mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-03-21 17:54:09 -05:00
476 lines
26 KiB
TypeScript
476 lines
26 KiB
TypeScript
"use client";
|
|
|
|
import React from "react";
|
|
import ReactMarkdown from "react-markdown";
|
|
import remarkGfm from "remark-gfm";
|
|
import rehypeSlug from "rehype-slug";
|
|
import TagSelector from "@/components/Submit/TagSelector";
|
|
import { baseRoms } from "@/data/baseRoms";
|
|
import Image from "next/image";
|
|
import { createClient } from "@/utils/supabase/client";
|
|
import { updateHack, saveHackCovers, presignCoverUpload } from "@/app/hack/actions";
|
|
import SortableCovers from "@/components/Hack/SortableCovers";
|
|
import Select from "@/components/Primitives/Select";
|
|
|
|
interface HackEditFormProps {
|
|
slug: string;
|
|
initial: {
|
|
title: string;
|
|
summary: string;
|
|
description: string;
|
|
base_rom: string;
|
|
language: string;
|
|
version: string;
|
|
box_art: string | null;
|
|
social_links: {
|
|
discord?: string;
|
|
twitter?: string;
|
|
pokecommunity?: string;
|
|
github?: string;
|
|
} | null;
|
|
tags: string[];
|
|
coverKeys: string[]; // storage keys for covers in order
|
|
signedCoverUrls?: string[]; // optional signed URLs aligned to keys
|
|
};
|
|
}
|
|
|
|
export default function HackEditForm({ slug, initial }: HackEditFormProps) {
|
|
const supabase = createClient();
|
|
const MAX_COVERS = 10;
|
|
const [title, setTitle] = React.useState(initial.title);
|
|
const [summary, setSummary] = React.useState(initial.summary);
|
|
const [description, setDescription] = React.useState(initial.description);
|
|
const [showMdPreview, setShowMdPreview] = React.useState(false);
|
|
const [baseRom, setBaseRom] = React.useState(initial.base_rom);
|
|
const [language, setLanguage] = React.useState(initial.language);
|
|
const [version, setVersion] = React.useState(initial.version);
|
|
const [boxArt, setBoxArt] = React.useState(initial.box_art || "");
|
|
const [discord, setDiscord] = React.useState(initial.social_links?.discord || "");
|
|
const [twitter, setTwitter] = React.useState(initial.social_links?.twitter || "");
|
|
const [pokecommunity, setPokecommunity] = React.useState(initial.social_links?.pokecommunity || "");
|
|
const [github, setGithub] = React.useState(initial.social_links?.github || "");
|
|
const [tags, setTags] = React.useState<string[]>(initial.tags || []);
|
|
|
|
// Baseline state used for change detection and reverting
|
|
const [baseline, setBaseline] = React.useState({
|
|
title: initial.title,
|
|
summary: initial.summary,
|
|
description: initial.description,
|
|
language: initial.language,
|
|
boxArt: initial.box_art || "",
|
|
tags: initial.tags || [],
|
|
discord: initial.social_links?.discord || "",
|
|
twitter: initial.social_links?.twitter || "",
|
|
pokecommunity: initial.social_links?.pokecommunity || "",
|
|
github: initial.social_links?.github || "",
|
|
});
|
|
|
|
type CoverItem =
|
|
| { type: "existing"; key: string; url: string }
|
|
| { type: "new"; file: File; url: string };
|
|
|
|
const [coverItems, setCoverItems] = React.useState<CoverItem[]>(() => {
|
|
const keys = initial.coverKeys || [];
|
|
const urls = initial.signedCoverUrls || [];
|
|
return keys.map((k, i) => ({ type: "existing", key: k, url: urls[i] || '' }));
|
|
});
|
|
const [coversBaseline, setCoversBaseline] = React.useState<{ keys: string[]; urls: string[] }>(() => ({
|
|
keys: initial.coverKeys || [],
|
|
urls: (initial.signedCoverUrls || []).slice(),
|
|
}));
|
|
const [saving, setSaving] = React.useState(false);
|
|
|
|
const urlLike = (s: string) => !s || /^https?:\/\//i.test(s);
|
|
|
|
function getAllowedSizesForPlatform(platform: "GB" | "GBC" | "GBA" | "NDS") {
|
|
if (platform === "GB" || platform === "GBC") return [{ w: 160, h: 144 }];
|
|
if (platform === "GBA") return [{ w: 240, h: 160 }];
|
|
return [{ w: 256, h: 192 }, { w: 256, h: 384 }];
|
|
}
|
|
|
|
async function validateImageDimensions(file: File, allowed: { w: number; h: number }[]) {
|
|
return new Promise<boolean>((resolve) => {
|
|
const img = document.createElement("img");
|
|
const url = URL.createObjectURL(file);
|
|
img.onload = () => {
|
|
const ok = allowed.some((s) => img.naturalWidth === s.w && img.naturalHeight === s.h);
|
|
URL.revokeObjectURL(url);
|
|
resolve(ok);
|
|
};
|
|
img.onerror = () => {
|
|
URL.revokeObjectURL(url);
|
|
resolve(false);
|
|
};
|
|
img.src = url;
|
|
});
|
|
}
|
|
|
|
const platformEntry = React.useMemo(() => baseRoms.find(r => r.id === baseRom), [baseRom]);
|
|
const allowedSizes = platformEntry ? getAllowedSizesForPlatform(platformEntry.platform) : [];
|
|
const overLimit = coverItems.length > MAX_COVERS;
|
|
|
|
// Change detection helpers
|
|
const arraysEqual = (a: string[], b: string[]) => a.length === b.length && a.every((v, i) => v === b[i]);
|
|
const tagsChanged = !arraysEqual(tags, baseline.tags);
|
|
const titleChanged = title !== baseline.title;
|
|
const summaryChanged = summary !== baseline.summary;
|
|
const descriptionChanged = description !== baseline.description;
|
|
const languageChanged = language !== baseline.language;
|
|
const boxArtChanged = boxArt !== baseline.boxArt;
|
|
const discordChanged = discord !== baseline.discord;
|
|
const twitterChanged = twitter !== baseline.twitter;
|
|
const pokeChanged = pokecommunity !== baseline.pokecommunity;
|
|
const githubChanged = github !== baseline.github;
|
|
const contentChanged = titleChanged || summaryChanged || descriptionChanged || languageChanged || boxArtChanged || tagsChanged || discordChanged || twitterChanged || pokeChanged || githubChanged;
|
|
|
|
const newItemsCount = coverItems.filter((i) => i.type === "new").length;
|
|
const currentExistingKeys = coverItems.filter((i): i is { type: "existing"; key: string; url: string } => i.type === "existing").map((i) => i.key);
|
|
const coversChanged = newItemsCount > 0 || !arraysEqual(currentExistingKeys, coversBaseline.keys);
|
|
|
|
function removeAt(index: number) {
|
|
setCoverItems((prev) => prev.filter((_, i) => i !== index));
|
|
}
|
|
|
|
function onReorder(oldIndex: number, newIndex: number) {
|
|
setCoverItems((prev) => {
|
|
const copy = prev.slice();
|
|
const [moved] = copy.splice(oldIndex, 1);
|
|
copy.splice(newIndex, 0, moved);
|
|
return copy;
|
|
});
|
|
}
|
|
|
|
function onAddFiles(files: File[]) {
|
|
const platform = platformEntry?.platform;
|
|
const sizes = platform ? getAllowedSizesForPlatform(platform) : [];
|
|
const doValidate = async () => {
|
|
const accepted: CoverItem[] = [];
|
|
for (const f of files) {
|
|
if (sizes.length === 0) {
|
|
accepted.push({ type: "new", file: f, url: URL.createObjectURL(f) });
|
|
continue;
|
|
}
|
|
const ok = await validateImageDimensions(f, sizes);
|
|
if (ok) {
|
|
accepted.push({ type: "new", file: f, url: URL.createObjectURL(f) });
|
|
}
|
|
}
|
|
setCoverItems((prev) => [...prev, ...accepted]);
|
|
};
|
|
void doValidate();
|
|
}
|
|
|
|
async function onSaveMeta() {
|
|
setSaving(true);
|
|
try {
|
|
const social = discord || twitter || pokecommunity || github ? { discord: discord || undefined, twitter: twitter || undefined, pokecommunity: pokecommunity || undefined, github: github || undefined } : null;
|
|
const updateArgs: any = { slug };
|
|
if (titleChanged) updateArgs.title = title.trim();
|
|
if (summaryChanged) updateArgs.summary = summary.trim();
|
|
if (descriptionChanged) updateArgs.description = description.trim();
|
|
if (languageChanged) updateArgs.language = language;
|
|
if (boxArtChanged) updateArgs.box_art = boxArt ? boxArt.trim() : null;
|
|
if (tagsChanged) updateArgs.tags = tags.slice();
|
|
if (discordChanged || twitterChanged || pokeChanged || githubChanged) updateArgs.social_links = social; // may be null to clear
|
|
|
|
const { ok, error } = await updateHack(updateArgs);
|
|
if (!ok) throw new Error(error || "Save failed");
|
|
// Update baseline on success to clear modified indicators
|
|
setBaseline({
|
|
title,
|
|
summary,
|
|
description,
|
|
language,
|
|
boxArt,
|
|
tags: tags.slice(),
|
|
discord,
|
|
twitter,
|
|
pokecommunity,
|
|
github,
|
|
});
|
|
} catch (e: any) {
|
|
alert(e.message || "Save failed");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function onSaveCovers() {
|
|
setSaving(true);
|
|
try {
|
|
// Upload any new files and build final key order
|
|
const keys: string[] = [];
|
|
for (let i = 0; i < coverItems.length; i++) {
|
|
const item = coverItems[i];
|
|
if (item.type === "existing") {
|
|
keys.push(item.key);
|
|
} else {
|
|
const ext = item.file.name.split('.').pop();
|
|
const path = `${slug}/${Date.now()}-${i}.${ext}`;
|
|
const presigned = await presignCoverUpload({ slug, objectKey: path });
|
|
if (!presigned.ok) throw new Error(presigned.error || 'Failed to presign cover upload');
|
|
await fetch(presigned.presignedUrl, { method: 'PUT', body: item.file, headers: { 'Content-Type': item.file.type || 'image/jpeg' } });
|
|
keys.push(path);
|
|
}
|
|
}
|
|
// Persist cover ordering/rows first
|
|
const saved = await saveHackCovers({ slug, coverUrls: keys });
|
|
if (!saved.ok) throw new Error(saved.error || 'Failed to save covers');
|
|
// Transform any new items into existing with their new keys
|
|
setCoverItems((prev) => prev.map((item, i) => item.type === "existing" ? item : { type: "existing", key: keys[i], url: item.url }));
|
|
// Update covers baseline to newly saved order/keys and keep current preview URLs
|
|
setCoversBaseline({ keys, urls: coverItems.map((c) => c.url) });
|
|
} catch (e: any) {
|
|
alert(e.message || "Failed to save covers");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
const summaryLimit = 120;
|
|
const summaryTooLong = summary.length > summaryLimit;
|
|
const contentHasErrors = summaryTooLong || (!!boxArt && !urlLike(boxArt));
|
|
|
|
return (
|
|
<div className="mt-6 flex flex-col gap-6 lg:grid lg:grid-cols-[minmax(0,1fr)_360px]">
|
|
<div className="space-y-6">
|
|
<div className="card-simple p-5">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-xl font-semibold tracking-tight">Content</h2>
|
|
<div className="flex items-center gap-3">
|
|
{contentChanged && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setTitle(baseline.title);
|
|
setSummary(baseline.summary);
|
|
setDescription(baseline.description);
|
|
setLanguage(baseline.language);
|
|
setBoxArt(baseline.boxArt);
|
|
setTags(baseline.tags.slice());
|
|
setDiscord(baseline.discord);
|
|
setTwitter(baseline.twitter);
|
|
setPokecommunity(baseline.pokecommunity);
|
|
setGithub(baseline.github);
|
|
}}
|
|
className="inline-flex items-center underline underline-offset-2 text-[12px] font-semibold cursor-pointer"
|
|
>
|
|
Revert all
|
|
</button>
|
|
)}
|
|
<button onClick={onSaveMeta} disabled={saving || !contentChanged || contentHasErrors} className="shine-wrap btn-premium h-8 min-w-[6rem] text-sm font-semibold dark:disabled:opacity-70 disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]">
|
|
<span>{saving ? "Saving…" : "Save Content/Details"}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 grid gap-4">
|
|
<div className="grid gap-2">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-sm text-foreground/80">Title</label>
|
|
{titleChanged && (
|
|
<div className="flex items-center gap-2 text-[11px] text-foreground/70">
|
|
<span>Modified</span>
|
|
<button type="button" onClick={() => setTitle(baseline.title)} className="inline-flex items-center underline underline-offset-2 text-[11px] cursor-pointer">Revert</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<input value={title} onChange={(e) => setTitle(e.target.value)} className={`h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${titleChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'}`} />
|
|
</div>
|
|
<div className="grid gap-1">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-sm text-foreground/80">Summary</label>
|
|
<div className="flex items-center gap-2">
|
|
{summaryChanged && (
|
|
<>
|
|
<span className="text-[11px] text-foreground/70 ml-2">Modified</span>
|
|
<button type="button" onClick={() => setSummary(baseline.summary)} className="inline-flex items-center underline underline-offset-2 text-[11px] cursor-pointer">Revert</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="relative w-full">
|
|
<input
|
|
value={summary}
|
|
onChange={(e) => setSummary(e.target.value)}
|
|
className={`w-full h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 pr-16 ${summary.length > summaryLimit ? "ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" : summaryChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'}`}
|
|
/>
|
|
<span className={`absolute right-3 top-1/2 -translate-y-1/2 text-[11px] ${summary.length > summaryLimit ? "text-red-300" : "text-foreground/60"}`}>
|
|
{summary.length}/{summaryLimit}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-sm text-foreground/80">Description</label>
|
|
<div className="flex items-center gap-1 text-xs">
|
|
<button type="button" onClick={() => setShowMdPreview(false)} className={`px-2 py-1 rounded ${!showMdPreview ? "bg-[var(--surface-2)] ring-1 ring-[var(--border)]" : "text-foreground/70"}`}>Write</button>
|
|
<button type="button" onClick={() => setShowMdPreview(true)} className={`px-2 py-1 rounded ${showMdPreview ? "bg-[var(--surface-2)] ring-1 ring-[var(--border)]" : "text-foreground/70"}`}>Preview</button>
|
|
{descriptionChanged && (
|
|
<div className="flex items-center gap-2 ml-2">
|
|
<span className="text-[11px] text-foreground/70">Modified</span>
|
|
<button type="button" onClick={() => setDescription(baseline.description)} className="inline-flex items-center underline underline-offset-2 text-[11px] cursor-pointer">Revert</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{!showMdPreview ? (
|
|
<textarea
|
|
rows={14}
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="Supports Markdown"
|
|
className={`rounded-md px-3 py-2 min-h-[14rem] text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${descriptionChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'}`}
|
|
/>
|
|
) : (
|
|
<div className={`prose max-w-none rounded-md min-h-[14rem] px-3 py-2 ring-1 ring-inset ${descriptionChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'} ${description ? "" : "text-foreground/60 text-sm"}`}>
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSlug]}>{description || "Nothing to preview yet."}</ReactMarkdown>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-sm text-foreground/80">Tags</label>
|
|
{tagsChanged && (
|
|
<div className="flex items-center gap-2 text-[11px] text-foreground/70">
|
|
<span>Modified</span>
|
|
<button type="button" onClick={() => setTags(baseline.tags.slice())} className="inline-flex items-center underline underline-offset-2 text-[11px] cursor-pointer">Revert</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className={`rounded-md ring-1 ring-inset ${tagsChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'ring-transparent'} p-1`}>
|
|
<TagSelector value={tags} onChange={setTags} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card p-5">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-xl font-semibold tracking-tight">Screenshots</h2>
|
|
<div className="flex items-center gap-2">
|
|
{coversChanged && (
|
|
<button type="button" onClick={() => setCoverItems(coversBaseline.keys.map((k, i) => ({ type: 'existing' as const, key: k, url: coversBaseline.urls[i] || '' })))} className="inline-flex h-8 items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 text-[12px] cursor-pointer">
|
|
Revert
|
|
</button>
|
|
)}
|
|
<button onClick={onSaveCovers} disabled={saving || !coversChanged || overLimit} className="inline-flex shine-wrap btn-premium h-8 min-w-[6rem] text-sm font-semibold dark:disabled:opacity-70 disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]">
|
|
<span>{saving ? "Saving…" : "Save images"}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 grid gap-4">
|
|
{allowedSizes.length > 0 && (
|
|
<p className="text-xs text-foreground/60">Allowed sizes: {allowedSizes.map((s) => `${s.w}x${s.h}`).join(", ")}</p>
|
|
)}
|
|
<input
|
|
type="file"
|
|
multiple
|
|
accept="image/*"
|
|
onChange={(e) => onAddFiles(Array.from(e.target.files || []))}
|
|
className="w-full rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none"
|
|
/>
|
|
|
|
{coverItems.length === 0 ? (
|
|
<p className="text-sm text-foreground/70">No screenshots yet.</p>
|
|
) : (
|
|
<SortableCovers
|
|
items={coverItems.map((item, i) => ({
|
|
id: item.type === 'existing' ? `e:${(item as any).key}` : `n:${(item as any).file.name}-${i}`,
|
|
url: item.url,
|
|
filename: item.type === 'existing' ? item.key.split('/').pop() || `Cover ${i+1}` : (item as any).file.name,
|
|
isNew: item.type === 'new'
|
|
}))}
|
|
onReorder={onReorder}
|
|
onRemove={removeAt}
|
|
/>
|
|
)}
|
|
|
|
<div className="text-xs text-foreground/60 flex justify-between">
|
|
<p>Images: <span className={overLimit ? "text-red-300 font-bold" : "text-foreground/60"}>{coverItems.length}</span>/{MAX_COVERS}</p>
|
|
{overLimit && <p className="text-red-300/80 italic">Remove some to save.</p>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<aside className="space-y-6 self-start w-full lg:w-auto">
|
|
<div className="card p-5">
|
|
<h3 className="text-[15px] font-semibold tracking-tight">Details</h3>
|
|
<div className="mt-3 grid gap-3 text-sm">
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-foreground/80">Base ROM</label>
|
|
<p className="flex items-center h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)] text-foreground/60 select-none cursor-not-allowed">
|
|
{baseRoms.find(r => r.id === baseRom)?.name || baseRom}
|
|
</p>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-sm text-foreground/80">Language</label>
|
|
{languageChanged && (
|
|
<div className="ml-auto flex items-center gap-2 text-[11px] text-foreground/70">
|
|
<span>Modified</span>
|
|
<button type="button" onClick={() => setLanguage(baseline.language)} className="inline-flex items-center underline underline-offset-2 text-[11px] cursor-pointer">Revert</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Select
|
|
value={language}
|
|
onChange={setLanguage}
|
|
className={languageChanged ? 'ring-[var(--ring)]' : ''}
|
|
options={['English','Spanish','French','German','Italian','Portuguese','Japanese','Chinese','Korean','Other'].map(l => ({
|
|
value: l,
|
|
label: l,
|
|
}))}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-foreground/80">Current version</label>
|
|
<p className="flex items-center h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)] text-foreground/60 select-none cursor-not-allowed">
|
|
{version}
|
|
</p>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-foreground/80">Box art URL</label>
|
|
<div className="flex items-center justify-between">
|
|
<input value={boxArt} onChange={(e) => setBoxArt(e.target.value)} placeholder="https://..." className={`flex-1 h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${boxArt && !urlLike(boxArt) ? "ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" : boxArtChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'}`} />
|
|
{boxArtChanged && (
|
|
<button type="button" onClick={() => setBoxArt(baseline.boxArt)} className="ml-3 text-[11px] underline underline-offset-2 cursor-pointer">Revert</button>
|
|
)}
|
|
</div>
|
|
{boxArt && urlLike(boxArt) && (
|
|
<div className="relative aspect-square w-full max-h-[300px] overflow-hidden rounded ring-1 ring-[var(--border)]">
|
|
<Image src={boxArt} alt="Box art preview" fill className="object-contain" unoptimized />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-foreground/80">Social links</label>
|
|
<div className="grid gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<input value={discord} onChange={(e) => setDiscord(e.target.value)} placeholder="Discord invite URL" className={`flex-1 h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${discord && !urlLike(discord) ? "ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" : discordChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'}`} />
|
|
{discordChanged && <button type="button" onClick={() => setDiscord(baseline.discord)} className="text-[11px] underline underline-offset-2 cursor-pointer">Revert</button>}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<input value={twitter} onChange={(e) => setTwitter(e.target.value)} placeholder="Twitter/X profile URL" className={`flex-1 h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${twitter && !urlLike(twitter) ? "ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" : twitterChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'}`} />
|
|
{twitterChanged && <button type="button" onClick={() => setTwitter(baseline.twitter)} className="text-[11px] underline underline-offset-2 cursor-pointer">Revert</button>}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<input value={pokecommunity} onChange={(e) => setPokecommunity(e.target.value)} placeholder="PokeCommunity thread URL" className={`flex-1 h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${pokecommunity && !urlLike(pokecommunity) ? "ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" : pokeChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'}`} />
|
|
{pokeChanged && <button type="button" onClick={() => setPokecommunity(baseline.pokecommunity)} className="text-[11px] underline underline-offset-2 cursor-pointer">Revert</button>}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<input value={github} onChange={(e) => setGithub(e.target.value)} placeholder="GitHub repository URL" className={`flex-1 h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${github && !urlLike(github) ? "ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" : githubChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'}`} />
|
|
{githubChanged && <button type="button" onClick={() => setGithub(baseline.github)} className="text-[11px] underline underline-offset-2 cursor-pointer">Revert</button>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|