Make pending hacks assignable to admin for review (#66)
Some checks failed
Deploy Supabase Migrations to Production / migrate (push) Has been cancelled

* Extract common Modal component

* Move admin pending hacks list to client component

* Add assigning pending hacks to admin for review
This commit is contained in:
Jared Schoeny
2026-07-18 22:37:41 -06:00
committed by GitHub
parent 6568c0c2d1
commit 92c86e80af
8 changed files with 802 additions and 511 deletions

View File

@@ -309,3 +309,19 @@ export const getHackInsights = async ({ slug }: { slug: string }): Promise<HackI
return runner();
};
export const assignHacksToAdminForReview = async ({ slugs }: { slugs: string[] }): Promise<void> => {
const supa = await createClient();
const { data: userResp } = await supa.auth.getUser();
const user = userResp.user;
if (!user) throw new Error("Unauthorized");
const { data: admin } = await supa.rpc("is_admin");
if (!admin) throw new Error("Unauthorized");
const { error } = await supa
.from("hacks")
.update({ assigned_admin: user.id })
.in("slug", slugs);
if (error) throw error;
};

View File

@@ -1,12 +1,11 @@
import { createClient, createServiceClient } from "@/utils/supabase/server";
import Link from "next/link";
import { redirect } from "next/navigation";
import { FiAlertTriangle, 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";
import { FaCircleCheck } from "react-icons/fa6";
import type { PendingHack } from "@/components/Dashboard/PendingHacks";
import PendingHacks from "@/components/Dashboard/PendingHacks";
export default async function DashboardPage() {
const supa = await createClient();
@@ -15,27 +14,24 @@ export default async function DashboardPage() {
if (!user) redirect("/login");
const { data: isAdmin } = await supa.rpc("is_admin");
let pendingHacks: (HackRow & {
created_by: string;
creator_username: string | null;
creator_full_name: string | null;
creator_email: string | null;
creator_verified: boolean;
})[] = [];
let pendingHacks: PendingHack[] = [];
if (isAdmin) {
const { data: pendingHacksData } = await supa
.from("hacks")
.select("slug,title,approved,updated_at,downloads,current_patch,version,created_at,created_by")
.select("slug,title,approved,updated_at,downloads,current_patch,version,created_at,created_by,assigned_admin")
.eq("approved", false)
.order("created_at", { ascending: false });
if (pendingHacksData && pendingHacksData.length > 0) {
// Fetch creator usernames
const creatorIds = [...new Set(pendingHacksData.map(h => h.created_by as string))];
const profileIds = [
...new Set(pendingHacksData.map(h => h.created_by as string)),
...new Set(pendingHacksData.map(h => h.assigned_admin).filter(a => a !== null) as string[]),
];
const { data: profiles } = await supa
.from("profiles")
.select("id,username,full_name,verified")
.in("id", creatorIds);
.in("id", profileIds);
const usernameById = new Map<string, string | null>();
const fullNameById = new Map<string, string | null>();
@@ -49,7 +45,7 @@ export default async function DashboardPage() {
// Fetch creator emails using service client (admin API)
const serviceClient = await createServiceClient();
const emailById = new Map<string, string | null>();
for (const userId of creatorIds) {
for (const userId of profileIds) {
try {
const { data: userData, error } = await serviceClient.auth.admin.getUserById(userId);
if (!error && userData?.user?.email) {
@@ -67,6 +63,7 @@ export default async function DashboardPage() {
creator_full_name: fullNameById.get(h.created_by as string) || null,
creator_email: emailById.get(h.created_by as string) || null,
creator_verified: verifiedById.get(h.created_by as string) || false,
assigned_admin_username: usernameById.get(h.assigned_admin as string) || null,
}));
}
}
@@ -110,126 +107,7 @@ export default async function DashboardPage() {
displayName={full_name || `@${username}`}
/>
{pendingHacks.length > 0 && (
<div className="mt-12">
<div className="mb-4 flex items-center gap-2">
<h2 className="text-xl font-semibold">Pending hacks</h2>
<span className="inline-flex items-center rounded-full bg-amber-500/10 px-2.5 py-0.5 text-sm font-medium text-amber-900/90 dark:bg-amber-500/20 dark:text-amber-100 border border-amber-600/30">
{pendingHacks.length}
</span>
</div>
<div className="overflow-hidden rounded-lg border border-amber-600/30 bg-amber-500/5">
{/* Header row (desktop only) */}
<div className="hidden lg:grid grid-cols-12 bg-amber-500/5 px-4 py-2 text-xs text-amber-900/80 dark:text-amber-200/80">
<div className="col-span-4">Title</div>
<div className="col-span-4">Creator</div>
<div className="col-span-4">Created</div>
</div>
<div className="divide-y divide-amber-600/20">
{pendingHacks.map((h) => {
const createdDate = h.created_at
? new Date(h.created_at).toLocaleTimeString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
: "Unknown";
const creator = h.creator_username ? `@${h.creator_username}` : "Unknown";
const hasMissingPatch = h.current_patch === null;
const bgClasses = hasMissingPatch
? "bg-amber-50/5 dark:bg-amber-900/5 hover:bg-amber-400/10 dark:hover:bg-amber-600/10"
: "bg-amber-500/5 hover:bg-amber-500/10";
return (
<Link
key={h.slug}
href={`/hack/${h.slug}`}
target="_blank"
className={`group block px-4 py-3 text-sm ${bgClasses} transition-colors`}
>
{/* Desktop row */}
<div className="hidden lg:grid grid-cols-12 items-center">
<div className="col-span-4 min-w-0">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<div className="truncate font-medium text-amber-900/90 dark:text-amber-200/90 group-hover:underline">{h.title}</div>
{hasMissingPatch && (
<div className="inline-flex items-center gap-1 rounded-full bg-amber-600/20 dark:bg-amber-600/40 px-2 py-0.5 text-xs font-medium text-amber-900/90 dark:text-amber-200/90">
<FiAlertTriangle className="h-3 w-3 text-amber-900/90 dark:text-amber-200/90" />
<span>Missing Patch</span>
</div>
)}
</div>
<div className="mt-0.5 text-xs text-amber-900/60 dark:text-amber-200/60 group-hover:underline">/{h.slug}</div>
</div>
</div>
</div>
<div className="col-span-4 flex flex-col min-w-0">
{h.creator_full_name && <div className="text-xs text-amber-900/70 dark:text-amber-200/70">{h.creator_full_name}</div>}
<div className="text-amber-900/90 dark:text-amber-200/90">
{creator}
{h.creator_verified && (
<div className="group/verified relative inline-flex items-center group ml-1">
<FaCircleCheck className="text-amber-950/90 dark:text-amber-100/90" size={12} />
<div className="pointer-events-none absolute left-1/2 top-full z-10 mt-1 hidden -translate-x-1/2 whitespace-nowrap rounded bg-black px-2 py-1 text-xs text-white opacity-0 group-hover/verified:block group-hover/verified:opacity-100">
Creator is verified
</div>
</div>
)}
</div>
{h.creator_email && (
<div className="text-xs text-amber-900/60 dark:text-amber-200/60 truncate mt-0.5">{h.creator_email}</div>
)}
</div>
<div className="col-span-4 min-w-0">
<div className="flex items-center gap-3">
<div className="text-amber-900/90 dark:text-amber-200/90 flex-1">{createdDate}</div>
<FiExternalLink className="h-4 w-4 text-amber-900/90 dark:text-amber-200/90 flex-shrink-0" />
</div>
</div>
</div>
{/* Mobile card */}
<div className="lg:hidden flex flex-col gap-2">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<div className="font-medium wrap-break-word">{h.title}</div>
{hasMissingPatch && (
<div className="inline-flex items-center gap-1 rounded-full bg-amber-600/20 dark:bg-amber-600/40 px-2 py-0.5 text-xs font-medium text-amber-900/90 dark:text-amber-200/90">
<FiAlertTriangle className="h-3 w-3 text-amber-900/90 dark:text-amber-200/90" />
<span>Missing Patch</span>
</div>
)}
</div>
<div className="mt-0.5 text-xs text-foreground/60 break-all">/{h.slug}</div>
<div className="flex flex-col gap-1 text-xs text-amber-900/90 dark:text-amber-200/90 mt-2">
{(h.creator_full_name || h.creator_email) && (
<div className="text-amber-900/70 dark:text-amber-200/70 break-all flex flex-wrap items-center gap-2">
{h.creator_full_name && <span>{h.creator_full_name}</span>}
{h.creator_full_name && h.creator_email && <span></span>}
{h.creator_email && <span>{h.creator_email}</span>}
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<span>{creator}</span>
<span></span>
<span>{createdDate}</span>
</div>
</div>
</div>
<FiExternalLink className="h-4 w-4 text-foreground/80 flex-shrink-0" />
</div>
</div>
</Link>
);
})}
</div>
</div>
</div>
)}
{isAdmin && <PendingHacks hacks={pendingHacks} userId={user.id} />}
{isAdmin && <ArchiverManagement />}

View File

@@ -0,0 +1,423 @@
"use client";
import React, { useState, useEffect } from "react";
import Link from "next/link";
import type { HackRow } from "./DashboardClient";
import { FiAlertTriangle, FiExternalLink } from "react-icons/fi";
import { FaCircleCheck, FaRegCircle, FaUserCheck } from "react-icons/fa6";
import { assignHacksToAdminForReview } from "@/app/dashboard/actions";
import { useRouter } from "next/navigation";
export type PendingHack = HackRow & {
created_by: string;
creator_username: string | null;
creator_full_name: string | null;
creator_email: string | null;
creator_verified: boolean;
assigned_admin: string | null;
assigned_admin_username: string | null;
}
interface PendingHacksProps {
hacks: PendingHack[];
userId: string;
}
type Filter =
"unassigned" |
"assigned_to_me" |
"recent" |
"all";
export default function PendingHacks({ hacks, userId }: PendingHacksProps) {
const router = useRouter();
const [filter, setFilter] = useState<Filter>("unassigned");
const [claimMode, _setClaimMode] = useState<boolean>(false);
const [selectedHacks, setSelectedHacks] = useState<PendingHack[]>([]);
const [lastClaimedCount, setLastClaimedCount] = useState<number>(0);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const filteredHacks = React.useMemo(() => {
let filtered = hacks;
if (filter === "assigned_to_me") {
filtered = filtered.filter((h) => h.assigned_admin && h.assigned_admin === userId);
} else if (filter === "unassigned") {
filtered = filtered.filter((h) => h.assigned_admin === null);
} else if (filter === "recent") {
// From within the last 30 days
filtered = filtered.filter((h) => new Date(h.created_at).getTime() > Date.now() - 30 * 24 * 60 * 60 * 1000);
}
return filtered as PendingHack[];
}, [filter, hacks, userId]);
const setClaimMode = (mode: boolean) => {
_setClaimMode(mode);
if (!mode) {
setSelectedHacks([]);
}
};
const onClaimSelected = async () => {
try {
setLoading(true);
await assignHacksToAdminForReview({ slugs: selectedHacks.map((h) => h.slug) });
setLastClaimedCount(selectedHacks.length);
setSelectedHacks([]);
setError(null);
setClaimMode(false);
router.refresh();
} catch (error) {
setError(error instanceof Error ? error.message : "An unknown error occurred");
} finally {
setLoading(false);
}
};
const onHackSelected = (hack: PendingHack) => {
setSelectedHacks(selectedHacks.includes(hack) ? selectedHacks.filter((h) => h.slug !== hack.slug) : [...selectedHacks, hack]);
};
if (hacks.length === 0 || (filteredHacks.length === 0 && filter !== "assigned_to_me")) {
return (
<PendingHacksContainer
totalCount={hacks.length}
filteredCount={0}
selectedCount={0}
lastClaimedCount={lastClaimedCount}
loading={loading}
error={error}
filter={filter}
setFilter={setFilter}
claimMode={claimMode}
setClaimMode={setClaimMode}
onClaimSelected={onClaimSelected}
>
<div className="flex items-center justify-center text-foreground/80 my-4">
No pending hacks to review! 🎉
</div>
</PendingHacksContainer>
)
}
if (filteredHacks.length === 0 && filter === "assigned_to_me") {
return (
<PendingHacksContainer
totalCount={hacks.length}
filteredCount={0}
selectedCount={0}
lastClaimedCount={lastClaimedCount}
loading={loading}
error={error}
filter={filter}
setFilter={setFilter}
claimMode={claimMode}
setClaimMode={setClaimMode}
onClaimSelected={onClaimSelected}
>
<div className="flex items-center justify-center text-foreground/80 m-4">
None of the currently {hacks.length} pending hacks have been claimed by you for review.
</div>
</PendingHacksContainer>
)
}
return (
<PendingHacksContainer
totalCount={hacks.length}
filteredCount={filteredHacks.length}
selectedCount={selectedHacks.length}
lastClaimedCount={lastClaimedCount}
loading={loading}
error={error}
filter={filter}
setFilter={setFilter}
claimMode={claimMode}
setClaimMode={setClaimMode}
onClaimSelected={onClaimSelected}
>
{/* Header row (desktop only) */}
<div className="hidden lg:grid grid-cols-12 bg-amber-500/5 px-4 py-2 text-xs text-amber-900/80 dark:text-amber-200/80">
<div className="col-span-4">Title</div>
<div className="col-span-4">Creator</div>
<div className="col-span-4">Created</div>
</div>
<div className="divide-y divide-amber-600/20">
{filteredHacks.map((h) => {
return (
<PendingHackCard
key={h.slug}
hack={h}
userId={userId}
selectMode={claimMode}
selected={selectedHacks.includes(h)}
onSelect={onHackSelected}
/>
);
})}
{filter === "recent" && filteredHacks.length < hacks.length && (
<div className="flex items-center justify-center text-foreground/80 m-4">
+ {hacks.length - filteredHacks.length} older pending hacks
</div>
)}
</div>
</PendingHacksContainer>
);
}
interface PendingHacksContainerProps {
totalCount: number;
filteredCount: number;
selectedCount: number;
lastClaimedCount: number;
loading: boolean;
error: string | null;
children: React.ReactNode;
filter: Filter;
setFilter: (filter: Filter) => void;
claimMode: boolean;
setClaimMode: (claimMode: boolean) => void;
onClaimSelected: () => void;
}
function PendingHacksContainer({
totalCount,
filteredCount,
selectedCount,
lastClaimedCount,
loading,
error,
children,
filter,
setFilter,
claimMode,
setClaimMode,
onClaimSelected,
}: PendingHacksContainerProps) {
return (
<div className="mt-12">
<div className="mb-2 flex flex-col md:flex-row gap-4 md:gap-2">
<div className="flex items-center gap-2">
<h2 className="text-xl font-semibold">Pending hacks</h2>
<span className="inline-flex items-center rounded-full bg-amber-500/10 px-2.5 py-0.5 text-sm font-medium text-amber-900/90 dark:bg-amber-500/20 dark:text-amber-100 border border-amber-600/30">
{filteredCount}
</span>
</div>
{/* Filter dropdown */}
{totalCount > 0 && !claimMode && (
<div className="flex items-center gap-2 w-full md:ml-auto md:max-w-2xs">
<button
className="h-10 inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-sm text-foreground/90 dark:bg-[var(--surface-2)] dark:text-foreground/90 hover:bg-black/5 dark:hover:bg-white/10 hover:cursor-pointer"
onClick={() => setClaimMode(!claimMode)}
>
<FaUserCheck className="h-4 w-4 text-foreground/90 dark:text-foreground/90" />
<span>Claim</span>
</button>
<select
className="w-full h-10 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-sm text-foreground/90 dark:bg-[var(--surface-2)] dark:text-foreground/90"
value={filter}
onChange={(e) => setFilter(e.target.value as Filter)}
>
<option value="unassigned">Unassigned</option>
<option value="assigned_to_me">Assigned to me</option>
<option value="recent">Recent (last 30 days)</option>
<option value="all">All</option>
</select>
</div>
)}
{totalCount > 0 && claimMode && (
<div className="flex items-center gap-2 ml-auto md:max-w-2xs">
<button
disabled={loading || selectedCount === 0}
className="min-w-max h-10 inline-flex items-center gap-1 rounded-md border border-emerald-600/30 bg-emerald-500/5 hover:bg-emerald-500/10 hover:cursor-pointer px-3 py-2 text-sm text-foreground/90 dark:bg-emerald-500/20 dark:text-foreground/90 disabled:border-[var(--border)]/30 disabled:bg-[var(--surface-2)]/50 disabled:text-foreground/60 disabled:cursor-not-allowed"
onClick={onClaimSelected}
>
<FaUserCheck className="h-4 w-4 text-foreground/90 dark:text-foreground/90 mr-1" />
<span>Claim selected</span>
</button>
<button
disabled={loading}
className="h-10 inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-sm text-foreground/90 dark:bg-[var(--surface-2)] dark:text-foreground/90 enabled:hover:bg-black/5 dark:enabled:hover:bg-white/10 enabled:hover:cursor-pointer disabled:border-[var(--border)]/30 disabled:bg-[var(--surface-2)]/50 disabled:text-foreground/60 disabled:cursor-not-allowed"
onClick={() => setClaimMode(false)}
>
<span>Cancel</span>
</button>
</div>
)}
</div>
<div className="mb-2 text-sm text-foreground/60">
Use "Claim" to select hacks whose creator you have already reached out to for verification.
</div>
{error && (
<div className="mb-2 rounded-md border border-red-600/30 bg-red-500/10 p-3 text-sm text-red-600 dark:text-red-400">
{error}
</div>
)}
{lastClaimedCount > 0 && (
<div className="mb-2 rounded-md border border-emerald-600/30 bg-emerald-500/10 p-3 text-sm text-emerald-900/90 dark:text-emerald-200/90">
{lastClaimedCount} hacks claimed successfully!
</div>
)}
<div className="overflow-hidden rounded-lg border border-amber-600/30 bg-amber-500/5">
{children}
</div>
</div>
);
}
interface PendingHackCardProps {
hack: PendingHack;
userId: string;
selectMode: boolean;
selected: boolean;
onSelect: (hack: PendingHack) => void;
}
function PendingHackCard({ hack, userId, selectMode, selected, onSelect }: PendingHackCardProps) {
const createdDate = hack.created_at
? new Date(hack.created_at).toLocaleTimeString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
: "Unknown";
const creator = hack.creator_username ? `@${hack.creator_username}` : "Unknown";
const hasMissingPatch = hack.current_patch === null;
const bgClasses = hasMissingPatch
? "bg-amber-50/5 dark:bg-amber-900/5 hover:bg-amber-400/10 dark:hover:bg-amber-600/10"
: "bg-amber-500/5 hover:bg-amber-500/10";
const content = <>
{/* Desktop row */}
<div className="hidden lg:grid grid-cols-12 items-center">
<div className="col-span-4 min-w-0">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<div className="truncate font-medium text-amber-900/90 dark:text-amber-200/90 group-hover:underline">{hack.title}</div>
{hasMissingPatch && (
<div className="inline-flex items-center gap-1 rounded-full bg-amber-600/20 dark:bg-amber-600/40 px-2 py-0.5 text-xs font-medium text-amber-900/90 dark:text-amber-200/90">
<FiAlertTriangle className="h-3 w-3 text-amber-900/90 dark:text-amber-200/90" />
<span>Missing Patch</span>
</div>
)}
</div>
<div className="mt-0.5 text-xs text-amber-900/60 dark:text-amber-200/60 group-hover:underline">/{hack.slug}</div>
{hack.assigned_admin && hack.assigned_admin === userId ? (
<div className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-amber-900/90 dark:text-amber-200/90">
<FaCircleCheck className="h-3 w-3 text-amber-900/90 dark:text-amber-200/90" />
<span>Assigned to you</span>
</div>
) : hack.assigned_admin_username && (
<div className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-amber-900/90 dark:text-amber-200/90">
<span>Assigned to <span className="font-bold">@{hack.assigned_admin_username}</span></span>
</div>
)}
</div>
</div>
</div>
<div className="col-span-4 flex flex-col min-w-0">
{hack.creator_full_name && <div className="text-xs text-amber-900/70 dark:text-amber-200/70">{hack.creator_full_name}</div>}
<div className="text-amber-900/90 dark:text-amber-200/90">
{creator}
{hack.creator_verified && (
<div className="group/verified relative inline-flex items-center group ml-1">
<FaCircleCheck className="text-amber-950/90 dark:text-amber-100/90" size={12} />
<div className="pointer-events-none absolute left-1/2 top-full z-10 mt-1 hidden -translate-x-1/2 whitespace-nowrap rounded bg-black px-2 py-1 text-xs text-white opacity-0 group-hover/verified:block group-hover/verified:opacity-100">
Creator is verified
</div>
</div>
)}
</div>
{hack.creator_email && (
<div className="text-xs text-amber-900/60 dark:text-amber-200/60 truncate mt-0.5">{hack.creator_email}</div>
)}
</div>
<div className="col-span-4 min-w-0">
<div className="flex items-center gap-3">
<div className="text-amber-900/90 dark:text-amber-200/90 flex-1">{createdDate}</div>
{selectMode && selected ? (
<FaCircleCheck className="h-6 w-6 text-foreground/80 flex-shrink-0" />
) : selectMode ? (
<FaRegCircle className="h-6 w-6 text-foreground/80 flex-shrink-0" />
) : (
<FiExternalLink className="h-4 w-4 text-foreground/80 flex-shrink-0" />
)}
</div>
</div>
</div>
{/* Mobile card */}
<div className="lg:hidden flex flex-col gap-2">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<div className="font-medium wrap-break-word">{hack.title}</div>
{hasMissingPatch && (
<div className="inline-flex items-center gap-1 rounded-full bg-amber-600/20 dark:bg-amber-600/40 px-2 py-0.5 text-xs font-medium text-amber-900/90 dark:text-amber-200/90">
<FiAlertTriangle className="h-3 w-3 text-amber-900/90 dark:text-amber-200/90" />
<span>Missing Patch</span>
</div>
)}
</div>
<div className="mt-0.5 text-xs text-foreground/60 break-all">/{hack.slug}</div>
<div className="flex flex-col gap-1 text-xs text-amber-900/90 dark:text-amber-200/90 mt-2">
{(hack.creator_full_name || hack.creator_email) && (
<div className="text-amber-900/70 dark:text-amber-200/70 break-all flex flex-wrap items-center gap-2">
{hack.creator_full_name && <span>{hack.creator_full_name}</span>}
{hack.creator_full_name && hack.creator_email && <span></span>}
{hack.creator_email && <span>{hack.creator_email}</span>}
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<span>{creator}</span>
<span></span>
<span>{createdDate}</span>
</div>
</div>
{hack.assigned_admin && hack.assigned_admin === userId ? (
<div className="mt-4 inline-flex items-center gap-1 text-xs font-medium text-amber-900/90 dark:text-amber-200/90">
<FaCircleCheck className="h-3 w-3 text-amber-900/90 dark:text-amber-200/90" />
<span>Assigned to you</span>
</div>
) : hack.assigned_admin_username && (
<div className="mt-4 inline-flex items-center gap-1 text-xs font-medium text-amber-900/90 dark:text-amber-200/90">
<span>Assigned to <span className="font-bold">@{hack.assigned_admin_username}</span></span>
</div>
)}
</div>
{selectMode && selected ? (
<FaCircleCheck className="h-6 w-6 text-foreground/80 flex-shrink-0" />
) : selectMode ? (
<FaRegCircle className="h-6 w-6 text-foreground/80 flex-shrink-0" />
) : (
<FiExternalLink className="h-4 w-4 text-foreground/80 flex-shrink-0" />
)}
</div>
</div>
</>
if (selectMode) {
return (
<div
key={hack.slug}
className={`group block px-4 py-3 text-sm ${bgClasses} transition-colors hover:cursor-pointer`}
onClick={() => onSelect(hack)}
>
{content}
</div>
);
}
return (
<Link
key={hack.slug}
href={`/hack/${hack.slug}`}
target="_blank"
className={`group block px-4 py-3 text-sm ${bgClasses} transition-colors`}
>
{content}
</Link>
);
}

View File

@@ -2,7 +2,7 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { FiX } from "react-icons/fi";
import Modal from "@/components/Primitives/Modal";
import { updatePatcherSelectablePatches } from "@/app/hack/[slug]/actions";
import PatcherVersionSettings from "@/components/Hack/PatcherVersionSettings";
import VersionList from "@/components/Hack/VersionList";
@@ -308,105 +308,68 @@ export default function PatcherVersionManager({
isCustomPatcherActive={publishedOption === "custom"}
onTogglePatcherPatch={togglePatch}
/>
{showPublishModal && (
<Modal
title="Publish Patcher Changes"
onClose={() => !saving && setShowPublishModal(false)}
>
<p className="text-foreground/80 mb-3">
These patches will be available to choose from in the downloader on your hack&apos;s homepage:
<Modal
title="Publish Patcher Changes"
visible={showPublishModal}
onClose={() => !saving && setShowPublishModal(false)}
>
<p className="text-foreground/80 mb-3">
These patches will be available to choose from in the downloader on your hack&apos;s homepage:
</p>
{draftVersionLabels.length > 0 ? (
<ul className="mb-4 space-y-1 text-sm text-foreground/75">
{draftVersionLabels.map((label, index) => (
<li key={label} className="flex items-center gap-2">
<span aria-hidden>-</span>
<span>{label}</span>
{draftOption === "custom" && index === 0 && (
<span className="inline-flex items-center rounded-full bg-emerald-500/20 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
Default
</span>
)}
</li>
))}
</ul>
) : (
<p className="mb-4 text-sm text-foreground/60">No current patch is set.</p>
)}
{draftOption === "custom" && (
<p className="mb-4 text-sm text-foreground/70">
Public version name: <strong className="text-foreground">{draftCustomVersionName.trim()}</strong>
</p>
{draftVersionLabels.length > 0 ? (
<ul className="mb-4 space-y-1 text-sm text-foreground/75">
{draftVersionLabels.map((label, index) => (
<li key={label} className="flex items-center gap-2">
<span aria-hidden>-</span>
<span>{label}</span>
{draftOption === "custom" && index === 0 && (
<span className="inline-flex items-center rounded-full bg-emerald-500/20 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
Default
</span>
)}
</li>
))}
</ul>
) : (
<p className="mb-4 text-sm text-foreground/60">No current patch is set.</p>
)}
{draftOption === "custom" && (
<p className="mb-4 text-sm text-foreground/70">
Public version name: <strong className="text-foreground">{draftCustomVersionName.trim()}</strong>
</p>
)}
{selectedUnpublishedVersionLabels.length > 0 && (
<p className="mb-4 text-sm text-amber-600 dark:text-amber-400">
Selected unpublished versions will be published when these changes are saved.
</p>
)}
{publishError && (
<p className="mb-4 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-400">
{publishError}
</p>
)}
<div className="flex gap-2">
<button
type="button"
onClick={confirmPublish}
disabled={saving}
className="flex-1 inline-flex items-center justify-center rounded-md bg-[var(--accent)] px-4 py-2 text-sm font-medium text-[var(--accent-foreground)] hover:bg-[var(--accent-700)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? "Publishing..." : "Publish Changes"}
</button>
<button
type="button"
onClick={() => {
setShowPublishModal(false);
setPublishError(null);
}}
disabled={saving}
className="flex-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-4 py-2 text-sm font-medium hover:bg-[var(--surface-3)] disabled:opacity-50"
>
Cancel
</button>
</div>
</Modal>
)}
)}
{selectedUnpublishedVersionLabels.length > 0 && (
<p className="mb-4 text-sm text-amber-600 dark:text-amber-400">
Selected unpublished versions will be published when these changes are saved.
</p>
)}
{publishError && (
<p className="mb-4 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-400">
{publishError}
</p>
)}
<div className="flex gap-2">
<button
type="button"
onClick={confirmPublish}
disabled={saving}
className="flex-1 inline-flex items-center justify-center rounded-md bg-[var(--accent)] px-4 py-2 text-sm font-medium text-[var(--accent-foreground)] hover:bg-[var(--accent-700)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? "Publishing..." : "Publish Changes"}
</button>
<button
type="button"
onClick={() => {
setShowPublishModal(false);
setPublishError(null);
}}
disabled={saving}
className="flex-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-4 py-2 text-sm font-medium hover:bg-[var(--surface-3)] disabled:opacity-50"
>
Cancel
</button>
</div>
</Modal>
</>
);
}
function Modal({
title,
children,
onClose,
}: {
title: string;
children: React.ReactNode;
onClose: () => void;
}) {
return (
<div className="fixed left-0 right-0 top-0 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"
onClick={onClose}
/>
<div
role="dialog"
aria-modal="true"
aria-label={title}
className="relative z-[101] card backdrop-blur-lg dark:!bg-black/70 p-6 max-w-md w-full rounded-lg"
>
<button
type="button"
onClick={onClose}
aria-label="Close modal"
className="absolute top-4 right-4 p-1.5 rounded-md text-foreground/60 hover:text-foreground hover:bg-[var(--surface-2)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
>
<FiX size={20} />
</button>
<h2 className="text-xl font-semibold mb-4 pr-8">{title}</h2>
{children}
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { FiX, FiMoreVertical } from "react-icons/fi";
import { FiMoreVertical } from "react-icons/fi";
import {
FaDownload,
FaTrash,
@@ -10,6 +10,7 @@ import {
FaCheck,
} from "react-icons/fa6";
import { Menu, MenuButton, MenuItem, MenuItems, MenuSeparator } from "@headlessui/react";
import Modal from "@/components/Primitives/Modal";
import {
archivePatchVersion,
restorePatchVersion,
@@ -422,32 +423,31 @@ export default function VersionActions({
</Menu>
{/* Restore Modal */}
{showRestoreModal && (
<Modal
title="Restore Version"
onClose={() => !actionLoading && setShowRestoreModal(false)}
>
<p className="text-foreground/80 mb-4">
Restore version <strong>{patch.version}</strong>? This will make it visible again.
</p>
<div className="flex gap-2">
<button
onClick={handleRestore}
disabled={actionLoading}
className="flex-1 rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{actionLoading ? "Restoring..." : "Restore"}
</button>
<button
onClick={() => setShowRestoreModal(false)}
disabled={actionLoading}
className="flex-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-4 py-2 text-sm font-medium hover:bg-[var(--surface-3)] disabled:opacity-50"
>
Cancel
</button>
</div>
</Modal>
)}
<Modal
title="Restore Version"
visible={showRestoreModal}
onClose={() => !actionLoading && setShowRestoreModal(false)}
>
<p className="text-foreground/80 mb-4">
Restore version <strong>{patch.version}</strong>? This will make it visible again.
</p>
<div className="flex gap-2">
<button
onClick={handleRestore}
disabled={actionLoading}
className="flex-1 rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{actionLoading ? "Restoring..." : "Restore"}
</button>
<button
onClick={() => setShowRestoreModal(false)}
disabled={actionLoading}
className="flex-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-4 py-2 text-sm font-medium hover:bg-[var(--surface-3)] disabled:opacity-50"
>
Cancel
</button>
</div>
</Modal>
</>
);
}
@@ -581,115 +581,223 @@ export default function VersionActions({
</Menu>
{/* Delete Modal */}
{showDeleteModal && (
<Modal
title="Archive Version"
onClose={() => !actionLoading && setShowDeleteModal(false)}
>
{archiveWouldRemoveLastCustomPatch ? (
<Modal
title="Archive Version"
visible={showDeleteModal}
onClose={() => !actionLoading && setShowDeleteModal(false)}
>
{archiveWouldRemoveLastCustomPatch ? (
<p className="text-foreground/80 mb-4">
Version <strong>{patch.version}</strong> is one of the last 2 versions in the <strong>Custom</strong> patcher list. Switch to <strong>Latest published patch</strong> or add another Custom version before archiving it.
</p>
) : (
<>
<p className="text-foreground/80 mb-4">
Version <strong>{patch.version}</strong> is one of the last 2 versions in the <strong>Custom</strong> patcher list. Switch to <strong>Latest published patch</strong> or add another Custom version before archiving it.
Are you sure you want to archive version <strong>{patch.version}</strong>? This will hide it from public view, but it can be restored later.
</p>
) : (
<>
<p className="text-foreground/80 mb-4">
Are you sure you want to archive version <strong>{patch.version}</strong>? This will hide it from public view, but it can be restored later.
{isInCustomPatcherList && (
<p className="text-sm text-foreground/60 mb-4">
This version will also be removed from the Custom patcher list.
</p>
{isInCustomPatcherList && (
<p className="text-sm text-foreground/60 mb-4">
This version will also be removed from the Custom patcher list.
</p>
)}
</>
)}
<div className="flex gap-2">
{!archiveWouldRemoveLastCustomPatch && (
<button
onClick={handleDelete}
disabled={actionLoading}
className="flex-1 rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{actionLoading ? "Archiving..." : "Archive"}
</button>
)}
</>
)}
<div className="flex gap-2">
{!archiveWouldRemoveLastCustomPatch && (
<button
onClick={() => setShowDeleteModal(false)}
onClick={handleDelete}
disabled={actionLoading}
className="flex-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-4 py-2 text-sm font-medium hover:bg-[var(--surface-3)] disabled:opacity-50"
className="flex-1 rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
{actionLoading ? "Archiving..." : "Archive"}
</button>
</div>
</Modal>
)}
)}
<button
onClick={() => setShowDeleteModal(false)}
disabled={actionLoading}
className="flex-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-4 py-2 text-sm font-medium hover:bg-[var(--surface-3)] disabled:opacity-50"
>
Cancel
</button>
</div>
</Modal>
{/* Rollback Modal */}
{showRollbackModal && (
<Modal
title="Rollback to Version"
onClose={() => !actionLoading && setShowRollbackModal(false)}
>
<p className="text-foreground/80 mb-4">
Rollback to version <strong>{patch.version}</strong>? This will set this version as the current patch and unpublish all newer versions.
</p>
<div className="flex gap-2">
<button
onClick={handleRollback}
disabled={actionLoading}
className="flex-1 rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{actionLoading ? "Rolling back..." : "Rollback"}
</button>
<button
onClick={() => setShowRollbackModal(false)}
disabled={actionLoading}
className="flex-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-4 py-2 text-sm font-medium hover:bg-[var(--surface-3)] disabled:opacity-50"
>
Cancel
</button>
</div>
</Modal>
)}
<Modal
title="Rollback to Version"
visible={showRollbackModal}
onClose={() => !actionLoading && setShowRollbackModal(false)}
>
<p className="text-foreground/80 mb-4">
Rollback to version <strong>{patch.version}</strong>? This will set this version as the current patch and unpublish all newer versions.
</p>
<div className="flex gap-2">
<button
onClick={handleRollback}
disabled={actionLoading}
className="flex-1 rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{actionLoading ? "Rolling back..." : "Rollback"}
</button>
<button
onClick={() => setShowRollbackModal(false)}
disabled={actionLoading}
className="flex-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-4 py-2 text-sm font-medium hover:bg-[var(--surface-3)] disabled:opacity-50"
>
Cancel
</button>
</div>
</Modal>
{/* Publish Modal */}
{showPublishModal && (
<Modal
title="Publish Version"
onClose={() => !actionLoading && setShowPublishModal(false)}
>
<p className="text-foreground/80 mb-4">
Publish version <strong>{patch.version}</strong>? This will make it viewable to the public along with its changelog.
</p>
<p className="text-sm text-foreground/60 mb-4">
{isCustomPatcherActive
? "This will not add the version to the Custom patcher list. Add it through Patcher Version Settings if you want it available in the homepage downloader."
: "If this version is newer than the current patch, it will become the primary download used for all users."}
</p>
<div className="flex gap-2">
<button
onClick={handlePublish}
disabled={actionLoading}
className="flex-1 rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{actionLoading ? "Publishing..." : "Publish"}
</button>
<button
onClick={() => setShowPublishModal(false)}
disabled={actionLoading}
className="flex-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-4 py-2 text-sm font-medium hover:bg-[var(--surface-3)] disabled:opacity-50"
>
Cancel
</button>
</div>
</Modal>
)}
<Modal
title="Publish Version"
visible={showPublishModal}
onClose={() => !actionLoading && setShowPublishModal(false)}
>
<p className="text-foreground/80 mb-4">
Publish version <strong>{patch.version}</strong>? This will make it viewable to the public along with its changelog.
</p>
<p className="text-sm text-foreground/60 mb-4">
{isCustomPatcherActive
? "This will not add the version to the Custom patcher list. Add it through Patcher Version Settings if you want it available in the homepage downloader."
: "If this version is newer than the current patch, it will become the primary download used for all users."}
</p>
<div className="flex gap-2">
<button
onClick={handlePublish}
disabled={actionLoading}
className="flex-1 rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{actionLoading ? "Publishing..." : "Publish"}
</button>
<button
onClick={() => setShowPublishModal(false)}
disabled={actionLoading}
className="flex-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-4 py-2 text-sm font-medium hover:bg-[var(--surface-3)] disabled:opacity-50"
>
Cancel
</button>
</div>
</Modal>
{/* Re-upload Modal */}
{showReuploadModal && (
<Modal
title="Re-upload Patch File"
onClose={() => {
if (!actionLoading) {
<Modal
title="Re-upload Patch File"
visible={showReuploadModal}
onClose={() => {
if (!actionLoading) {
setShowReuploadModal(false);
setReuploadFile(null);
setReuploadError(null);
setChecksumStatus("idle");
setChecksumError("");
setGenStatus("idle");
setGenError("");
setBaseRomFile(null);
setPatchMode("bps");
}
}}
>
<div className="mb-4">
<label className="block text-sm font-medium mb-2">
Provide patch <span className="text-red-500">*</span>
</label>
<div className="flex flex-col gap-3">
<div className="inline-flex items-center">
<button
type="button"
onClick={() => setPatchMode("bps")}
className={`rounded-md rounded-r-none px-3 py-1.5 text-xs border-l-1 border-y-1 ${patchMode === "bps" ? "bg-[var(--surface-2)] border-[var(--border)]" : "text-foreground/70 border-[var(--border)]"}`}
>
Upload .bps
</button>
<button
type="button"
onClick={() => setPatchMode("rom")}
className={`rounded-md rounded-l-none px-3 py-1.5 text-xs border-1 ${patchMode === "rom" ? "bg-[var(--surface-2)] border-[var(--border)]" : "text-foreground/70 border-[var(--border)]"}`}
>
Upload modified ROM (auto-generate .bps)
</button>
</div>
{patchMode === "bps" && (
<div className="grid gap-2">
<input
ref={patchInputRef}
onChange={onUploadPatch}
type="file"
accept=".bps"
className="rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm italic text-foreground/50 ring-1 ring-inset ring-[var(--border)] file:bg-black/10 dark:file:bg-[var(--surface-2)] file:text-foreground/80 file:text-sm file:font-medium file:not-italic file:rounded-md file:border-0 file:px-3 file:py-2 file:mr-2 file:cursor-pointer"
/>
<p className="text-xs text-foreground/60">Upload a BPS patch file.</p>
{checksumStatus === "validating" && <div className="text-xs text-foreground/70">Validating checksum</div>}
{checksumStatus === "valid" && <div className="text-xs text-emerald-400/90">Checksum valid.</div>}
{checksumStatus === "invalid" && !!checksumError && <div className="text-xs text-red-400">{checksumError}</div>}
{checksumStatus === "unknown" && !!checksumError && <div className="text-xs text-amber-400/90">{checksumError}</div>}
</div>
)}
{patchMode === "rom" && (
<div className="grid gap-3">
<div className="rounded-md border border-[var(--border)] p-3 bg-[var(--surface-2)]/50">
<div className="text-xs text-foreground/75">Required base ROM</div>
<div className="mt-1 text-sm font-medium">{baseRomEntry ? `${baseRomEntry.name} (${baseRomEntry.platform})` : "Unknown base ROM"}</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<span className={`rounded-full px-2 py-0.5 ring-1 ${baseRomReady ? "bg-emerald-600/60 text-white ring-emerald-700/80 dark:bg-emerald-500/25 dark:text-emerald-100 dark:ring-emerald-400/90" : baseRomNeedsPermission ? "bg-amber-600/60 text-white ring-amber-700/80 dark:bg-amber-500/50 dark:text-amber-100 dark:ring-amber-400/90" : "bg-red-600/60 text-white ring-red-700/80 dark:bg-red-500/50 dark:text-red-100 dark:ring-red-400/90"}`}>
{baseRomReady ? "Ready" : baseRomNeedsPermission ? "Permission needed" : "Base ROM needed"}
</span>
{baseRomNeedsPermission && (
<button type="button" onClick={onGrantPermission} disabled={!supported} className="rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 disabled:opacity-60 disabled:cursor-not-allowed">Grant permission</button>
)}
{baseRomMissing && (
<label className="inline-flex items-center gap-2 text-xs text-foreground/80">
<input
ref={baseRomInputRef}
type="file"
onChange={onUploadBaseRom}
accept={baseRomPlatform ? platformAccept(baseRomPlatform) : "*/*"}
className="rounded-md bg-[var(--surface-2)] px-2 py-1 text-xs ring-1 ring-inset ring-[var(--border)]"
/>
<span>Upload base ROM</span>
</label>
)}
</div>
{!!genError && <div className="mt-2 text-xs text-red-400">{genError}</div>}
</div>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Modified ROM</label>
<input
ref={modifiedRomInputRef}
type="file"
accept={baseRomPlatform ? platformAccept(baseRomPlatform) : "*/*"}
disabled={!baseRomEntry || !baseRomReady || !baseRomPlatform}
onChange={onUploadModifiedRom}
className="rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm ring-1 ring-inset ring-[var(--border)] disabled:opacity-50 disabled:cursor-not-allowed"
/>
<p className="text-xs text-foreground/60">We'll generate a .bps patch on-device. No ROMs are uploaded.</p>
{genStatus === "generating" && <div className="text-xs text-foreground/70">Generating patch</div>}
{genStatus === "ready" && reuploadFile && <div className="text-xs text-emerald-400/90">Patch ready: {reuploadFile.name}</div>}
{genStatus === "error" && !!genError && <div className="text-xs text-red-400">{genError}</div>}
</div>
</div>
)}
</div>
{reuploadError && (
<p className="mt-2 text-sm text-red-400">{reuploadError}</p>
)}
</div>
<div className="flex gap-2">
<button
onClick={handleReupload}
disabled={actionLoading || !reuploadFile || checksumStatus === "invalid"}
className="flex-1 rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{actionLoading ? "Uploading..." : "Upload"}
</button>
<button
onClick={() => {
setShowReuploadModal(false);
setReuploadFile(null);
setReuploadError(null);
@@ -699,163 +807,15 @@ export default function VersionActions({
setGenError("");
setBaseRomFile(null);
setPatchMode("bps");
}
}}
>
<div className="mb-4">
<label className="block text-sm font-medium mb-2">
Provide patch <span className="text-red-500">*</span>
</label>
<div className="flex flex-col gap-3">
<div className="inline-flex items-center">
<button
type="button"
onClick={() => setPatchMode("bps")}
className={`rounded-md rounded-r-none px-3 py-1.5 text-xs border-l-1 border-y-1 ${patchMode === "bps" ? "bg-[var(--surface-2)] border-[var(--border)]" : "text-foreground/70 border-[var(--border)]"}`}
>
Upload .bps
</button>
<button
type="button"
onClick={() => setPatchMode("rom")}
className={`rounded-md rounded-l-none px-3 py-1.5 text-xs border-1 ${patchMode === "rom" ? "bg-[var(--surface-2)] border-[var(--border)]" : "text-foreground/70 border-[var(--border)]"}`}
>
Upload modified ROM (auto-generate .bps)
</button>
</div>
{patchMode === "bps" && (
<div className="grid gap-2">
<input
ref={patchInputRef}
onChange={onUploadPatch}
type="file"
accept=".bps"
className="rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm italic text-foreground/50 ring-1 ring-inset ring-[var(--border)] file:bg-black/10 dark:file:bg-[var(--surface-2)] file:text-foreground/80 file:text-sm file:font-medium file:not-italic file:rounded-md file:border-0 file:px-3 file:py-2 file:mr-2 file:cursor-pointer"
/>
<p className="text-xs text-foreground/60">Upload a BPS patch file.</p>
{checksumStatus === "validating" && <div className="text-xs text-foreground/70">Validating checksum</div>}
{checksumStatus === "valid" && <div className="text-xs text-emerald-400/90">Checksum valid.</div>}
{checksumStatus === "invalid" && !!checksumError && <div className="text-xs text-red-400">{checksumError}</div>}
{checksumStatus === "unknown" && !!checksumError && <div className="text-xs text-amber-400/90">{checksumError}</div>}
</div>
)}
{patchMode === "rom" && (
<div className="grid gap-3">
<div className="rounded-md border border-[var(--border)] p-3 bg-[var(--surface-2)]/50">
<div className="text-xs text-foreground/75">Required base ROM</div>
<div className="mt-1 text-sm font-medium">{baseRomEntry ? `${baseRomEntry.name} (${baseRomEntry.platform})` : "Unknown base ROM"}</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<span className={`rounded-full px-2 py-0.5 ring-1 ${baseRomReady ? "bg-emerald-600/60 text-white ring-emerald-700/80 dark:bg-emerald-500/25 dark:text-emerald-100 dark:ring-emerald-400/90" : baseRomNeedsPermission ? "bg-amber-600/60 text-white ring-amber-700/80 dark:bg-amber-500/50 dark:text-amber-100 dark:ring-amber-400/90" : "bg-red-600/60 text-white ring-red-700/80 dark:bg-red-500/50 dark:text-red-100 dark:ring-red-400/90"}`}>
{baseRomReady ? "Ready" : baseRomNeedsPermission ? "Permission needed" : "Base ROM needed"}
</span>
{baseRomNeedsPermission && (
<button type="button" onClick={onGrantPermission} disabled={!supported} className="rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 disabled:opacity-60 disabled:cursor-not-allowed">Grant permission</button>
)}
{baseRomMissing && (
<label className="inline-flex items-center gap-2 text-xs text-foreground/80">
<input
ref={baseRomInputRef}
type="file"
onChange={onUploadBaseRom}
accept={baseRomPlatform ? platformAccept(baseRomPlatform) : "*/*"}
className="rounded-md bg-[var(--surface-2)] px-2 py-1 text-xs ring-1 ring-inset ring-[var(--border)]"
/>
<span>Upload base ROM</span>
</label>
)}
</div>
{!!genError && <div className="mt-2 text-xs text-red-400">{genError}</div>}
</div>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Modified ROM</label>
<input
ref={modifiedRomInputRef}
type="file"
accept={baseRomPlatform ? platformAccept(baseRomPlatform) : "*/*"}
disabled={!baseRomEntry || !baseRomReady || !baseRomPlatform}
onChange={onUploadModifiedRom}
className="rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm ring-1 ring-inset ring-[var(--border)] disabled:opacity-50 disabled:cursor-not-allowed"
/>
<p className="text-xs text-foreground/60">We'll generate a .bps patch on-device. No ROMs are uploaded.</p>
{genStatus === "generating" && <div className="text-xs text-foreground/70">Generating patch</div>}
{genStatus === "ready" && reuploadFile && <div className="text-xs text-emerald-400/90">Patch ready: {reuploadFile.name}</div>}
{genStatus === "error" && !!genError && <div className="text-xs text-red-400">{genError}</div>}
</div>
</div>
)}
</div>
{reuploadError && (
<p className="mt-2 text-sm text-red-400">{reuploadError}</p>
)}
</div>
<div className="flex gap-2">
<button
onClick={handleReupload}
disabled={actionLoading || !reuploadFile || checksumStatus === "invalid"}
className="flex-1 rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{actionLoading ? "Uploading..." : "Upload"}
</button>
<button
onClick={() => {
setShowReuploadModal(false);
setReuploadFile(null);
setReuploadError(null);
setChecksumStatus("idle");
setChecksumError("");
setGenStatus("idle");
setGenError("");
setBaseRomFile(null);
setPatchMode("bps");
}}
disabled={actionLoading}
className="flex-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-4 py-2 text-sm font-medium hover:bg-[var(--surface-3)] disabled:opacity-50"
>
Cancel
</button>
</div>
</Modal>
)}
}}
disabled={actionLoading}
className="flex-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-4 py-2 text-sm font-medium hover:bg-[var(--surface-3)] disabled:opacity-50"
>
Cancel
</button>
</div>
</Modal>
</>
);
}
function Modal({
title,
children,
onClose,
}: {
title: string;
children: React.ReactNode;
onClose: () => void;
}) {
return (
<div className="fixed left-0 right-0 top-0 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"
onClick={onClose}
/>
<div
role="dialog"
aria-modal="true"
aria-label={title}
className="relative z-[101] card backdrop-blur-lg dark:!bg-black/70 p-6 max-w-md w-full rounded-lg"
>
<button
type="button"
onClick={onClose}
aria-label="Close modal"
className="absolute top-4 right-4 p-1.5 rounded-md text-foreground/60 hover:text-foreground hover:bg-[var(--surface-2)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
>
<FiX size={20} />
</button>
<h2 className="text-xl font-semibold mb-4 pr-8">{title}</h2>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { FiX } from "react-icons/fi";
interface ModalProps {
title: string;
children: React.ReactNode;
visible: boolean;
onClose: () => void;
}
export default function Modal({
title,
children,
visible,
onClose,
}: ModalProps) {
if (!visible) return null;
return (
<div className="fixed left-0 right-0 top-0 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"
onClick={onClose}
/>
<div
role="dialog"
aria-modal="true"
aria-label={title}
className="relative z-[101] card backdrop-blur-lg dark:!bg-black/70 p-6 max-w-md w-full rounded-lg"
>
<button
type="button"
onClick={onClose}
aria-label="Close modal"
className="absolute top-4 right-4 p-1.5 rounded-md text-foreground/60 hover:text-foreground hover:bg-[var(--surface-2)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
>
<FiX size={20} />
</button>
<h2 className="text-xl font-semibold mb-4 pr-8">{title}</h2>
{children}
</div>
</div>
);
}

View File

@@ -169,6 +169,7 @@ export type Database = {
approved: boolean
approved_at: string | null
approved_by: string | null
assigned_admin: string | null
base_rom: string
box_art: string | null
completion_status:
@@ -206,6 +207,7 @@ export type Database = {
approved?: boolean
approved_at?: string | null
approved_by?: string | null
assigned_admin?: string | null
base_rom: string
box_art?: string | null
completion_status?:
@@ -243,6 +245,7 @@ export type Database = {
approved?: boolean
approved_at?: string | null
approved_by?: string | null
assigned_admin?: string | null
base_rom?: string
box_art?: string | null
completion_status?:

View File

@@ -0,0 +1,5 @@
alter table if exists public.hacks
add column if not exists assigned_admin uuid;
alter table public.hacks add constraint "hacks_assigned_admin_fkey" FOREIGN KEY (assigned_admin) REFERENCES auth.users(id) ON DELETE SET NULL not valid;
alter table public.hacks validate constraint "hacks_assigned_admin_fkey";