diff --git a/src/app/dashboard/archiver-actions.ts b/src/app/dashboard/archiver-actions.ts new file mode 100644 index 0000000..6f1da45 --- /dev/null +++ b/src/app/dashboard/archiver-actions.ts @@ -0,0 +1,121 @@ +"use server"; + +import { createClient } from "@/utils/supabase/server"; + +export async function getArchivers() { + const supabase = await createClient(); + const { data: isAdmin } = await supabase.rpc("is_admin"); + if (!isAdmin) { + return { ok: false, error: "Unauthorized" } as const; + } + + // Get all profiles + const { data: profiles, error: profilesError } = await supabase + .from("profiles") + .select("id, username") + .order("username", { ascending: true }); + + if (profilesError) { + return { ok: false, error: profilesError.message } as const; + } + + // Check each profile for archiver claim + const archivers: { id: string; username: string | null }[] = []; + for (const profile of profiles || []) { + const { data: claim } = await supabase.rpc("get_claim", { + uid: profile.id, + claim: "archiver", + }); + if (claim && typeof claim === "object" && "error" in claim) continue; + if (claim === true || (typeof claim === "object" && claim !== null && !("error" in claim))) { + archivers.push({ id: profile.id, username: profile.username }); + } + } + + return { ok: true, archivers } as const; +} + +export async function searchUsersForArchiver(query: string) { + const supabase = await createClient(); + const { data: isAdmin } = await supabase.rpc("is_admin"); + if (!isAdmin) { + return { ok: false, error: "Unauthorized" } as const; + } + + const trimmed = query.trim(); + if (!trimmed) { + return { ok: true, users: [] } as const; + } + + // If the query looks like a UUID, search by exact ID; otherwise search by username (ilike) + const looksLikeUuid = + trimmed.length === 36 && + /^[0-9a-fA-F-]+$/.test(trimmed); + + let q = supabase + .from("profiles") + .select("id, username") + .limit(10); + + if (looksLikeUuid) { + q = q.eq("id", trimmed); + } else { + q = q.ilike("username", `%${trimmed}%`); + } + + const { data: profiles, error: profilesError } = await q; + + if (profilesError) { + return { ok: false, error: profilesError.message } as const; + } + + return { + ok: true, + users: (profiles || []).map((p) => ({ id: p.id, username: p.username })), + } as const; +} + +export async function addArchiverRole(userId: string) { + const supabase = await createClient(); + const { data: isAdmin } = await supabase.rpc("is_admin"); + if (!isAdmin) { + return { ok: false, error: "Unauthorized" } as const; + } + + const { data, error: rpcError } = await supabase.rpc("set_claim", { + uid: userId, + claim: "archiver", + value: true, + }); + + if (rpcError) { + return { ok: false, error: rpcError.message } as const; + } + if (data !== "OK") { + return { ok: false, error: data || "Failed to add archiver" } as const; + } + + return { ok: true } as const; +} + +export async function removeArchiverRole(userId: string) { + const supabase = await createClient(); + const { data: isAdmin } = await supabase.rpc("is_admin"); + if (!isAdmin) { + return { ok: false, error: "Unauthorized" } as const; + } + + const { data, error: rpcError } = await supabase.rpc("delete_claim", { + uid: userId, + claim: "archiver", + }); + + if (rpcError) { + return { ok: false, error: rpcError.message } as const; + } + if (data !== "OK") { + return { ok: false, error: data || "Failed to remove archiver" } as const; + } + + return { ok: true } as const; +} diff --git a/src/app/dashboard/archives/actions.ts b/src/app/dashboard/archives/actions.ts new file mode 100644 index 0000000..dfddc34 --- /dev/null +++ b/src/app/dashboard/archives/actions.ts @@ -0,0 +1,116 @@ +"use server"; + +import { createClient } from "@/utils/supabase/server"; + +export async function getArchives(args: { + page?: number; + limit?: number; + search?: string; + sortBy?: "title" | "created_at" | "original_author"; + sortOrder?: "asc" | "desc"; +}) { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) { + return { ok: false, error: "Unauthorized" } as const; + } + + // Check if user is archiver (or admin) + const { data: isArchiver } = await supabase.rpc("is_archiver"); + if (!isArchiver) { + return { ok: false, error: "Forbidden" } as const; + } + + const page = args.page || 1; + const limit = args.limit || 50; + const offset = (page - 1) * limit; + const search = args.search?.trim() || ""; + const sortBy = args.sortBy || "created_at"; + const sortOrder = args.sortOrder || "desc"; + + let query = supabase + .from("hacks") + .select("slug,title,original_author,base_rom,created_at,created_by,approved", { count: "exact" }) + .not("original_author", "is", null) + .is("current_patch", null) + .order(sortBy, { ascending: sortOrder === "asc" }) + .range(offset, offset + limit - 1); + + if (search) { + query = query.or(`title.ilike.%${search}%,original_author.ilike.%${search}%,base_rom.ilike.%${search}%`); + } + + const { data: hacks, error, count } = await query; + + if (error) { + return { ok: false, error: error.message } as const; + } + + // Fetch creator usernames + const creatorIds = [...new Set((hacks || []).map((h) => h.created_by as string))]; + const { data: profiles } = await supabase + .from("profiles") + .select("id,username") + .in("id", creatorIds); + + const usernameById = new Map(); + (profiles || []).forEach((p) => usernameById.set(p.id, p.username)); + + const archives = (hacks || []).map((h) => ({ + slug: h.slug, + title: h.title, + original_author: h.original_author, + base_rom: h.base_rom, + created_at: h.created_at, + created_by: h.created_by, + creator_username: usernameById.get(h.created_by as string) || null, + approved: h.approved, + })); + + return { + ok: true, + archives, + total: count || 0, + page, + limit, + totalPages: Math.ceil((count || 0) / limit), + } as const; +} + +export async function deleteArchive(slug: string) { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) { + return { ok: false, error: "Unauthorized" } as const; + } + + // Only admins can delete archives + const { data: isAdmin } = await supabase.rpc("is_admin"); + if (!isAdmin) { + return { ok: false, error: "Forbidden" } as const; + } + + // Verify it's an Archive hack + const { data: hack } = await supabase + .from("hacks") + .select("slug, original_author, current_patch") + .eq("slug", slug) + .maybeSingle(); + + if (!hack) { + return { ok: false, error: "Archive not found" } as const; + } + + if (hack.original_author == null || hack.current_patch != null) { + return { ok: false, error: "This is not an Archive hack" } as const; + } + + // Delete the hack (cascade will handle covers and tags) + const { error: deleteError } = await supabase.from("hacks").delete().eq("slug", slug); + + if (deleteError) { + return { ok: false, error: deleteError.message } as const; + } + + return { ok: true } as const; +} diff --git a/src/app/dashboard/archives/page.tsx b/src/app/dashboard/archives/page.tsx new file mode 100644 index 0000000..663e6c6 --- /dev/null +++ b/src/app/dashboard/archives/page.tsx @@ -0,0 +1,34 @@ +import { createClient } from "@/utils/supabase/server"; +import { redirect } from "next/navigation"; +import ArchivesList from "@/components/Dashboard/ArchivesList"; +import { getArchives } from "./actions"; + +export default async function ArchivesPage() { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) { + redirect("/login"); + } + + // Check if user is admin or archiver + const { data: isAdmin } = await supabase.rpc("is_admin"); + const { data: isArchiver } = await supabase.rpc("is_archiver"); + if (!isAdmin && !isArchiver) { + redirect("/dashboard"); + } + + // Fetch initial page of archives + const initialData = await getArchives({ page: 1, limit: 50 }); + + return ( +
+
+

Archive Management

+

+ Manage all Archive hacks. Archive hacks are informational entries preserved for historical reference. +

+
+ +
+ ); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index a8ac65a..a513eda 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { FiExternalLink } from "react-icons/fi"; import DashboardClient from "@/components/Dashboard/DashboardClient"; +import ArchiverManagement from "@/components/Dashboard/ArchiverManagement"; import { getDownloadsSeriesAll } from "./actions"; import type { HackRow } from "@/components/Dashboard/DashboardClient"; @@ -51,10 +52,15 @@ export default async function DashboardPage() { const { username, full_name } = profile; + // Check if user is admin or archiver for archives link + const { data: isArchiver } = await supa.rpc("is_archiver"); + const canAccessArchives = isAdmin || isArchiver; + const { data: hacks } = await supa .from("hacks") - .select("slug,title,approved,updated_at,downloads,current_patch,version,created_at") + .select("slug,title,approved,updated_at,downloads,current_patch,version,created_at,original_author") .eq("created_by", user.id) + .is("original_author", null) // Exclude Archive hacks .order("updated_at", { ascending: false }); const seriesAll = await getDownloadsSeriesAll({ days: 30 }); @@ -138,6 +144,25 @@ export default async function DashboardPage() { )} + + {isAdmin && } + + {canAccessArchives && ( +
+
+

Archive Management

+ + View all archives + +
+

+ Archive hacks are informational entries preserved for historical reference. They do not include patch files. +

+
+ )} ); } diff --git a/src/app/hack/[slug]/actions.ts b/src/app/hack/[slug]/actions.ts index 0a7be8a..4639608 100644 --- a/src/app/hack/[slug]/actions.ts +++ b/src/app/hack/[slug]/actions.ts @@ -14,7 +14,7 @@ export async function getSignedPatchUrl(slug: string): Promise<{ ok: true; url: // Fetch hack to validate it exists const { data: hack, error: hackError } = await supabase .from("hacks") - .select("slug, approved, created_by, current_patch") + .select("slug, approved, created_by, current_patch, original_author") .eq("slug", slug) .maybeSingle(); @@ -34,6 +34,12 @@ export async function getSignedPatchUrl(slug: string): Promise<{ ok: true; url: } } + // Check if this is an Archive hack (no patch available) + const isArchive = hack.original_author != null && hack.current_patch === null; + if (isArchive) { + return { ok: false, error: "Archive hacks do not have patch files available" }; + } + // Check if patch exists if (hack.current_patch == null) { return { ok: false, error: "No patch available" }; diff --git a/src/app/hack/[slug]/edit/page.tsx b/src/app/hack/[slug]/edit/page.tsx index 765a17e..04433aa 100644 --- a/src/app/hack/[slug]/edit/page.tsx +++ b/src/app/hack/[slug]/edit/page.tsx @@ -18,11 +18,22 @@ export default async function EditHackPage({ params }: EditPageProps) { const { data: hack } = await supabase .from("hacks") - .select("slug,title,summary,description,base_rom,language,box_art,social_links,created_by,current_patch") + .select("slug,title,summary,description,base_rom,language,box_art,social_links,created_by,current_patch,original_author") .eq("slug", slug) .maybeSingle(); if (!hack) return notFound(); - if (hack.created_by !== user!.id) { + + // Check if user can edit: either they're the creator, or they're admin/archiver editing an Archive hack + const canEditAsCreator = hack.created_by === user!.id; + const isArchive = hack.original_author != null && hack.current_patch === null; + let canEditAsAdminOrArchiver = false; + if (isArchive && !canEditAsCreator) { + // Admin check automatically included with is_archiver check + const { data: isArchiver } = await supabase.rpc("is_archiver"); + canEditAsAdminOrArchiver = !!isArchiver; + } + + if (!canEditAsCreator && !canEditAsAdminOrArchiver) { redirect(`/hack/${slug}`); } @@ -63,7 +74,7 @@ export default async function EditHackPage({ params }: EditPageProps) { description: hack.description, base_rom: hack.base_rom, language: hack.language, - version: version || "Pre-release", + version: isArchive ? "Archive" : (version || "Pre-release"), box_art: hack.box_art, social_links: (hack.social_links as unknown) as { discord?: string; twitter?: string; pokecommunity?: string } | null, tags, @@ -84,9 +95,11 @@ export default async function EditHackPage({ params }: EditPageProps) { Back to hack - - Upload new version - + {!isArchive && ( + + Upload new version + + )}
diff --git a/src/app/hack/[slug]/page.tsx b/src/app/hack/[slug]/page.tsx index 7de97da..fabc18a 100644 --- a/src/app/hack/[slug]/page.tsx +++ b/src/app/hack/[slug]/page.tsx @@ -18,6 +18,7 @@ import { headers } from "next/headers"; import { MenuItem } from "@headlessui/react"; import { FaCircleCheck } from "react-icons/fa6"; import { sortOrderedTags } from "@/utils/format"; +import { FaArchive } from "react-icons/fa"; interface HackDetailProps { params: Promise<{ slug: string }>; @@ -44,7 +45,7 @@ export async function generateMetadata({ params }: HackDetailProps): Promise r.id === hack.base_rom)?.name ?? "Pokémon"; const pageUrl = `/hack/${slug}`; - const title = `${hack.title} | ROM hack download`; - const description = `Play ${hack.title}, a fan-made Pokémon ROM hack for ${baseRomName}. ${hack.summary}`; + const title = isArchive ? `${hack.title} | Archive` : `${hack.title} | ROM hack download`; + const description = isArchive + ? `Archive entry for ${hack.title}, a fan-made ROM hack for ${baseRomName}. ${hack.summary}` + : `Play ${hack.title}, a fan-made ROM hack for ${baseRomName}. ${hack.summary}`; const keywords: string[] = [ hack.title, @@ -111,12 +115,15 @@ export default async function HackDetail({ params }: HackDetailProps) { const supabase = await createClient(); const { data: hack, error } = await supabase .from("hacks") - .select("slug,title,summary,description,base_rom,created_at,updated_at,downloads,current_patch,box_art,social_links,created_by,approved") + .select("slug,title,summary,description,base_rom,created_at,updated_at,downloads,current_patch,box_art,social_links,created_by,approved,original_author") .eq("slug", slug) .maybeSingle(); if (error || !hack) return notFound(); const baseRom = baseRoms.find((r) => r.id === hack.base_rom); + // Detect if this is an Archive hack + const isArchive = hack.original_author != null && hack.current_patch === null; + let images: string[] = []; const { data: covers } = await supabase .from("hack_covers") @@ -156,20 +163,21 @@ export default async function HackDetail({ params }: HackDetailProps) { data: { user }, } = await supabase.auth.getUser(); const canEdit = !!user && user.id === (hack.created_by as string); + const canUploadPatch = (!!user && user.id === (hack.created_by as string) && !isArchive); let isAdmin = false; - if (!hack.approved && !canEdit) { + if ((!hack.approved && !canEdit) || isArchive) { const { data: admin } = await supabase.rpc("is_admin"); if (admin) { isAdmin = true; - } else { + } else if (!isArchive) { return notFound(); } } // Get patch info, but don't sign URL yet (happens on user interaction) let patchFilename: string | null = null; - let patchVersion = ""; + let patchVersion = isArchive ? "Archive" : ""; let patchId: number | null = null; let lastUpdated: string | null = null; let patchCreatedAt: string | null = null; @@ -250,16 +258,38 @@ export default async function HackDetail({ params }: HackDetailProps) { type="application/ld+json" dangerouslySetInnerHTML={{ __html: serialize(jsonLd, { isJSON: true }) }} /> - + {!isArchive && ( + + )} + + {isArchive && ( +
+
+
+ +
+
+
+
+

+ Archive Entry +

+

+ This is an archive entry for informational and preservation purposes only. No patch file is available for download. +

+
+
+
+ )} {!hack.approved && ( isAdmin ? ( @@ -306,7 +336,7 @@ export default async function HackDetail({ params }: HackDetailProps) { {patchVersion || "Pre-release"}
-

By {author}

+

By {isArchive ? (hack.original_author || "Unknown") : author}

{hack.summary}

@@ -318,9 +348,9 @@ export default async function HackDetail({ params }: HackDetailProps) { ))}
- - - {isAdmin && ( + {!isArchive && } + + {isAdmin && !hack.approved && (
)} -
-

- This page provides the official patch file for {hack.title}. You can safely download the patched ROM for this hack - using our built-in patcher. -

-

- By pressing the "Patch Now" button, your browser will apply the downloaded {hack.title} .bps patch file to your legally-obtained {baseRom?.name} ROM. The patched ROM will then be automatically downloaded. -

-

- No pre-patched ROMs or base ROMs are hosted or distributed on this site. All patching is done locally on your device. -

-
+ {isArchive ? ( +
+

+ This is an archive entry for {hack.title} preserved for informational purposes. + {hack.original_author && ( + The original author of this hack is {hack.original_author}. + )} +

+

+ Archive entries do not include patch files and are maintained for historical reference and preservation purposes only. +

+
+ ) : ( +
+

+ This page provides the official patch file for {hack.title}. You can safely download the patched ROM for this hack + using our built-in patcher. +

+

+ By pressing the "Patch Now" button, your browser will apply the downloaded {hack.title} .bps patch file to your legally-obtained {baseRom?.name} ROM. The patched ROM will then be automatically downloaded. +

+

+ No pre-patched ROMs or base ROMs are hosted or distributed on this site. All patching is done locally on your device. +

+
+ )} diff --git a/src/app/hack/actions.ts b/src/app/hack/actions.ts index e3bd5f1..c220df8 100644 --- a/src/app/hack/actions.ts +++ b/src/app/hack/actions.ts @@ -28,12 +28,25 @@ export async function updateHack(args: { const { data: hack, error: hErr } = await supabase .from("hacks") - .select("slug, created_by") + .select("slug, created_by, current_patch, original_author") .eq("slug", args.slug) .maybeSingle(); if (hErr) return { ok: false, error: hErr.message } as const; if (!hack) return { ok: false, error: "Hack not found" } as const; - if (hack.created_by !== user.id) return { ok: false, error: "Forbidden" } as const; + + // Check if user can edit: either they're the creator, or they're admin/archiver editing an Archive hack + const canEditAsCreator = hack.created_by === user.id; + const isArchive = hack.original_author != null && hack.current_patch === null; + let canEditAsAdminOrArchiver = false; + if (isArchive && !canEditAsCreator) { + const { data: isAdmin } = await supabase.rpc("is_admin"); + const { data: isArchiver } = await supabase.rpc("is_archiver"); + canEditAsAdminOrArchiver = !!isAdmin || !!isArchiver; + } + + if (!canEditAsCreator && !canEditAsAdminOrArchiver) { + return { ok: false, error: "Forbidden" } as const; + } const updatePayload: TablesInsert<"hacks"> | any = {}; if (args.title !== undefined) updatePayload.title = args.title; diff --git a/src/app/page.tsx b/src/app/page.tsx index 45fd949..3071363 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -21,7 +21,7 @@ export default async function Home() { // Fetch top 6 approved hacks ordered by downloads const { data: popularHacks } = await supabase .from("hacks") - .select("slug,title,summary,description,base_rom,downloads,created_by,current_patch") + .select("slug,title,summary,description,base_rom,downloads,created_by,current_patch,original_author") .eq("approved", true) .order("downloads", { ascending: false }) .limit(6); @@ -87,7 +87,7 @@ export default async function Home() { .maybeSingle(); mappedVersions.set(r.slug, currentPatch?.version || "Pre-release"); } else { - mappedVersions.set(r.slug, "Pre-release"); + mappedVersions.set(r.slug, r.original_author ? "Archive" : "Pre-release"); } }) ); diff --git a/src/app/submit/actions.ts b/src/app/submit/actions.ts index 6934284..e3b8500 100644 --- a/src/app/submit/actions.ts +++ b/src/app/submit/actions.ts @@ -45,11 +45,19 @@ export async function prepareSubmission(formData: FormData) { const twitter = (formData.get("twitter") as string)?.trim(); const pokecommunity = (formData.get("pokecommunity") as string)?.trim(); const tags = (formData.get("tags") as string)?.split(",").map((t) => t.trim()).filter(Boolean) || []; + const original_author = (formData.get("original_author") as string)?.trim() || null; + const isArchive = formData.get("isArchive") === "true"; - if (!title || !summary || !description || !base_rom || !language || !version) { + // For archives, version is not required; for regular hacks, it is + if (!title || !summary || !description || !base_rom || !language || (!isArchive && !version)) { return { ok: false, error: "Missing required fields" } as const; } + // For archives, original_author is required + if (isArchive && !original_author) { + return { ok: false, error: "Original author is required for Archive hacks" } as const; + } + const baseSlug = slugify(title); const slug = await ensureUniqueSlug(baseSlug, supabase); @@ -69,13 +77,15 @@ export async function prepareSubmission(formData: FormData) { description, base_rom, language, - version, + version: version || "Archive", created_by: user.id, downloads: 0, box_art, social_links, - approved: false, + approved: isArchive, // Auto-approve archives patch_url: "", + original_author: original_author || null, + current_patch: null, // Archives don't have patches } as HackInsert; const { error: insertErr } = await supabase.from("hacks").insert(insertPayload); diff --git a/src/app/submit/page.tsx b/src/app/submit/page.tsx index 1095a7e..247e98f 100644 --- a/src/app/submit/page.tsx +++ b/src/app/submit/page.tsx @@ -1,4 +1,4 @@ -import HackForm from "@/components/Hack/HackForm"; +import SubmitPageClient from "@/components/Submit/SubmitPageClient"; import { createClient } from "@/utils/supabase/server"; import SubmitAuthOverlay from "@/components/Submit/SubmitAuthOverlay"; import { Metadata } from "next"; @@ -13,6 +13,7 @@ export default async function SubmitPage() { const supabase = await createClient(); const { data: { user } } = await supabase.auth.getUser(); let needsInitialSetup = false; + let canCreateArchive = false; if (user) { const { data: profile } = await supabase .from('profiles') @@ -20,6 +21,10 @@ export default async function SubmitPage() { .eq('id', user.id) .maybeSingle(); needsInitialSetup = !profile || profile.username == null; + + // Check if user is archiver (or admin) + const { data: isArchiver } = await supabase.rpc("is_archiver"); + canCreateArchive = !!isArchiver; } return ( @@ -27,7 +32,7 @@ export default async function SubmitPage() {

Submit your ROM hack

Share your hack so others can discover and play it.

- +
{!user ? ( ([]); + const [loading, setLoading] = React.useState(true); + const [searchQuery, setSearchQuery] = React.useState(""); + const [searchResults, setSearchResults] = React.useState<{ id: string; username: string | null }[]>([]); + const [searching, setSearching] = React.useState(false); + const [error, setError] = React.useState(null); + + // Load current archivers + React.useEffect(() => { + loadArchivers(); + }, []); + + // Debounce search query + React.useEffect(() => { + if (!searchQuery.trim()) { + setSearchResults([]); + setSearching(false); + return; + } + + setSearching(true); + const timer = setTimeout(async () => { + try { + setSearching(true); + setError(null); + const result = await searchUsersForArchiver(searchQuery); + if (!result.ok) { + throw new Error(result.error); + } + setSearchResults([...result.users]); + } catch (err: any) { + setError(err?.message || "Failed to search users"); + } finally { + setSearching(false); + } + }, 300); + + return () => { + clearTimeout(timer); + setSearching(false); + }; + }, [searchQuery]); + + async function loadArchivers() { + try { + setLoading(true); + setError(null); + const result = await getArchivers(); + if (!result.ok) { + throw new Error(result.error); + } + setArchivers(result.archivers); + } catch (err: any) { + setError(err?.message || "Failed to load archivers"); + } finally { + setLoading(false); + } + } + + async function addArchiver(userId: string) { + try { + setError(null); + const result = await addArchiverRole(userId); + if (!result.ok) { + throw new Error(result.error); + } + await loadArchivers(); + setSearchQuery(""); + setSearchResults([]); + } catch (err: any) { + setError(err?.message || "Failed to add archiver"); + } + } + + async function removeArchiver(userId: string) { + try { + setError(null); + const result = await removeArchiverRole(userId); + if (!result.ok) { + throw new Error(result.error); + } + await loadArchivers(); + } catch (err: any) { + setError(err?.message || "Failed to remove archiver"); + } + } + + const handleSearchChange = React.useCallback( + (e: React.ChangeEvent) => { + setSearchQuery(e.target.value); + }, + [] + ); + + const isArchiver = (userId: string) => archivers.some((a) => a.id === userId); + + return ( +
+

Archiver Role Management

+
+ {error && ( +
+ {error} +
+ )} + + {/* Search for users */} +
+ +
+ + + {searching && ( + + )} +
+ {searchResults.length > 0 && ( +
+ {searchResults.map((user) => ( +
+
+ {user.username ? `@${user.username}` : "No username"} + {user.id} +
+ {isArchiver(user.id) ? ( + Already archiver + ) : ( + + )} +
+ ))} +
+ )} +
+ + {/* Current archivers list */} +
+ + {loading ? ( +
Loading...
+ ) : archivers.length === 0 ? ( +
No archivers assigned
+ ) : ( +
+ {archivers.map((archiver) => ( +
+
+ {archiver.username ? `@${archiver.username}` : "No username"} + {archiver.id} +
+ +
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/src/components/Dashboard/ArchivesList.tsx b/src/components/Dashboard/ArchivesList.tsx new file mode 100644 index 0000000..574ec60 --- /dev/null +++ b/src/components/Dashboard/ArchivesList.tsx @@ -0,0 +1,272 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +import { FiExternalLink, FiEdit2, FiTrash2, FiChevronLeft, FiChevronRight, FiArrowDown, FiSearch, FiLoader } from "react-icons/fi"; +import { getArchives, deleteArchive } from "@/app/dashboard/archives/actions"; +import { baseRoms } from "@/data/baseRoms"; + +type Archive = { + slug: string; + title: string; + original_author: string | null; + base_rom: string; + created_at: string; + created_by: string; + creator_username: string | null; + approved: boolean; +}; + +type ArchivesData = + | { ok: true; archives: Archive[]; total: number; page: number; limit: number; totalPages: number } + | { ok: false; error: string }; + +export default function ArchivesList({ initialData, isAdmin = false }: { initialData: ArchivesData; isAdmin?: boolean }) { + const [data, setData] = React.useState(initialData); + const [loading, setLoading] = React.useState(false); + const [page, setPage] = React.useState(1); + const [search, setSearch] = React.useState(""); + const [debouncedSearch, setDebouncedSearch] = React.useState(""); + const [sortBy, setSortBy] = React.useState<"title" | "created_at" | "original_author">("created_at"); + const [sortOrder, setSortOrder] = React.useState<"asc" | "desc">("desc"); + const [deletingSlug, setDeletingSlug] = React.useState(null); + + // Debounce search input + React.useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(search); + setPage(1); // Reset to first page on search + }, 300); + + return () => clearTimeout(timer); + }, [search]); + + const loadArchives = React.useCallback(async () => { + setLoading(true); + try { + const result = await getArchives({ page, limit: 50, search: debouncedSearch, sortBy, sortOrder }); + setData(result); + } catch (err: any) { + setData({ ok: false, error: err?.message || "Failed to load archives" }); + } finally { + setLoading(false); + } + }, [page, debouncedSearch, sortBy, sortOrder]); + + React.useEffect(() => { + loadArchives(); + }, [loadArchives]); + + async function handleDelete(slug: string) { + if (!confirm(`Are you sure you want to delete the archive "${slug}"? This action cannot be undone.`)) { + return; + } + + setDeletingSlug(slug); + try { + const result = await deleteArchive(slug); + if (!result.ok) { + alert(result.error || "Failed to delete archive"); + return; + } + // Reload current page + await loadArchives(); + } catch (err: any) { + alert(err?.message || "Failed to delete archive"); + } finally { + setDeletingSlug(null); + } + } + + const handleSearchChange = React.useCallback((e: React.ChangeEvent) => { + setSearch(e.target.value); + }, []); + + if (!data.ok) { + return ( +
+ {data.error} +
+ ); + } + + const { archives, total, totalPages } = data; + + return ( +
+ {/* Search and filters */} +
+
+ + + {search !== debouncedSearch && ( + + )} +
+
+ + +
+
+ + {/* Results count */} +
+ Showing {archives.length} of {total} archive{total !== 1 ? "s" : ""} +
+ + {/* Table */} +
+ {loading ? ( +
Loading...
+ ) : archives.length === 0 ? ( +
No archives found
+ ) : ( + <> + {/* Desktop header */} +
+
Title
+
Original Author
+
Base ROM
+
Archived by
+
Actions
+
+
+ {archives.map((archive) => { + const baseRom = baseRoms.find((r) => r.id === archive.base_rom); + const createdDate = new Date(archive.created_at).toLocaleDateString(); + const creator = archive.creator_username ? `@${archive.creator_username}` : "Unknown"; + + return ( +
+ {/* Desktop row */} +
+ +
+
{archive.title}
+
/{archive.slug}
+
+ + +
{archive.original_author || "—"}
+
{baseRom?.name || archive.base_rom}
+
+
{creator}
+
{createdDate}
+
+
+ + + + {isAdmin && ( + + )} +
+
+ + {/* Mobile card */} +
+
+ +
{archive.title}
+
/{archive.slug}
+ + +
+
+ Author: {archive.original_author || "—"} + | + Base: {baseRom?.name || archive.base_rom} +
+
+ Archived by {creator} on {createdDate} +
+
+ + + Edit + + {isAdmin && ( + + )} +
+
+
+ ); + })} +
+ + )} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ +
+ Page {page} of {totalPages} +
+ +
+ )} +
+ ); +} diff --git a/src/components/Discover/DiscoverBrowser.tsx b/src/components/Discover/DiscoverBrowser.tsx index 89abeca..cb0b803 100644 --- a/src/components/Discover/DiscoverBrowser.tsx +++ b/src/components/Discover/DiscoverBrowser.tsx @@ -56,7 +56,7 @@ export default function DiscoverBrowser() { const { data: rows } = await supabase .from("hacks") - .select("slug,title,summary,description,base_rom,downloads,created_by,updated_at,current_patch") + .select("slug,title,summary,description,base_rom,downloads,created_by,updated_at,current_patch,original_author") .order(orderBy, { ascending: false }); const slugs = (rows || []).map((r) => r.slug); const { data: coverRows } = await supabase @@ -111,7 +111,7 @@ export default function DiscoverBrowser() { .maybeSingle(); mappedVersions.set(r.slug, currentPatch?.version || "Pre-release"); } else { - mappedVersions.set(r.slug, "Pre-release"); + mappedVersions.set(r.slug, r.original_author ? "Archive" : "Pre-release"); } })); // Fetch all tags with category to build UI groups diff --git a/src/components/Hack/HackForm.tsx b/src/components/Hack/HackForm.tsx index 5c91df7..a63163f 100644 --- a/src/components/Hack/HackForm.tsx +++ b/src/components/Hack/HackForm.tsx @@ -9,6 +9,7 @@ type Mode = "create" | "edit"; interface HackFormCreateProps { mode: "create"; dummy?: boolean; + isArchive?: boolean; } interface HackFormEditProps { @@ -21,7 +22,7 @@ export type HackFormProps = HackFormCreateProps | HackFormEditProps; export default function HackForm(props: HackFormProps) { if (props.mode === "create") { - return ; + return ; } return ; } diff --git a/src/components/Hack/HackOptionsMenu.tsx b/src/components/Hack/HackOptionsMenu.tsx index 0cd6472..f8005f0 100644 --- a/src/components/Hack/HackOptionsMenu.tsx +++ b/src/components/Hack/HackOptionsMenu.tsx @@ -7,10 +7,16 @@ import { Menu, MenuButton, MenuItem, MenuItems, MenuSeparator } from "@headlessu interface HackOptionsMenuProps { slug: string; canEdit: boolean; + canUploadPatch: boolean; children?: React.ReactNode; } -export default function HackOptionsMenu({ slug, canEdit, children }: HackOptionsMenuProps) { +export default function HackOptionsMenu({ + slug, + canEdit, + canUploadPatch, + children, +}: HackOptionsMenuProps) { return ( Edit + } + {canUploadPatch && <> initialDraftRef.current?.pokecommunity || ""); const [tags, setTags] = React.useState(() => (Array.isArray(initialDraftRef.current?.tags) ? initialDraftRef.current.tags : [])); const [showMdPreview, setShowMdPreview] = React.useState(() => !!initialDraftRef.current?.showMdPreview); + const [originalAuthor, setOriginalAuthor] = React.useState(() => initialDraftRef.current?.originalAuthor || ""); const [patchFile, setPatchFile] = React.useState(null); const [patchMode, setPatchMode] = React.useState<"bps" | "rom">(() => (initialDraftRef.current?.patchMode === "rom" ? "rom" : "bps")); const [genStatus, setGenStatus] = React.useState<"idle" | "generating" | "ready" | "error">("idle"); const [genError, setGenError] = React.useState(""); const [submitting, setSubmitting] = React.useState(false); + const maxSteps = isArchive ? 3 : 4; const [step, setStep] = React.useState(() => { const s = initialDraftRef.current?.step; - return Number.isInteger(s) ? Math.min(4, Math.max(1, s)) : 1; + return Number.isInteger(s) ? Math.min(maxSteps, Math.max(1, s)) : 1; }); const supabase = createClient(); const isDummy = !!dummy; @@ -224,11 +230,11 @@ export default function HackSubmitForm({ dummy = false }: HackSubmitFormProps) { let target: HTMLInputElement | null = null; if (step === 1) { target = titleInputRef.current; - } else if (step === 2) { + } else if (step === 2 && !isArchive) { target = versionInputRef.current; - } else if (step === 3) { + } else if ((step === 2 && isArchive) || (step === 3 && !isArchive)) { target = screenshotsInputRef.current; - } else if (step === 4) { + } else if (step === 4 && !isArchive) { target = patchInputRef.current; } if (!target) return; @@ -256,7 +262,7 @@ export default function HackSubmitForm({ dummy = false }: HackSubmitFormProps) { const data = JSON.parse(raw); if (data && typeof data === "object") { const isEmpty = - !title && !summary && !description && !baseRom && !platform && !version && !language && !boxArt && !discord && !twitter && !pokecommunity && (!tags || tags.length === 0); + !title && !summary && !description && !baseRom && !platform && !version && !language && !boxArt && !discord && !twitter && !pokecommunity && (!tags || tags.length === 0) && !originalAuthor; if (isEmpty) { let applied = false; if (typeof data.title === "string") setTitle(data.title); @@ -283,7 +289,9 @@ export default function HackSubmitForm({ dummy = false }: HackSubmitFormProps) { if (typeof data.pokecommunity === "string") applied = applied || !!data.pokecommunity; if (Array.isArray(data.tags)) setTags(data.tags.filter((t: any) => typeof t === "string")); if (Array.isArray(data.tags)) applied = applied || data.tags.length > 0; - if (data.step && Number.isInteger(data.step)) setStep(Math.min(4, Math.max(1, data.step))); + if (typeof data.originalAuthor === "string") setOriginalAuthor(data.originalAuthor); + if (typeof data.originalAuthor === "string") applied = applied || !!data.originalAuthor; + if (data.step && Number.isInteger(data.step)) setStep(Math.min(maxSteps, Math.max(1, data.step))); if (typeof data.showMdPreview === "boolean") setShowMdPreview(data.showMdPreview); if (data.patchMode === "bps" || data.patchMode === "rom") setPatchMode(data.patchMode); if (applied) { hydratedFromDraftRef.current = true; setRestoredDraft(true); } @@ -305,7 +313,7 @@ export default function HackSubmitForm({ dummy = false }: HackSubmitFormProps) { const d = initialDraftRef.current; if (!d || typeof d !== "object") return; const hasAny = Boolean( - d.title || d.summary || d.description || d.baseRom || d.platform || d.version || d.language || d.boxArt || d.discord || d.twitter || d.pokecommunity || (Array.isArray(d.tags) && d.tags.length > 0) + d.title || d.summary || d.description || d.baseRom || d.platform || d.version || d.language || d.boxArt || d.discord || d.twitter || d.pokecommunity || (Array.isArray(d.tags) && d.tags.length > 0) || d.originalAuthor ); if (hasAny) { hydratedFromDraftRef.current = true; setRestoredDraft(true); } }, [dummy, draftKey]); @@ -327,6 +335,7 @@ export default function HackSubmitForm({ dummy = false }: HackSubmitFormProps) { twitter, pokecommunity, tags, + originalAuthor, step, showMdPreview, patchMode, @@ -354,6 +363,7 @@ export default function HackSubmitForm({ dummy = false }: HackSubmitFormProps) { twitter, pokecommunity, tags, + originalAuthor, step, showMdPreview, patchMode, @@ -368,10 +378,10 @@ export default function HackSubmitForm({ dummy = false }: HackSubmitFormProps) { const allSocialValid = [discord, twitter, pokecommunity].every((s) => !s || urlLike(s)); - const step1Valid = !!title.trim() && !!platform && !!baseRom.trim() && !!language.trim(); - const step2Valid = !!version.trim() && !!summary.trim() && !summaryTooLong && !!description.trim() && tags.length > 0; + const step1Valid = !!title.trim() && !!platform && !!baseRom.trim() && !!language.trim() && (isArchive ? !!originalAuthor.trim() : true); + const step2Valid = (isArchive ? true : !!version.trim()) && !!summary.trim() && !summaryTooLong && !!description.trim() && tags.length > 0; const step3Valid = (newCoverFiles.length > 0) && !overLimit && coverErrors.length === 0 && (!boxArt.trim() || urlLike(boxArt)) && allSocialValid; - const isValid = step1Valid && step2Valid && step3Valid && !!patchFile; + const isValid = step1Valid && step2Valid && step3Valid && (isArchive ? true : !!patchFile); const onSubmit = async () => { if (!isValid || submitting) return; @@ -389,26 +399,18 @@ export default function HackSubmitForm({ dummy = false }: HackSubmitFormProps) { if (twitter) fd.set('twitter', twitter); if (pokecommunity) fd.set('pokecommunity', pokecommunity); if (tags.length) fd.set('tags', tags.join(',')); + if (isArchive) { + fd.set('original_author', originalAuthor); + fd.set('isArchive', 'true'); + } const prepared = await prepareSubmission(fd); if (!prepared.ok) throw new Error(prepared.error || 'Failed to prepare'); const uploadedCoverUrls = await uploadCovers(prepared.slug); - const presigned = await presignPatchAndSaveCovers({ slug: prepared.slug, version, coverUrls: uploadedCoverUrls }); - if (!presigned.ok) throw new Error(presigned.error || 'Failed to presign'); - if (patchFile) { - await fetch(presigned.presignedUrl, { method: 'PUT', body: patchFile, headers: { 'Content-Type': 'application/octet-stream' } }); - const finalized = await confirmPatchUpload({ slug: prepared.slug, objectKey: presigned.objectKey!, version, firstUpload: true }); - if (!finalized.ok) throw new Error(finalized.error || 'Failed to finalize'); - try { - if (draftKey) { - localStorage.removeItem(draftKey); - await deleteDraftCovers(draftKey); - } - } catch {} - window.location.href = finalized.redirectTo!; - } else { + if (isArchive) { + // For archives, we don't need patch upload try { if (draftKey) { localStorage.removeItem(draftKey); @@ -416,6 +418,30 @@ export default function HackSubmitForm({ dummy = false }: HackSubmitFormProps) { } } catch {} window.location.href = `/hack/${prepared.slug}`; + } else { + const presigned = await presignPatchAndSaveCovers({ slug: prepared.slug, version, coverUrls: uploadedCoverUrls }); + if (!presigned.ok) throw new Error(presigned.error || 'Failed to presign'); + + if (patchFile) { + await fetch(presigned.presignedUrl, { method: 'PUT', body: patchFile, headers: { 'Content-Type': 'application/octet-stream' } }); + const finalized = await confirmPatchUpload({ slug: prepared.slug, objectKey: presigned.objectKey!, version, firstUpload: true }); + if (!finalized.ok) throw new Error(finalized.error || 'Failed to finalize'); + try { + if (draftKey) { + localStorage.removeItem(draftKey); + await deleteDraftCovers(draftKey); + } + } catch {} + window.location.href = finalized.redirectTo!; + } else { + try { + if (draftKey) { + localStorage.removeItem(draftKey); + await deleteDraftCovers(draftKey); + } + } catch {} + window.location.href = `/hack/${prepared.slug}`; + } } } catch (e: any) { alert(e.message || 'Submission failed'); @@ -491,13 +517,13 @@ export default function HackSubmitForm({ dummy = false }: HackSubmitFormProps) { const preview = { slug: slug || "preview", title: title || "Your hack title", - author: profile?.username ? `@${profile.username}` : "You", + author: isArchive ? (originalAuthor || "Unknown") : (profile?.username ? `@${profile.username}` : "You"), summary: (summary || "Short description, max 100 characters.") as string, description: (description || "Write a longer markdown description here.") as string, covers: coverPreviews, baseRomId: baseRom, downloads: 0, - version: version || "v0.0.0", + version: isArchive ? "Archive" : (version || "v0.0.0"), tags: sortOrderedTags(tags.map((name, index) => ({ name, order: index + 1 }))), ...(boxArt ? { boxArt } : {}), socialLinks: @@ -554,6 +580,7 @@ export default function HackSubmitForm({ dummy = false }: HackSubmitFormProps) { setNewCoverFiles([]); setCoverErrors([]); setPatchFile(null); + setOriginalAuthor(""); setShowMdPreview(false); setStep(1); // Clear file inputs if present @@ -653,25 +680,44 @@ export default function HackSubmitForm({ dummy = false }: HackSubmitFormProps) {
{language}
)} + + {isArchive && ( +
+ + {!isDummy ? ( + setOriginalAuthor(e.target.value)} + placeholder="Name of the original hack creator" + className="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)]" + /> + ) : ( +
Original author name
+ )} +
The name of the person or team who originally created this hack
+
+ )} )} {step === 2 && ( <> -
- - {!isDummy ? ( - setVersion(e.target.value)} - placeholder="e.g. v1.2.0" - className={`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)]`} - /> - ) : ( -
v0.1.0
- )} -
+ {!isArchive && ( +
+ + {!isDummy ? ( + setVersion(e.target.value)} + placeholder="e.g. v1.2.0" + className={`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)]`} + /> + ) : ( +
v0.1.0
+ )} +
+ )}
@@ -879,7 +925,7 @@ export default function HackSubmitForm({ dummy = false }: HackSubmitFormProps) { )} - {step === 4 && ( + {step === 4 && !isArchive && (
{!isDummy ? ( @@ -971,12 +1017,12 @@ export default function HackSubmitForm({ dummy = false }: HackSubmitFormProps) { Back
- Step {step} of 4 + Step {step} of {maxSteps}
- {step < 4 ? ( + {step < maxSteps ? ( + +
+
+ + + ); +}; + +export default ArchiveModeSelector; diff --git a/src/components/Submit/SubmitPageClient.tsx b/src/components/Submit/SubmitPageClient.tsx new file mode 100644 index 0000000..5146493 --- /dev/null +++ b/src/components/Submit/SubmitPageClient.tsx @@ -0,0 +1,16 @@ +"use client"; + +import React from "react"; +import HackForm from "@/components/Hack/HackForm"; +import ArchiveModeSelector from "@/components/Submit/ArchiveModeSelector"; + +export default function SubmitPageClient({ canCreateArchive, dummy }: { canCreateArchive: boolean; dummy: boolean }) { + const [showModeSelector, setShowModeSelector] = React.useState(canCreateArchive); + const [isArchive, setIsArchive] = React.useState(false); + + if (showModeSelector) { + return { setIsArchive(archive); setShowModeSelector(false); }} />; + } + + return ; +}