mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-08-22 08:34:13 -05:00
Add "Archive" hacks and archiver role functionality
This commit is contained in:
121
src/app/dashboard/archiver-actions.ts
Normal file
121
src/app/dashboard/archiver-actions.ts
Normal file
@@ -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;
|
||||
}
|
||||
116
src/app/dashboard/archives/actions.ts
Normal file
116
src/app/dashboard/archives/actions.ts
Normal file
@@ -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<string, string | null>();
|
||||
(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;
|
||||
}
|
||||
34
src/app/dashboard/archives/page.tsx
Normal file
34
src/app/dashboard/archives/page.tsx
Normal file
@@ -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 (
|
||||
<div className="mx-auto my-12 max-w-screen-xl px-6 py-8 w-full">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Archive Management</h1>
|
||||
<p className="mt-2 text-[15px] text-foreground/80">
|
||||
Manage all Archive hacks. Archive hacks are informational entries preserved for historical reference.
|
||||
</p>
|
||||
</div>
|
||||
<ArchivesList initialData={initialData.ok ? initialData : { ok: false, error: initialData.error || "Failed to load archives" }} isAdmin={isAdmin ?? false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdmin && <ArchiverManagement />}
|
||||
|
||||
{canAccessArchives && (
|
||||
<div className="mt-12">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-xl font-semibold">Archive Management</h2>
|
||||
<Link
|
||||
href="/dashboard/archives"
|
||||
className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-sm font-medium hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
View all archives
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-sm text-foreground/60">
|
||||
Archive hacks are informational entries preserved for historical reference. They do not include patch files.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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) {
|
||||
<FaChevronLeft size={16} className="inline-block mr-1" />
|
||||
Back to hack
|
||||
</Link>
|
||||
<Link href={`/hack/${slug}/edit/patch`} className="inline-flex items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-sm font-medium hover:bg-black/5 dark:hover:bg-white/10">
|
||||
Upload new version
|
||||
</Link>
|
||||
{!isArchive && (
|
||||
<Link href={`/hack/${slug}/edit/patch`} className="inline-flex items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-sm font-medium hover:bg-black/5 dark:hover:bg-white/10">
|
||||
Upload new version
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 lg:mt-8">
|
||||
|
||||
@@ -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<Met
|
||||
const supabase = await createClient();
|
||||
const { data: hack } = await supabase
|
||||
.from("hacks")
|
||||
.select("title,summary,approved,base_rom,box_art,created_by,created_at,updated_at")
|
||||
.select("title,summary,approved,base_rom,box_art,created_by,created_at,updated_at,original_author,current_patch")
|
||||
.eq("slug", slug)
|
||||
.maybeSingle();
|
||||
if (!hack) return { title: "Hack not found" };
|
||||
@@ -61,10 +62,13 @@ export async function generateMetadata({ params }: HackDetailProps): Promise<Met
|
||||
description: 'This hack is pending approval by an admin.',
|
||||
} satisfies Metadata;
|
||||
|
||||
const isArchive = hack.original_author != null && hack.current_patch === null;
|
||||
const baseRomName = baseRoms.find((r) => 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 }) }}
|
||||
/>
|
||||
<HackActions
|
||||
title={hack.title}
|
||||
version={patchVersion || "Pre-release"}
|
||||
author={author}
|
||||
baseRomId={baseRom?.id || ""}
|
||||
platform={baseRom?.platform}
|
||||
patchFilename={patchFilename}
|
||||
patchId={patchId ?? undefined}
|
||||
hackSlug={hack.slug}
|
||||
/>
|
||||
{!isArchive && (
|
||||
<HackActions
|
||||
title={hack.title}
|
||||
version={patchVersion || "Pre-release"}
|
||||
author={author}
|
||||
baseRomId={baseRom?.id || ""}
|
||||
platform={baseRom?.platform}
|
||||
patchFilename={patchFilename}
|
||||
patchId={patchId ?? undefined}
|
||||
hackSlug={hack.slug}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isArchive && (
|
||||
<div className="flex flex-row items-center gap-4 mx-6 mt-6 rounded-lg border-2 border-rose-500/40 bg-rose-50 dark:bg-rose-900/10 p-4 md:pl-6">
|
||||
<div className="flex items-center gap-4 md:gap-6">
|
||||
<div className="flex-shrink-0">
|
||||
<FaArchive className="text-rose-600 dark:text-rose-400" size={24} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-rose-900 dark:text-rose-100 mb-0.5 md:mb-1">
|
||||
Archive Entry
|
||||
</h3>
|
||||
<p className="text-sm text-rose-800 dark:text-rose-200">
|
||||
This is an archive entry for informational and preservation purposes only. No patch file is available for download.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hack.approved && (
|
||||
isAdmin ? (
|
||||
@@ -306,7 +336,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
|
||||
{patchVersion || "Pre-release"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[15px] text-foreground/70">By {author}</p>
|
||||
<p className="mt-1 text-[15px] text-foreground/70">By {isArchive ? (hack.original_author || "Unknown") : author}</p>
|
||||
<p className="mt-2 text-sm text-foreground/75">{hack.summary}</p>
|
||||
</div>
|
||||
<div className="w-full mt-2 flex flex-col justify-between gap-6 md:flex-row md:items-end">
|
||||
@@ -318,9 +348,9 @@ export default async function HackDetail({ params }: HackDetailProps) {
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 self-end md:self-auto lg:min-w-[260px]">
|
||||
<DownloadsBadge slug={hack.slug} initialCount={hack.downloads} />
|
||||
<HackOptionsMenu slug={hack.slug} canEdit={canEdit}>
|
||||
{isAdmin && (
|
||||
{!isArchive && <DownloadsBadge slug={hack.slug} initialCount={hack.downloads} />}
|
||||
<HackOptionsMenu slug={hack.slug} canEdit={canEdit || isAdmin} canUploadPatch={canUploadPatch || isAdmin}>
|
||||
{isAdmin && !hack.approved && (
|
||||
<MenuItem
|
||||
as="a"
|
||||
href={`/hack/${hack.slug}/approve`}
|
||||
@@ -408,18 +438,32 @@ export default async function HackDetail({ params }: HackDetailProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="card overflow-hidden p-4 mt-4 text-sm text-foreground/60">
|
||||
<p>
|
||||
This page provides the official patch file for <span className="font-semibold">{hack.title}</span>. You can safely download the patched ROM for this hack
|
||||
using our built-in patcher.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
By pressing the "Patch Now" button, your browser will apply the downloaded <span className="font-semibold">{hack.title}</span> .bps patch file to your legally-obtained <span className="font-semibold">{baseRom?.name}</span> ROM. The patched ROM will then be automatically downloaded.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
No pre-patched ROMs or base ROMs are hosted or distributed on this site. All patching is done locally on your device.
|
||||
</p>
|
||||
</div>
|
||||
{isArchive ? (
|
||||
<div className="card overflow-hidden p-4 mt-4 text-sm text-foreground/60">
|
||||
<p>
|
||||
This is an archive entry for <span className="font-semibold">{hack.title}</span> preserved for informational purposes.
|
||||
{hack.original_author && (
|
||||
<span> The original author of this hack is <span className="font-semibold">{hack.original_author}</span>.</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
Archive entries do not include patch files and are maintained for historical reference and preservation purposes only.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card overflow-hidden p-4 mt-4 text-sm text-foreground/60">
|
||||
<p>
|
||||
This page provides the official patch file for <span className="font-semibold">{hack.title}</span>. You can safely download the patched ROM for this hack
|
||||
using our built-in patcher.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
By pressing the "Patch Now" button, your browser will apply the downloaded <span className="font-semibold">{hack.title}</span> .bps patch file to your legally-obtained <span className="font-semibold">{baseRom?.name}</span> ROM. The patched ROM will then be automatically downloaded.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
No pre-patched ROMs or base ROMs are hosted or distributed on this site. All patching is done locally on your device.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
<h1 className="text-3xl font-bold tracking-tight">Submit your ROM hack</h1>
|
||||
<p className="mt-2 text-[15px] text-foreground/80">Share your hack so others can discover and play it.</p>
|
||||
<div className="mt-8">
|
||||
<HackForm mode="create" dummy={!user || needsInitialSetup} />
|
||||
<SubmitPageClient canCreateArchive={canCreateArchive} dummy={!user || needsInitialSetup} />
|
||||
</div>
|
||||
{!user ? (
|
||||
<SubmitAuthOverlay
|
||||
|
||||
195
src/components/Dashboard/ArchiverManagement.tsx
Normal file
195
src/components/Dashboard/ArchiverManagement.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
272
src/components/Dashboard/ArchivesList.tsx
Normal file
272
src/components/Dashboard/ArchivesList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
69
src/components/Submit/ArchiveModeSelector.tsx
Normal file
69
src/components/Submit/ArchiveModeSelector.tsx
Normal 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'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;
|
||||
16
src/components/Submit/SubmitPageClient.tsx
Normal file
16
src/components/Submit/SubmitPageClient.tsx
Normal 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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user