Add "Archive" hacks and archiver role functionality

This commit is contained in:
Jared Schoeny
2025-12-03 12:56:34 -10:00
parent b3d43e4e50
commit 09796d185e
19 changed files with 1093 additions and 99 deletions

View File

@@ -0,0 +1,195 @@
"use client";
import React from "react";
import { FiX, FiPlus, FiSearch, FiLoader } from "react-icons/fi";
import { getArchivers, searchUsersForArchiver, addArchiverRole, removeArchiverRole } from "@/app/dashboard/archiver-actions";
export default function ArchiverManagement() {
const [archivers, setArchivers] = React.useState<{ id: string; username: string | null }[]>([]);
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<string | null>(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<HTMLInputElement>) => {
setSearchQuery(e.target.value);
},
[]
);
const isArchiver = (userId: string) => archivers.some((a) => a.id === userId);
return (
<div className="mt-12">
<h2 className="text-xl font-semibold mb-4">Archiver Role Management</h2>
<div className="rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-5">
{error && (
<div className="mb-4 rounded-md border border-red-600/30 bg-red-500/10 p-3 text-sm text-red-600 dark:text-red-400">
{error}
</div>
)}
{/* Search for users */}
<div className="mb-6">
<label className="block text-sm font-medium text-foreground/80 mb-2">Add archiver</label>
<div className="relative">
<FiSearch className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-foreground/50" />
<input
type="text"
value={searchQuery}
onChange={handleSearchChange}
placeholder="Search by username or user ID..."
className="w-full rounded-md bg-[var(--background)] px-10 py-2 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
/>
{searching && (
<FiLoader className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-foreground/50 animate-spin" />
)}
</div>
{searchResults.length > 0 && (
<div className="mt-2 rounded-md border border-[var(--border)] bg-[var(--background)] max-h-48 overflow-y-auto">
{searchResults.map((user) => (
<div
key={user.id}
className="flex items-center justify-between px-3 py-2 hover:bg-[var(--surface-2)] border-b border-[var(--border)] last:border-b-0"
>
<div className="flex flex-col">
<span className="text-sm font-medium">{user.username ? `@${user.username}` : "No username"}</span>
<span className="text-xs text-foreground/60">{user.id}</span>
</div>
{isArchiver(user.id) ? (
<span className="text-xs text-foreground/60">Already archiver</span>
) : (
<button
type="button"
onClick={() => addArchiver(user.id)}
className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 text-xs font-medium hover:bg-black/5 dark:hover:bg-white/10"
>
<FiPlus className="h-3 w-3" />
Add
</button>
)}
</div>
))}
</div>
)}
</div>
{/* Current archivers list */}
<div>
<label className="block text-sm font-medium text-foreground/80 mb-2">
Current archivers ({archivers.length})
</label>
{loading ? (
<div className="text-sm text-foreground/60">Loading...</div>
) : archivers.length === 0 ? (
<div className="text-sm text-foreground/60">No archivers assigned</div>
) : (
<div className="space-y-2">
{archivers.map((archiver) => (
<div
key={archiver.id}
className="flex items-center justify-between rounded-md border border-[var(--border)] bg-[var(--background)] px-3 py-2"
>
<div className="flex flex-col">
<span className="text-sm font-medium">{archiver.username ? `@${archiver.username}` : "No username"}</span>
<span className="text-xs text-foreground/60">{archiver.id}</span>
</div>
<button
type="button"
onClick={() => removeArchiver(archiver.id)}
className="inline-flex items-center gap-1 rounded-md border border-red-600/40 bg-red-600/5 dark:border-red-400/40 dark:bg-red-400/5 px-2 py-1 text-xs font-medium text-red-600/90 dark:text-red-400/80 hover:bg-red-600/10 dark:hover:bg-red-400/10"
>
<FiX className="h-3 w-3" />
Remove
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -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<ArchivesData>(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<string | null>(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<HTMLInputElement>) => {
setSearch(e.target.value);
}, []);
if (!data.ok) {
return (
<div className="rounded-md border border-red-600/30 bg-red-500/10 p-4 text-sm text-red-600 dark:text-red-400">
{data.error}
</div>
);
}
const { archives, total, totalPages } = data;
return (
<div className="space-y-4">
{/* Search and filters */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<div className="relative flex-1 w-full">
<FiSearch className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-foreground/50" />
<input
type="text"
value={search}
onChange={handleSearchChange}
placeholder="Search by title, author, or base ROM..."
className="w-full rounded-md bg-[var(--surface-2)] px-10 py-3 md:py-2 text-lg md:text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
/>
{search !== debouncedSearch && (
<FiLoader className="absolute right-3 top-1/2 -translate-y-1/2 h-6 w-6 md:h-4 md:w-4 text-foreground/50 animate-spin" />
)}
</div>
<div className="flex items-center gap-2 w-full md:w-auto">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="w-full md:w-auto rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
>
<option value="created_at">Sort by date</option>
<option value="title">Sort by title</option>
<option value="original_author">Sort by author</option>
</select>
<button
type="button"
onClick={() => setSortOrder(sortOrder === "asc" ? "desc" : "asc")}
className="rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-6 md:px-3 py-2 text-sm hover:bg-black/5 dark:hover:bg-white/10"
>
<FiArrowDown className={`h-4 w-4 ${sortOrder !== "asc" ? "rotate-180" : ""}`} />
</button>
</div>
</div>
{/* Results count */}
<div className="text-sm text-foreground/60">
Showing {archives.length} of {total} archive{total !== 1 ? "s" : ""}
</div>
{/* Table */}
<div className="overflow-hidden rounded-lg border border-[var(--border)]">
{loading ? (
<div className="p-8 text-center text-sm text-foreground/60">Loading...</div>
) : archives.length === 0 ? (
<div className="p-8 text-center text-sm text-foreground/60">No archives found</div>
) : (
<>
{/* Desktop header */}
<div className="hidden lg:grid grid-cols-12 gap-4 bg-[var(--surface-2)] px-4 py-2 text-xs text-foreground/60">
<div className="col-span-4">Title</div>
<div className="col-span-2">Original Author</div>
<div className="col-span-2">Base ROM</div>
<div className="col-span-2">Archived by</div>
<div className="col-span-2 text-right">Actions</div>
</div>
<div className="divide-y divide-[var(--border)]">
{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 (
<div key={archive.slug} className="px-4 py-3 text-sm">
{/* Desktop row */}
<div className="hidden lg:grid grid-cols-12 items-center gap-4">
<Link href={`/hack/${archive.slug}`} target="_blank" className="group flex items-center gap-3 col-span-4 min-w-0 hover:text-foreground">
<div className="flex flex-col items-start min-w-0">
<div className="truncate font-medium group-hover:underline">{archive.title}</div>
<div className="mt-0.5 text-xs text-foreground/60 group-hover:text-foreground group-hover:underline">/{archive.slug}</div>
</div>
<FiExternalLink className="h-4 w-4 text-foreground/80 group-hover:text-foreground flex-shrink-0" />
</Link>
<div className="col-span-2 text-foreground/80">{archive.original_author || "—"}</div>
<div className="col-span-2 text-foreground/80">{baseRom?.name || archive.base_rom}</div>
<div className="col-span-2 text-foreground/80">
<div>{creator}</div>
<div className="text-xs text-foreground/60">{createdDate}</div>
</div>
<div className="col-span-2 flex items-center justify-end gap-2">
<Link
href={`/hack/${archive.slug}/edit`}
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
title="Edit"
>
<FiEdit2 className="h-4 w-4" />
</Link>
{isAdmin && (
<button
type="button"
onClick={() => handleDelete(archive.slug)}
disabled={deletingSlug === archive.slug}
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-red-600/10 disabled:opacity-50"
title="Delete"
>
<FiTrash2 className="h-4 w-4 text-red-600 dark:text-red-400" />
</button>
)}
</div>
</div>
{/* Mobile card */}
<div className="lg:hidden flex flex-col gap-2">
<div className="group flex justify-between items-center">
<Link href={`/hack/${archive.slug}`} target="_blank">
<div className="text-lg font-bold group-hover:underline">{archive.title}</div>
<div className="text-xs text-foreground/60 group-hover:underline">/{archive.slug}</div>
</Link>
<FiExternalLink className="h-4 w-4 text-foreground/80 group-hover:text-foreground flex-shrink-0" />
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-foreground/60">
<span className="font-bold">Author: {archive.original_author || "—"}</span>
<span>|</span>
<span className="font-bold">Base: {baseRom?.name || archive.base_rom}</span>
</div>
<div className="flex flex-wrap items-center text-xs italic text-foreground/60">
Archived by {creator} on {createdDate}
</div>
<div className="flex items-center gap-2 mt-2">
<Link
href={`/hack/${archive.slug}/edit`}
className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 text-xs hover:bg-black/5 dark:hover:bg-white/10"
>
<FiEdit2 className="h-3 w-3" />
Edit
</Link>
{isAdmin && (
<button
type="button"
onClick={() => handleDelete(archive.slug)}
disabled={deletingSlug === archive.slug}
className="inline-flex items-center gap-1 rounded-md border border-red-600/40 bg-red-600/5 dark:border-red-400/40 dark:bg-red-400/5 px-2 py-1 text-xs text-red-600 dark:text-red-400 hover:bg-red-600/10 dark:hover:bg-red-400/10 disabled:opacity-50"
>
<FiTrash2 className="h-3 w-3" />
Delete
</button>
)}
</div>
</div>
</div>
);
})}
</div>
</>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1 || loading}
className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-sm hover:bg-black/5 dark:hover:bg-white/10 disabled:opacity-50 disabled:cursor-not-allowed"
>
<FiChevronLeft className="h-4 w-4" />
Previous
</button>
<div className="text-sm text-foreground/60">
Page {page} of {totalPages}
</div>
<button
type="button"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages || loading}
className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-sm hover:bg-black/5 dark:hover:bg-white/10 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
<FiChevronRight className="h-4 w-4" />
</button>
</div>
)}
</div>
);
}

View File

@@ -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

View File

@@ -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 <HackSubmitForm dummy={props.dummy} />;
return <HackSubmitForm dummy={props.dummy} isArchive={props.isArchive} />;
}
return <HackEditForm slug={props.slug} initial={props.initial} />;
}

