mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-08-22 00:24:34 -05:00
Make pending hacks assignable to admin for review (#66)
Some checks failed
Deploy Supabase Migrations to Production / migrate (push) Has been cancelled
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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
|
||||
423
src/components/Dashboard/PendingHacks.tsx
Normal file
423
src/components/Dashboard/PendingHacks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
43
src/components/Primitives/Modal.tsx
Normal file
43
src/components/Primitives/Modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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?:
|
||||
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user