View File

@@ -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 (
<Menu as="div" className="relative">
<MenuButton
@@ -71,6 +77,8 @@ export default function HackOptionsMenu({ slug, canEdit, children }: HackOptions
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10">
Edit
</MenuItem>
</>}
{canUploadPatch && <>
<MenuItem
as="a"
href={`/hack/${slug}/edit/patch`}

View File

@@ -59,9 +59,13 @@ function SortableCoverItem({ id, index, url, filename, onRemove }: { id: string;
interface HackSubmitFormProps {
dummy?: boolean;
isArchive?: boolean;
}
export default function HackSubmitForm({ dummy = false }: HackSubmitFormProps) {
export default function HackSubmitForm({
dummy = false,
isArchive = false,
}: HackSubmitFormProps) {
const MAX_COVERS = 10;
const { profile, user } = useAuthContext();
const [isHydrating, setIsHydrating] = React.useState(true);
@@ -96,14 +100,16 @@ export default function HackSubmitForm({ dummy = false }: HackSubmitFormProps) {
const [pokecommunity, setPokecommunity] = React.useState(() => initialDraftRef.current?.pokecommunity || "");
const [tags, setTags] = React.useState<string[]>(() => (Array.isArray(initialDraftRef.current?.tags) ? initialDraftRef.current.tags : []));
const [showMdPreview, setShowMdPreview] = React.useState<boolean>(() => !!initialDraftRef.current?.showMdPreview);
const [originalAuthor, setOriginalAuthor] = React.useState(() => initialDraftRef.current?.originalAuthor || "");
const [patchFile, setPatchFile] = React.useState<File | null>(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<string>("");
const [submitting, setSubmitting] = React.useState(false);
const maxSteps = isArchive ? 3 : 4;
const [step, setStep] = React.useState<number>(() => {
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) {
<div role="textbox" aria-disabled className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] flex items-center text-foreground/60 select-none">{language}</div>
)}
</div>
{isArchive && (
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Original Author <span className="text-red-500">*</span></label>
{!isDummy ? (
<input
value={originalAuthor}
onChange={(e) => 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)]"
/>
) : (
<div role="textbox" aria-disabled className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] flex items-center text-foreground/60 select-none">Original author name</div>
)}
<div className="text-xs text-foreground/60">The name of the person or team who originally created this hack</div>
</div>
)}
</>
)}
{step === 2 && (
<>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Version <span className="text-red-500">*</span></label>
{!isDummy ? (
<input
ref={versionInputRef}
value={version}
onChange={(e) => 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)]`}
/>
) : (
<div role="textbox" aria-disabled className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] flex items-center text-foreground/60 select-none">v0.1.0</div>
)}
</div>
{!isArchive && (
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Version <span className="text-red-500">*</span></label>
{!isDummy ? (
<input
ref={versionInputRef}
value={version}
onChange={(e) => 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)]`}
/>
) : (
<div role="textbox" aria-disabled className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] flex items-center text-foreground/60 select-none">v0.1.0</div>
)}
</div>
)}
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Tags <span className="text-red-500">*</span></label>
@@ -879,7 +925,7 @@ export default function HackSubmitForm({ dummy = false }: HackSubmitFormProps) {
</>
)}
{step === 4 && (
{step === 4 && !isArchive && (
<div className="grid gap-3">
<label className="text-sm text-foreground/80">Provide patch <span className="text-red-500">*</span></label>
{!isDummy ? (
@@ -971,12 +1017,12 @@ export default function HackSubmitForm({ dummy = false }: HackSubmitFormProps) {
Back
</button>
<div className="flex items-center gap-3">
<span className="text-sm text-foreground/60">Step {step} of 4</span>
<span className="text-sm text-foreground/60">Step {step} of {maxSteps}</span>
</div>
{step < 4 ? (
{step < maxSteps ? (
<button
type="button"
onClick={() => setStep((s) => Math.min(4, s + 1))}
onClick={() => setStep((s) => Math.min(maxSteps, s + 1))}
disabled={
submitting ||
(step === 1 && !step1Valid) ||

View File

@@ -0,0 +1,69 @@
"use client";
import React, { useEffect } from "react";
type ArchiveModeSelectorProps = {
onSelect: (isArchive: boolean) => void;
};
const ArchiveModeSelector: React.FC<ArchiveModeSelectorProps> = ({ onSelect }) => {
useEffect(() => {
const html = document.documentElement;
const body = document.body;
const previousHtmlOverflow = html.style.overflow;
const previousBodyOverflow = body.style.overflow;
const previousBodyPaddingRight = body.style.paddingRight;
const scrollBarWidth = window.innerWidth - html.clientWidth;
html.style.overflow = "hidden";
body.style.overflow = "hidden";
if (scrollBarWidth > 0) {
body.style.paddingRight = `${scrollBarWidth}px`;
}
return () => {
html.style.overflow = previousHtmlOverflow;
body.style.overflow = previousBodyOverflow;
body.style.paddingRight = previousBodyPaddingRight;
};
}, []);
return (
<div className="fixed left-0 right-0 top-16 bottom-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 dark:bg-black/60 backdrop-blur-sm" />
<div
role="dialog"
aria-modal="true"
aria-label="Select hack type"
className="relative z-[101] mb-16 card backdrop-blur-lg dark:!bg-white/6 p-6 max-w-md w-full rounded-lg"
>
<div className="flex flex-col gap-8 sm:gap-4">
<div>
<div className="text-xl font-semibold">What would you like to create?</div>
<p className="mt-1 text-sm text-foreground/80">
Choose whether you&apos;re creating a new hack for yourself or archiving an existing hack for preservation purposes.
</p>
</div>
<div className="flex flex-col gap-3">
<button
type="button"
onClick={() => onSelect(false)}
className="shine-wrap btn-premium h-14 sm:h-11 w-full text-sm font-semibold rounded-md text-[var(--accent-foreground)]"
>
<span>Create new hack</span>
</button>
<button
type="button"
onClick={() => onSelect(true)}
className="inline-flex h-14 sm:h-11 w-full items-center justify-center rounded-md px-4 text-sm font-semibold ring-1 ring-[var(--border)] hover:bg-[var(--surface-2)]"
>
Create Archive hack
</button>
</div>
</div>
</div>
</div>
);
};
export default ArchiveModeSelector;

View File

@@ -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 <ArchiveModeSelector onSelect={(archive) => { setIsArchive(archive); setShowModeSelector(false); }} />;
}
return <HackForm mode="create" dummy={dummy} isArchive={isArchive} />;
}