Add patch version management and changelogs (#9)

* Start version manager implementation

* Allow changing the name of patch `version`

* Update some links to version manager

* Hide rollback button for unpublished newer versions

* Move "publish" to be first action

* Fix `publishPatchVersion` not updating `published` state

* Reorder HackOptionsMenu and add some icons

* Add "Version Status Guide" info card to versions page

* Add most recent changelog to hack page
This commit is contained in:
Jared Schoeny 2025-12-22 16:55:59 -10:00 committed by GitHub
parent 26b40ca457
commit 627836b448
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 2195 additions and 29 deletions

View File

@ -1,11 +1,12 @@
"use server";
import { createClient } from "@/utils/supabase/server";
import { createClient, createServiceClient } from "@/utils/supabase/server";
import { getMinioClient, PATCHES_BUCKET } from "@/utils/minio/server";
import { isInformationalArchiveHack } from "@/utils/hack";
import { isInformationalArchiveHack, canEditAsCreator } from "@/utils/hack";
import { sendDiscordMessageEmbed } from "@/utils/discord";
import { headers } from "next/headers";
import { validateEmail } from "@/utils/auth";
import { revalidatePath } from "next/cache";
export async function getSignedPatchUrl(slug: string): Promise<{ ok: true; url: string } | { ok: false; error: string }> {
const supabase = await createClient();
@ -211,3 +212,434 @@ export async function submitHackReport(data: {
return { error: null };
}
export async function getPatchDownloadUrl(patchId: number): Promise<{ ok: true; url: string } | { ok: false; error: string }> {
const supabase = await createClient();
// Fetch patch info with parent_hack
const { data: patch, error: patchError } = await supabase
.from("patches")
.select("id, bucket, filename, published, archived, parent_hack")
.eq("id", patchId)
.maybeSingle();
if (patchError || !patch) {
return { ok: false, error: "Patch not found" };
}
// Only allow downloading published, non-archived patches (or if user is creator)
const { data: { user } } = await supabase.auth.getUser();
if (!patch.published || patch.archived) {
if (!user) {
return { ok: false, error: "Unauthorized" };
}
// Check if user is creator
if (!patch.parent_hack) {
return { ok: false, error: "Unauthorized" };
}
const { data: hack } = await supabase
.from("hacks")
.select("created_by")
.eq("slug", patch.parent_hack)
.maybeSingle();
if (!hack || hack.created_by !== user.id) {
return { ok: false, error: "Unauthorized" };
}
}
try {
const client = getMinioClient();
const bucket = patch.bucket || PATCHES_BUCKET;
const signedUrl = await client.presignedGetObject(bucket, patch.filename, 60 * 5);
return { ok: true, url: signedUrl };
} catch (error) {
console.error("Error signing patch URL:", error);
return { ok: false, error: "Failed to generate download URL" };
}
}
export async function archivePatchVersion(slug: string, patchId: number): Promise<{ ok: true } | { ok: false; error: string }> {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return { ok: false, error: "Unauthorized" };
// Fetch hack and verify permissions
const { data: hack, error: hErr } = await supabase
.from("hacks")
.select("slug, created_by, current_patch, original_author")
.eq("slug", slug)
.maybeSingle();
if (hErr || !hack) return { ok: false, error: "Hack not found" };
if (!canEditAsCreator(hack, user.id)) {
return { ok: false, error: "Forbidden" };
}
// Cannot archive current_patch
if (hack.current_patch === patchId) {
return { ok: false, error: "Cannot archive the current patch version" };
}
// Verify patch belongs to this hack
const { data: patch, error: pErr } = await supabase
.from("patches")
.select("id, parent_hack")
.eq("id", patchId)
.maybeSingle();
if (pErr || !patch || patch.parent_hack !== slug) {
return { ok: false, error: "Patch not found" };
}
// Archive the patch
const serviceClient = await createServiceClient();
const { error: updateErr } = await serviceClient
.from("patches")
.update({ archived: true, archived_at: new Date().toISOString() })
.eq("id", patchId);
if (updateErr) return { ok: false, error: updateErr.message };
revalidatePath(`/hack/${slug}/versions`);
return { ok: true };
}
export async function restorePatchVersion(slug: string, patchId: number): Promise<{ ok: true } | { ok: false; error: string }> {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return { ok: false, error: "Unauthorized" };
// Fetch hack and verify permissions
const { data: hack, error: hErr } = await supabase
.from("hacks")
.select("slug, created_by, current_patch, original_author")
.eq("slug", slug)
.maybeSingle();
if (hErr || !hack) return { ok: false, error: "Hack not found" };
if (!canEditAsCreator({ created_by: hack.created_by, current_patch: hack.current_patch, original_author: hack.original_author }, user.id)) {
return { ok: false, error: "Forbidden" };
}
// Verify patch belongs to this hack
const { data: patch, error: pErr } = await supabase
.from("patches")
.select("id, parent_hack")
.eq("id", patchId)
.maybeSingle();
if (pErr || !patch || patch.parent_hack !== slug) {
return { ok: false, error: "Patch not found" };
}
// Restore the patch (un-archive)
const serviceClient = await createServiceClient();
const { error: updateErr } = await serviceClient
.from("patches")
.update({ archived: false, archived_at: null })
.eq("id", patchId);
if (updateErr) return { ok: false, error: updateErr.message };
revalidatePath(`/hack/${slug}/versions`);
return { ok: true };
}
export async function rollbackToVersion(slug: string, patchId: number): Promise<{ ok: true } | { ok: false; error: string }> {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return { ok: false, error: "Unauthorized" };
// Fetch hack and verify permissions
const { data: hack, error: hErr } = await supabase
.from("hacks")
.select("slug, created_by, current_patch, original_author")
.eq("slug", slug)
.maybeSingle();
if (hErr || !hack) return { ok: false, error: "Hack not found" };
if (!canEditAsCreator(hack, user.id)) {
return { ok: false, error: "Forbidden" };
}
// Verify patch belongs to this hack and get its created_at
const { data: rollbackPatch, error: pErr } = await supabase
.from("patches")
.select("id, parent_hack, created_at")
.eq("id", patchId)
.maybeSingle();
if (pErr || !rollbackPatch || rollbackPatch.parent_hack !== slug) {
return { ok: false, error: "Patch not found" };
}
// Update current_patch
const { error: updateHackErr } = await supabase
.from("hacks")
.update({ current_patch: patchId })
.eq("slug", slug);
if (updateHackErr) return { ok: false, error: updateHackErr.message };
// Unpublish all patches created after the rollback patch
const serviceClient = await createServiceClient();
const { error: unpubErr } = await serviceClient
.from("patches")
.update({ published: false })
.eq("parent_hack", slug)
.gt("created_at", rollbackPatch.created_at);
if (unpubErr) return { ok: false, error: unpubErr.message };
revalidatePath(`/hack/${slug}/versions`);
revalidatePath(`/hack/${slug}`);
return { ok: true };
}
export async function updatePatchChangelog(slug: string, patchId: number, changelog: string): Promise<{ ok: true } | { ok: false; error: string }> {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return { ok: false, error: "Unauthorized" };
// Fetch hack and verify permissions
const { data: hack, error: hErr } = await supabase
.from("hacks")
.select("slug, created_by, current_patch, original_author")
.eq("slug", slug)
.maybeSingle();
if (hErr || !hack) return { ok: false, error: "Hack not found" };
if (!canEditAsCreator({ created_by: hack.created_by, current_patch: hack.current_patch, original_author: hack.original_author }, user.id)) {
return { ok: false, error: "Forbidden" };
}
// Verify patch belongs to this hack
const { data: patch, error: pErr } = await supabase
.from("patches")
.select("id, parent_hack")
.eq("id", patchId)
.maybeSingle();
if (pErr || !patch || patch.parent_hack !== slug) {
return { ok: false, error: "Patch not found" };
}
// Update changelog
const serviceClient = await createServiceClient();
const { error: updateErr } = await serviceClient
.from("patches")
.update({ changelog: changelog.trim() || null })
.eq("id", patchId);
if (updateErr) return { ok: false, error: updateErr.message };
revalidatePath(`/hack/${slug}/versions`);
revalidatePath(`/hack/${slug}/changelog`);
return { ok: true };
}
export async function updatePatchVersion(slug: string, patchId: number, version: string): Promise<{ ok: true } | { ok: false; error: string }> {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return { ok: false, error: "Unauthorized" };
// Fetch hack and verify permissions
const { data: hack, error: hErr } = await supabase
.from("hacks")
.select("slug, created_by, current_patch, original_author")
.eq("slug", slug)
.maybeSingle();
if (hErr || !hack) return { ok: false, error: "Hack not found" };
if (!canEditAsCreator({ created_by: hack.created_by, current_patch: hack.current_patch, original_author: hack.original_author }, user.id)) {
return { ok: false, error: "Forbidden" };
}
// Verify patch belongs to this hack
const { data: patch, error: pErr } = await supabase
.from("patches")
.select("id, parent_hack, version")
.eq("id", patchId)
.maybeSingle();
if (pErr || !patch || patch.parent_hack !== slug) {
return { ok: false, error: "Patch not found" };
}
// Trim and validate version
const trimmedVersion = version.trim();
if (!trimmedVersion) {
return { ok: false, error: "Version cannot be empty" };
}
// If version hasn't changed, return success
if (patch.version === trimmedVersion) {
return { ok: true };
}
// Check if version already exists for this hack (excluding current patch)
const { data: existing, error: vErr } = await supabase
.from("patches")
.select("id")
.eq("parent_hack", slug)
.eq("version", trimmedVersion)
.neq("id", patchId)
.maybeSingle();
if (vErr) return { ok: false, error: vErr.message };
if (existing) return { ok: false, error: "That version already exists for this hack." };
// Update version
const serviceClient = await createServiceClient();
const { error: updateErr } = await serviceClient
.from("patches")
.update({ version: trimmedVersion, updated_at: new Date().toISOString() })
.eq("id", patchId);
if (updateErr) return { ok: false, error: updateErr.message };
revalidatePath(`/hack/${slug}/versions`);
revalidatePath(`/hack/${slug}`);
return { ok: true };
}
export async function publishPatchVersion(slug: string, patchId: number): Promise<{ ok: true; willBecomeCurrent?: boolean } | { ok: false; error: string }> {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return { ok: false, error: "Unauthorized" };
// Fetch hack and verify permissions
const { data: hack, error: hErr } = await supabase
.from("hacks")
.select("slug, created_by, current_patch, original_author")
.eq("slug", slug)
.maybeSingle();
if (hErr || !hack) return { ok: false, error: "Hack not found" };
if (!canEditAsCreator(hack, user.id)) {
return { ok: false, error: "Forbidden" };
}
// Verify patch belongs to this hack and get its created_at
const { data: patch, error: pErr } = await supabase
.from("patches")
.select("id, parent_hack, created_at")
.eq("id", patchId)
.maybeSingle();
if (pErr || !patch || patch.parent_hack !== slug) {
return { ok: false, error: "Patch not found" };
}
// Check if this patch is newer than current_patch
let willBecomeCurrent = false;
const serviceClient = await createServiceClient();
if (hack.current_patch) {
const { data: currentPatch } = await serviceClient
.from("patches")
.select("created_at")
.eq("id", hack.current_patch)
.maybeSingle();
if (currentPatch && new Date(patch.created_at) > new Date(currentPatch.created_at)) {
willBecomeCurrent = true;
}
} else {
willBecomeCurrent = true;
}
// Publish the patch
const { error: updateErr } = await serviceClient
.from("patches")
.update({ published: true, published_at: new Date().toISOString() })
.eq("id", patchId);
if (updateErr) return { ok: false, error: updateErr.message };
// If newer than current_patch, update current_patch
if (willBecomeCurrent) {
const { error: updateHackErr } = await supabase
.from("hacks")
.update({ current_patch: patchId })
.eq("slug", slug);
if (updateHackErr) return { ok: false, error: updateHackErr.message };
}
revalidatePath(`/hack/${slug}/versions`);
revalidatePath(`/hack/${slug}`);
return { ok: true, willBecomeCurrent };
}
export async function reuploadPatchVersion(
slug: string,
patchId: number,
objectKey: string
): Promise<{ ok: true; presignedUrl: string } | { ok: false; error: string }> {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return { ok: false, error: "Unauthorized" };
// Fetch hack and verify permissions
const { data: hack, error: hErr } = await supabase
.from("hacks")
.select("slug, created_by, current_patch, original_author")
.eq("slug", slug)
.maybeSingle();
if (hErr || !hack) return { ok: false, error: "Hack not found" };
if (!canEditAsCreator({ created_by: hack.created_by, current_patch: hack.current_patch, original_author: hack.original_author }, user.id)) {
return { ok: false, error: "Forbidden" };
}
// Verify patch belongs to this hack
const { data: patch, error: pErr } = await supabase
.from("patches")
.select("id, parent_hack, filename")
.eq("id", patchId)
.maybeSingle();
if (pErr || !patch || patch.parent_hack !== slug) {
return { ok: false, error: "Patch not found" };
}
// Generate presigned URL for upload
const client = getMinioClient();
const url = await client.presignedPutObject(PATCHES_BUCKET, objectKey, 60 * 10);
// Update patch filename after upload (caller should handle the actual upload and update)
return { ok: true, presignedUrl: url };
}
export async function confirmReuploadPatchVersion(
slug: string,
patchId: number,
objectKey: string
): Promise<{ ok: true } | { ok: false; error: string }> {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return { ok: false, error: "Unauthorized" };
// Fetch hack and verify permissions
const { data: hack, error: hErr } = await supabase
.from("hacks")
.select("slug, created_by, current_patch, original_author")
.eq("slug", slug)
.maybeSingle();
if (hErr || !hack) return { ok: false, error: "Hack not found" };
if (!canEditAsCreator({ created_by: hack.created_by, current_patch: hack.current_patch, original_author: hack.original_author }, user.id)) {
return { ok: false, error: "Forbidden" };
}
// Verify patch belongs to this hack
const { data: patch, error: pErr } = await supabase
.from("patches")
.select("id, parent_hack")
.eq("id", patchId)
.maybeSingle();
if (pErr || !patch || patch.parent_hack !== slug) {
return { ok: false, error: "Patch not found" };
}
// Update patch filename
const serviceClient = await createServiceClient();
const { error: updateErr } = await serviceClient
.from("patches")
.update({ filename: objectKey, updated_at: new Date().toISOString() })
.eq("id", patchId);
if (updateErr) return { ok: false, error: updateErr.message };
revalidatePath(`/hack/${slug}/versions`);
return { ok: true };
}

View File

@ -0,0 +1,104 @@
import { notFound } from "next/navigation";
import { createClient } from "@/utils/supabase/server";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeSlug from "rehype-slug";
import Link from "next/link";
import { FaChevronLeft } from "react-icons/fa6";
interface ChangelogPageProps {
params: Promise<{ slug: string }>;
}
export default async function ChangelogPage({ params }: ChangelogPageProps) {
const { slug } = await params;
const supabase = await createClient();
// Fetch hack
const { data: hack } = await supabase
.from("hacks")
.select("slug, title, current_patch")
.eq("slug", slug)
.maybeSingle();
if (!hack) return notFound();
// Fetch all published, non-archived patches with changelogs
const { data: patches } = await supabase
.from("patches")
.select("id, version, created_at, changelog")
.eq("parent_hack", slug)
.eq("published", true)
.eq("archived", false)
.not("changelog", "is", null)
.order("created_at", { ascending: false });
const patchesWithChangelogs = (patches || []).filter(p => p.changelog && p.changelog.trim().length > 0);
return (
<div className="mx-auto w-full max-w-screen-md px-6 py-10">
<div className="mb-6">
<Link
href={`/hack/${slug}`}
className="inline-flex items-center text-sm text-foreground/60 hover:text-foreground mb-2"
>
<FaChevronLeft size={14} className="mr-1" />
Back to hack
</Link>
<h1 className="text-3xl font-bold tracking-tight">Changelog</h1>
<p className="mt-1 text-sm text-foreground/60">
{hack.title}
</p>
</div>
{patchesWithChangelogs.length === 0 ? (
<div className="card p-8 text-center">
<p className="text-foreground/60">No changelogs available yet.</p>
</div>
) : (
<div className="space-y-6">
{patchesWithChangelogs.map((patch) => (
<div key={patch.id} className="card p-6">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold">
{patch.version}
{hack.current_patch === patch.id && (
<span className="ml-2 inline-flex items-center rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
Current
</span>
)}
</h2>
<p className="mt-1 text-sm text-foreground/60">
{new Date(patch.created_at).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</p>
</div>
</div>
<div className="prose prose-sm max-w-none text-foreground/80">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSlug]}
components={{
h1: 'h2',
h2: 'h3',
h3: 'h4',
h4: 'h5',
h5: 'h6',
h6: 'h6',
}}
>
{patch.changelog || ""}
</ReactMarkdown>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -1,7 +1,7 @@
import { notFound, redirect } from "next/navigation";
import HackForm from "@/components/Hack/HackForm";
import { createClient } from "@/utils/supabase/server";
import { FaChevronLeft, FaChevronRight } from "react-icons/fa6";
import { FaChevronLeft, FaChevronRight, FaPlus } from "react-icons/fa6";
import Link from "next/link";
import { sortOrderedTags, getCoverUrls } from "@/utils/format";
import { checkEditPermission } from "@/utils/hack";
@ -90,16 +90,26 @@ export default async function EditHackPage({ params }: EditPageProps) {
<FaChevronRight size={22} className="inline-block mx-2 text-foreground/50 align-middle" />
<span className="gradient-text font-bold">{hack.title}</span>
</h1>
<div className="flex items-center gap-2 self-end lg:self-auto mt-8 lg:mt-0">
<Link href={`/hack/${slug}`} className="inline-flex items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-sm font-medium hover:bg-black/5 dark:hover:bg-white/10">
<div className="flex items-center gap-2 md:flex-row flex-col md:self-end lg:self-auto mt-8 lg:mt-0">
<Link href={`/hack/${slug}`} className="inline-flex items-center justify-center h-12 md:h-10 w-full md:w-auto rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-sm font-medium hover:bg-black/5 dark:hover:bg-white/10">
<FaChevronLeft size={16} className="inline-block mr-1" />
Back to hack
</Link>
{!isArchive && (
<Link href={`/hack/${slug}/edit/patch`} className="inline-flex items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-sm font-medium hover:bg-black/5 dark:hover:bg-white/10">
Upload new version
{!isArchive && <>
<Link
href={`/hack/${slug}/versions`}
className="inline-flex items-center justify-center h-12 md:h-10 w-full md:w-auto px-4 text-sm font-medium rounded-md border border-[var(--border)] bg-[var(--surface-2)] hover:bg-[var(--surface-3)] transition-colors"
>
Manage Versions
</Link>
)}
<Link
href={`/hack/${slug}/edit/patch`}
className="inline-flex items-center justify-center h-12 md:h-10 w-full md:w-auto px-4 text-sm font-semibold rounded-md bg-[var(--accent)] text-[var(--accent-foreground)] hover:bg-[var(--accent-700)] transition-colors"
>
<FaPlus size={14} className="mr-2" />
Upload New Version
</Link>
</>}
</div>
</div>
<div className="mt-4 lg:mt-8">

View File

@ -23,6 +23,7 @@ import { sortOrderedTags, getCoverUrls } from "@/utils/format";
import { RiArchiveStackFill } from "react-icons/ri";
import { isInformationalArchiveHack, isDownloadableArchiveHack, isArchiveHack, checkEditPermission } from "@/utils/hack";
import Avatar from "@/components/Account/Avatar";
import CollapsibleCard from "@/components/Primitives/CollapsibleCard";
interface HackDetailProps {
params: Promise<{ slug: string }>;
@ -197,10 +198,11 @@ export default async function HackDetail({ params }: HackDetailProps) {
let patchId: number | null = null;
let lastUpdated: string | null = null;
let patchCreatedAt: string | null = null;
let patchChangelog: string | null = null;
if (hack.current_patch != null) {
const { data: patch } = await supabase
.from("patches")
.select("id,bucket,filename,version,created_at")
.select("id,bucket,filename,version,created_at,changelog")
.eq("id", hack.current_patch as number)
.maybeSingle();
if (patch) {
@ -209,6 +211,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
patchId = patch.id;
lastUpdated = new Date(patch.created_at).toLocaleDateString();
patchCreatedAt = patch.created_at;
patchChangelog = patch.changelog;
}
}
@ -405,6 +408,37 @@ export default async function HackDetail({ params }: HackDetailProps) {
<div className="space-y-6 lg:min-w-[640px]">
<Gallery images={images} title={hack.title} />
{patchId && patchCreatedAt && (
<CollapsibleCard
title={`${patchVersion || "Pre-release"} released on ${new Date(patchCreatedAt).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
})}`}
>
{patchChangelog && patchChangelog.trim().length > 0 ? (
<div className="prose prose-sm max-w-none text-foreground/80">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSlug]}
components={{
h1: 'h2',
h2: 'h3',
h3: 'h4',
h4: 'h5',
h5: 'h6',
h6: 'h6',
}}
>
{patchChangelog}
</ReactMarkdown>
</div>
) : (
<p className="italic text-foreground/60">No changelog provided</p>
)}
</CollapsibleCard>
)}
<div className="card-simple p-5">
<h2 className="text-2xl font-semibold tracking-tight">About this hack</h2>
<div className="prose prose-sm mt-3 max-w-none text-foreground/80">

View File

@ -0,0 +1,136 @@
import { notFound } from "next/navigation";
import { createClient } from "@/utils/supabase/server";
import { canEditAsCreator } from "@/utils/hack";
import VersionList from "@/components/Hack/VersionList";
import CollapsibleCard from "@/components/Primitives/CollapsibleCard";
import Link from "next/link";
import { FaChevronLeft, FaPlus, FaStar } from "react-icons/fa6";
interface VersionsPageProps {
params: Promise<{ slug: string }>;
}
export default async function VersionsPage({ params }: VersionsPageProps) {
const { slug } = await params;
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
// Fetch hack
const { data: hack } = await supabase
.from("hacks")
.select("slug, title, created_by, current_patch, original_author, permission_from, base_rom")
.eq("slug", slug)
.maybeSingle();
if (!hack) return notFound();
// Check if user can edit (creator only for version management)
const canEdit = user ? canEditAsCreator(hack, user.id) : false;
// Fetch all published, non-archived patches
const { data: patches } = await supabase
.from("patches")
.select("id, version, created_at, updated_at, changelog, published, archived")
.eq("parent_hack", slug)
.eq("published", true)
.eq("archived", false)
.order("created_at", { ascending: false });
// Also fetch unpublished patches if user can edit
let unpublishedPatches: any[] = [];
if (canEdit) {
const { data: unpub } = await supabase
.from("patches")
.select("id, version, created_at, updated_at, changelog, published, archived")
.eq("parent_hack", slug)
.eq("published", false)
.eq("archived", false)
.order("created_at", { ascending: false });
unpublishedPatches = unpub || [];
}
const allPatches = [...(patches || []), ...unpublishedPatches].sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
return (
<div className="mx-auto w-full max-w-screen-md px-4 sm:px-6 py-6 sm:py-10">
<div className="mb-6">
<Link
href={`/hack/${slug}`}
className="inline-flex items-center text-sm text-foreground/60 hover:text-foreground mb-3"
>
<FaChevronLeft size={14} className="mr-1" />
Back to hack
</Link>
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight mb-2">
{canEdit ? "Manage Versions" : "Version History"}
</h1>
<p className="text-sm text-foreground/60 mb-4">
{hack.title}
</p>
<div className="flex flex-col sm:flex-row gap-2">
<Link
href={`/hack/${slug}/changelog`}
className="inline-flex items-center justify-center h-10 px-4 text-sm font-medium rounded-md border border-[var(--border)] bg-[var(--surface-2)] hover:bg-[var(--surface-3)] transition-colors"
>
View Changelog
</Link>
{canEdit && (
<Link
href={`/hack/${slug}/edit/patch`}
className="inline-flex items-center justify-center h-10 px-4 text-sm font-semibold rounded-md bg-[var(--accent)] text-[var(--accent-foreground)] hover:bg-[var(--accent-700)] transition-colors"
>
<FaPlus size={14} className="mr-2" />
Upload New Version
</Link>
)}
</div>
</div>
<CollapsibleCard title="Version Status Guide">
<div className="space-y-5 sm:space-y-2.5 text-sm text-foreground/80">
<div className="flex flex-col sm:grid sm:grid-cols-[100px_1fr] gap-2 sm:gap-1 items-start">
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400 shrink-0 w-fit">
<FaStar size={10} />
Current
</span>
<p className="text-foreground/70">
{canEdit ?
"The version that is currently active and visible to all users. This is the version users will download when pressing \"Patch Now\" on the hack page." :
"This is the version you will download when pressing \"Patch Now\" on the hack page."
}
</p>
</div>
{canEdit && <>
<div className="flex flex-col sm:grid sm:grid-cols-[100px_1fr] gap-2 sm:gap-1 items-start">
<span className="inline-flex items-center rounded-full bg-amber-500/20 px-2 py-0.5 text-xs font-medium text-amber-600 dark:text-amber-400 shrink-0 w-fit">
Unpublished
</span>
<p className="text-foreground/70">
Versions that are only visible to you, and will not appear in the public version list or changelog.
</p>
</div>
<div className="flex flex-col sm:grid sm:grid-cols-[100px_1fr] gap-2 sm:gap-1 items-start">
<span className="inline-flex items-center rounded-full bg-gray-500/20 px-2 py-0.5 text-xs font-medium text-gray-600 dark:text-gray-400 shrink-0 w-fit">
Archived
</span>
<p className="text-foreground/70">
Same as unpublished, but archived versions are hidden from normal view on this page. Check "Show archived versions" to view and restore them.
</p>
</div>
</>}
</div>
</CollapsibleCard>
<VersionList
patches={allPatches}
currentPatchId={hack.current_patch}
canEdit={canEdit}
hackSlug={slug}
baseRom={hack.base_rom}
/>
</div>
);
}

View File

@ -213,7 +213,7 @@ export async function presignPatchAndSaveCovers(args: {
return { ok: true, presignedUrl: url, objectKey } as const;
}
export async function confirmPatchUpload(args: { slug: string; objectKey: string; version: string, firstUpload?: boolean }) {
export async function confirmPatchUpload(args: { slug: string; objectKey: string; version: string, firstUpload?: boolean; publishAutomatically?: boolean }) {
const supabase = await createClient();
const {
data: { user },
@ -247,18 +247,51 @@ export async function confirmPatchUpload(args: { slug: string; objectKey: string
if (existing) return { ok: false, error: "That version already exists for this hack." } as const;
// Create patch row
const patchInsert: any = {
bucket: PATCHES_BUCKET,
filename: args.objectKey,
version: args.version,
parent_hack: args.slug,
};
// Set published status based on publishAutomatically flag
if (args.publishAutomatically) {
patchInsert.published = true;
patchInsert.published_at = new Date().toISOString();
} else {
patchInsert.published = false;
}
const { data: patch, error: pErr } = await supabase
.from("patches")
.insert({ bucket: PATCHES_BUCKET, filename: args.objectKey, version: args.version, parent_hack: args.slug })
.select("id")
.insert(patchInsert)
.select("id, created_at")
.single();
if (pErr) return { ok: false, error: pErr.message } as const;
// Only update current_patch if publishAutomatically is true
if (args.publishAutomatically) {
// Check if this patch is newer than current_patch
let shouldUpdateCurrentPatch = true;
if (hack.current_patch) {
const { data: currentPatch } = await supabase
.from("patches")
.select("created_at")
.eq("id", hack.current_patch)
.maybeSingle();
if (currentPatch && new Date(patch.created_at) <= new Date(currentPatch.created_at)) {
shouldUpdateCurrentPatch = false;
}
}
if (shouldUpdateCurrentPatch) {
const { error: uErr } = await supabase
.from("hacks")
.update({ current_patch: patch.id })
.eq("slug", args.slug);
if (uErr) return { ok: false, error: uErr.message } as const;
}
}
if (process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL) {
const { data: profile } = await supabase.from('profiles').select('*').eq('id', hack.created_by).single();
@ -292,7 +325,9 @@ export async function confirmPatchUpload(args: { slug: string; objectKey: string
]);
}
return { ok: true, patchId: patch.id, redirectTo: `/hack/${args.slug}` } as const;
// Redirect to versions page if not publishing automatically, otherwise to hack page
const redirectTo = args.publishAutomatically ? `/hack/${args.slug}` : `/hack/${args.slug}/versions`;
return { ok: true, patchId: patch.id, redirectTo } as const;
}

View File

@ -3,6 +3,7 @@
import React from "react";
import Link from "next/link";
import { FiExternalLink, FiEdit2, FiUpload, FiShare2, FiBarChart2, FiMoreVertical, FiCheck } from "react-icons/fi";
import { TbVersions } from "react-icons/tb";
import { useFloating, offset, flip, shift, autoUpdate } from "@floating-ui/react";
import ActionSheet from "@/components/Primitives/ActionSheet";
@ -65,8 +66,8 @@ export default function HackList({ hacks }: { hacks: HackRow[] }) {
<IconTooltipButton href={`/hack/${h.slug}/edit`} label="Edit">
<FiEdit2 className="h-4 w-4" />
</IconTooltipButton>
<IconTooltipButton href={`/hack/${h.slug}/edit/patch`} label="Upload patch">
<FiUpload className="h-4 w-4" />
<IconTooltipButton href={`/hack/${h.slug}/versions`} label="Manage versions">
<TbVersions className="h-5 w-5" />
</IconTooltipButton>
<ShareIconButton slug={h.slug} />
</div>
@ -131,7 +132,7 @@ function buildActions(slug: string | null) {
{ key: "view", label: "View", href: `/hack/${slug}`, icon: <FiExternalLink className="h-4 w-4" /> },
{ key: "stats", label: "Stats", href: `/hack/${slug}/stats`, icon: <FiBarChart2 className="h-4 w-4" /> },
{ key: "edit", label: "Edit", href: `/hack/${slug}/edit`, icon: <FiEdit2 className="h-4 w-4" /> },
{ key: "upload", label: "Upload patch", href: `/hack/${slug}/edit/patch`, icon: <FiUpload className="h-4 w-4" /> },
{ key: "versions", label: "Manage versions", href: `/hack/${slug}/versions`, icon: <TbVersions className="h-4 w-4" /> },
{ key: "share", label: "Share link", onClick: () => copyShare(slug), icon: <FiShare2 className="h-4 w-4" /> },
];
}

View File

@ -1,7 +1,8 @@
"use client";
import React, { useState } from "react";
import { FiMoreVertical } from "react-icons/fi";
import { FiMoreVertical, FiEdit2, FiBarChart2 } from "react-icons/fi";
import { TbVersions } from "react-icons/tb";
import { Menu, MenuButton, MenuItem, MenuItems, MenuSeparator } from "@headlessui/react";
import ReportModal from "@/components/Hack/ReportModal";
@ -33,14 +34,32 @@ export default function HackOptionsMenu({
<MenuItems
transition
className="absolute right-0 z-10 mt-2 w-40 origin-top-right overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-2)] backdrop-blur-lg shadow-lg focus:outline-none transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
className="absolute right-0 z-10 mt-2 w-42 origin-top-right overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-2)] backdrop-blur-lg shadow-lg focus:outline-none transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
>
<MenuItem
as="a"
href={`/hack/${slug}/changelog`}
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10"
>
Changelog
</MenuItem>
{!canUploadPatch && (
<MenuItem
as="a"
href={`/hack/${slug}/versions`}
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10"
>
Version history
</MenuItem>
)}
<MenuSeparator className="my-1 h-px bg-[var(--border)]" />
<MenuItem
as="button"
onClick={() => {
setShowReportModal(true);
}}
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10">
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10"
>
Report
</MenuItem>
{canEdit && <>
@ -48,24 +67,30 @@ export default function HackOptionsMenu({
<MenuItem
as="a"
href={`/hack/${slug}/stats`}
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10">
className="flex items-center gap-2 w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10"
>
<FiBarChart2 className="h-4 w-4" />
Stats
</MenuItem>
<MenuItem
as="a"
href={`/hack/${slug}/edit`}
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10">
className="flex items-center gap-2 w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10"
>
<FiEdit2 className="h-4 w-4" />
Edit
</MenuItem>
</>}
{canUploadPatch && <>
{canUploadPatch && (
<MenuItem
as="a"
href={`/hack/${slug}/edit/patch`}
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10">
Upload new version
href={`/hack/${slug}/versions`}
className="flex items-center gap-2 w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10"
>
<TbVersions className="h-4 w-4" />
Manage versions
</MenuItem>
</>}
)}
{children && <>
<MenuSeparator className="my-1 h-px bg-[var(--border)]" />
{children}

View File

@ -28,6 +28,7 @@ export default function HackPatchForm(props: HackPatchFormProps) {
const [genError, setGenError] = React.useState<string>("");
const [submitting, setSubmitting] = React.useState(false);
const [error, setError] = React.useState<string>("");
const [publishAutomatically, setPublishAutomatically] = React.useState(false);
const versionInputRef = React.useRef<HTMLInputElement | null>(null);
const patchInputRef = React.useRef<HTMLInputElement | null>(null);
@ -148,7 +149,7 @@ export default function HackPatchForm(props: HackPatchFormProps) {
const presigned = await presignNewPatchVersion({ slug, version: version.trim() });
if (!presigned.ok) throw new Error(presigned.error || 'Failed to presign');
await fetch(presigned.presignedUrl!, { method: 'PUT', body: patchFile!, headers: { 'Content-Type': 'application/octet-stream' } });
const finalized = await confirmPatchUpload({ slug, objectKey: presigned.objectKey!, version: version.trim() });
const finalized = await confirmPatchUpload({ slug, objectKey: presigned.objectKey!, version: version.trim(), publishAutomatically });
if (!finalized.ok) throw new Error(finalized.error || 'Failed to finalize');
window.location.href = finalized.redirectTo!;
} catch (e: any) {
@ -260,7 +261,24 @@ export default function HackPatchForm(props: HackPatchFormProps) {
{!!error && <div className="text-sm text-red-400">{error}</div>}
<div className="flex items-center justify-end gap-3 border-t border-[var(--border)] pt-4 mt-2">
<div className="flex items-start gap-3 border-t border-[var(--border)] pt-4 mt-2">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={publishAutomatically}
onChange={(e) => setPublishAutomatically(e.target.checked)}
className="mt-0.5 rounded border-[var(--border)] text-emerald-600 focus:ring-emerald-600"
/>
<div className="text-sm">
<div className="font-medium text-foreground/90">Publish Automatically</div>
<div className="text-foreground/60 mt-0.5">
If checked, this version will be published and set as the current patch immediately after upload.
</div>
</div>
</label>
</div>
<div className="flex items-center justify-end gap-3">
<button
type="button"
onClick={onSubmit}

View File

@ -0,0 +1,837 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { FiX, FiMoreVertical } from "react-icons/fi";
import {
FaDownload,
FaTrash,
FaRotateLeft,
FaUpload,
FaCheck,
} from "react-icons/fa6";
import { Menu, MenuButton, MenuItem, MenuItems, MenuSeparator } from "@headlessui/react";
import {
archivePatchVersion,
restorePatchVersion,
rollbackToVersion,
publishPatchVersion,
getPatchDownloadUrl,
reuploadPatchVersion,
confirmReuploadPatchVersion,
} from "@/app/hack/[slug]/actions";
import BinFile from "rom-patcher-js/rom-patcher-js/modules/BinFile.js";
import BPS from "rom-patcher-js/rom-patcher-js/modules/RomPatcher.format.bps.js";
import { sha1Hex } from "@/utils/hash";
import { baseRoms, type BaseRom } from "@/data/baseRoms";
import { platformAccept } from "@/utils/idb";
import { useBaseRoms } from "@/contexts/BaseRomContext";
interface Patch {
id: number;
version: string;
created_at: string;
changelog: string | null;
published: boolean;
archived: boolean;
}
interface VersionActionsProps {
patch: Patch;
isCurrent: boolean;
hackSlug: string;
baseRom: string;
currentPatchCreatedAt: string | null;
onActionComplete: () => void;
}
export default function VersionActions({
patch,
isCurrent,
hackSlug,
baseRom,
currentPatchCreatedAt,
onActionComplete,
}: VersionActionsProps) {
const { isLinked, hasPermission, hasCached, importUploadedBlob, ensurePermission, getFileBlob, supported } = useBaseRoms();
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showRestoreModal, setShowRestoreModal] = useState(false);
const [showRollbackModal, setShowRollbackModal] = useState(false);
const [showPublishModal, setShowPublishModal] = useState(false);
const [showReuploadModal, setShowReuploadModal] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
const [patchMode, setPatchMode] = useState<"bps" | "rom">("bps");
const [reuploadFile, setReuploadFile] = useState<File | null>(null);
const [reuploadError, setReuploadError] = useState<string | null>(null);
const [checksumStatus, setChecksumStatus] = useState<"idle" | "validating" | "valid" | "invalid" | "unknown">("idle");
const [checksumError, setChecksumError] = useState<string>("");
const [genStatus, setGenStatus] = useState<"idle" | "generating" | "ready" | "error">("idle");
const [genError, setGenError] = useState<string>("");
const [baseRomFile, setBaseRomFile] = useState<File | null>(null);
const patchInputRef = useRef<HTMLInputElement>(null);
const modifiedRomInputRef = useRef<HTMLInputElement>(null);
const baseRomInputRef = useRef<HTMLInputElement>(null);
const baseRomEntry = baseRoms.find((r) => r.id === baseRom);
const baseRomPlatform = baseRomEntry?.platform;
const baseRomReady = baseRom && (hasPermission(baseRom) || hasCached(baseRom));
const baseRomNeedsPermission = baseRom && isLinked(baseRom) && !baseRomReady;
const baseRomMissing = baseRom && !isLinked(baseRom) && !hasCached(baseRom);
// Determine if this patch is newer than the current patch
const isNewerThanCurrent = currentPatchCreatedAt
? new Date(patch.created_at).getTime() > new Date(currentPatchCreatedAt).getTime()
: false;
// Don't show Rollback if the patch is unpublished and newer than the current version
const shouldShowRollback = !isCurrent && !(!patch.published && isNewerThanCurrent);
useEffect(() => {
if (showDeleteModal || showRestoreModal || showRollbackModal || showPublishModal || showReuploadModal) {
const html = document.documentElement;
const body = document.body;
const previousHtmlOverflow = html.style.overflow;
const previousBodyOverflow = body.style.overflow;
const previousBodyPaddingRight = body.style.paddingRight;
const scrollBarWidth = window.innerWidth - html.clientWidth;
html.style.overflow = "hidden";
body.style.overflow = "hidden";
if (scrollBarWidth > 0) {
body.style.paddingRight = `${scrollBarWidth}px`;
}
return () => {
html.style.overflow = previousHtmlOverflow;
body.style.overflow = previousBodyOverflow;
body.style.paddingRight = previousBodyPaddingRight;
};
}
}, [showDeleteModal, showRestoreModal, showRollbackModal, showPublishModal, showReuploadModal]);
const handleDownload = async () => {
try {
const result = await getPatchDownloadUrl(patch.id);
if (result.ok) {
window.open(result.url, "_blank");
} else {
alert(result.error || "Failed to generate download URL");
}
} catch (error) {
alert("Failed to download patch");
}
};
const handleDelete = async () => {
setActionLoading(true);
try {
const result = await archivePatchVersion(hackSlug, patch.id);
if (result.ok) {
setShowDeleteModal(false);
onActionComplete();
} else {
alert(result.error || "Failed to archive version");
}
} catch (error) {
alert("Failed to archive version");
} finally {
setActionLoading(false);
}
};
const handleRestore = async () => {
setActionLoading(true);
try {
const result = await restorePatchVersion(hackSlug, patch.id);
if (result.ok) {
setShowRestoreModal(false);
onActionComplete();
} else {
alert(result.error || "Failed to restore version");
}
} catch (error) {
alert("Failed to restore version");
} finally {
setActionLoading(false);
}
};
const handleRollback = async () => {
setActionLoading(true);
try {
const result = await rollbackToVersion(hackSlug, patch.id);
if (result.ok) {
setShowRollbackModal(false);
onActionComplete();
} else {
alert(result.error || "Failed to rollback version");
}
} catch (error) {
alert("Failed to rollback version");
} finally {
setActionLoading(false);
}
};
const handlePublish = async () => {
setActionLoading(true);
try {
const result = await publishPatchVersion(hackSlug, patch.id);
if (result.ok) {
setShowPublishModal(false);
onActionComplete();
} else {
alert(result.error || "Failed to publish version");
}
} catch (error) {
alert("Failed to publish version");
} finally {
setActionLoading(false);
}
};
async function onUploadPatch(e: React.ChangeEvent<HTMLInputElement>) {
try {
setChecksumStatus("validating");
setChecksumError("");
const patchFile = e.target.files?.[0] || null;
if (!patchFile) {
setChecksumStatus("idle");
setChecksumError("");
setReuploadFile(null);
return;
}
if (!baseRomEntry) {
setChecksumStatus("unknown");
setChecksumError("A checksum is not available to validate this patch file. Proceed at your own risk, or upload your modified ROM instead.");
setReuploadFile(patchFile);
return;
}
// Verify that the patch is a valid BPS file for the selected base ROM
const bps = BPS.fromFile(new BinFile(await patchFile.arrayBuffer()));
if (bps.sourceChecksum === 0 || bps.sourceChecksum === undefined) {
setChecksumStatus("unknown");
setChecksumError("A checksum is not available to validate this patch file. Proceed at your own risk, or upload your modified ROM instead.");
setReuploadFile(patchFile);
return;
}
const baseRomChecksum = parseInt(baseRomEntry.crc32, 16);
if (bps.sourceChecksum !== baseRomChecksum) {
setChecksumStatus("invalid");
setChecksumError("Checksum validation failed. The patch file is not compatible with the selected base ROM.");
setReuploadFile(null);
return;
}
// All checks passed, set the checksum status to valid
setChecksumStatus("valid");
setChecksumError("");
setReuploadFile(patchFile);
} catch (err: any) {
setChecksumStatus("unknown");
setChecksumError(err?.message || "Failed to validate patch file.");
setReuploadFile(null);
}
}
async function onUploadBaseRom(e: React.ChangeEvent<HTMLInputElement>) {
try {
setGenError("");
const f = e.target.files?.[0];
if (!f || !baseRom) return;
const matchedId = await importUploadedBlob(f);
if (!matchedId) {
setGenError("That ROM doesn't match any supported base ROM.");
return;
}
if (matchedId !== baseRom) {
const matchedName = baseRoms.find(r => r.id === matchedId)?.name;
const baseRomName = baseRomEntry?.name || baseRom;
setGenError(`This ROM matches "${matchedName ?? matchedId}", but the form requires "${baseRomName}".`);
return;
}
setBaseRomFile(f);
} catch {
setGenError("Failed to import base ROM.");
setBaseRomFile(null);
}
}
async function onGrantPermission() {
if (!baseRom) return;
await ensurePermission(baseRom, true);
}
async function onUploadModifiedRom(e: React.ChangeEvent<HTMLInputElement>) {
try {
setGenStatus("generating");
setGenError("");
const mod = e.target.files?.[0] || null;
if (!mod || !baseRom) {
setGenStatus("idle");
return;
}
let baseFile = baseRomFile;
if (!baseFile) {
baseFile = await getFileBlob(baseRom);
}
if (!baseFile) {
setGenStatus("error");
setGenError("Base ROM not available. Please upload the base ROM first.");
return;
}
if (baseRomEntry?.sha1) {
const hash = await sha1Hex(baseFile);
if (hash.toLowerCase() !== baseRomEntry.sha1.toLowerCase()) {
setGenStatus("error");
setGenError("Selected base ROM hash does not match the chosen base ROM.");
return;
}
}
const [origBuf, modBuf] = await Promise.all([baseFile.arrayBuffer(), mod.arrayBuffer()]);
const origBin = new BinFile(origBuf);
const modBin = new BinFile(modBuf);
const deltaMode = origBin.fileSize <= 4194304;
const patch = BPS.buildFromRoms(origBin, modBin, deltaMode);
const fileName = hackSlug || "patch";
const patchBin = patch.export(fileName);
const out = new File([patchBin._u8array], `${fileName}.bps`, { type: 'application/octet-stream' });
setReuploadFile(out);
setGenStatus("ready");
} catch (err: any) {
setGenStatus("error");
setGenError(err?.message || "Failed to generate patch.");
}
}
const handleReupload = async () => {
if (!reuploadFile) {
setReuploadError("Please select a file");
return;
}
setActionLoading(true);
setReuploadError(null);
try {
const safeVersion = patch.version.replace(/[^a-zA-Z0-9._-]+/g, "-");
const objectKey = `${hackSlug}-${safeVersion}-reupload-${Date.now()}.bps`;
const presignResult = await reuploadPatchVersion(hackSlug, patch.id, objectKey);
if (!presignResult.ok) {
throw new Error(presignResult.error || "Failed to get upload URL");
}
// Upload file
const uploadResponse = await fetch(presignResult.presignedUrl, {
method: "PUT",
body: reuploadFile,
headers: { "Content-Type": "application/octet-stream" },
});
if (!uploadResponse.ok) {
throw new Error("Failed to upload file");
}
// Confirm upload
const confirmResult = await confirmReuploadPatchVersion(hackSlug, patch.id, objectKey);
if (confirmResult.ok) {
setShowReuploadModal(false);
setReuploadFile(null);
onActionComplete();
} else {
throw new Error(confirmResult.error || "Failed to confirm upload");
}
} catch (error: any) {
setReuploadError(error.message || "Failed to re-upload patch");
} finally {
setActionLoading(false);
}
};
// Mobile: Use dropdown menu, Desktop: Show buttons
// If archived, only show Download and Restore
if (patch.archived) {
return (
<>
{/* Desktop: Show buttons */}
<div className="hidden sm:flex flex-wrap gap-1.5">
<button
onClick={handleDownload}
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 text-xs font-medium hover:bg-[var(--surface-3)] transition-colors"
title="Download"
>
<FaDownload size={12} />
Download
</button>
<button
onClick={() => setShowRestoreModal(true)}
className="inline-flex items-center gap-1.5 rounded-md border border-emerald-600/50 bg-emerald-600/10 px-2 py-1 text-xs font-medium text-emerald-600 hover:bg-emerald-600/20 transition-colors"
title="Restore version"
>
<FaRotateLeft size={12} />
Restore
</button>
</div>
{/* Mobile: Use dropdown menu */}
<Menu as="div" className="relative sm:hidden">
<MenuButton
aria-label="Version actions"
className="inline-flex h-8 w-8 items-center justify-center rounded-md ring-1 ring-[var(--border)] bg-[var(--surface-2)] text-foreground/80 hover:bg-[var(--surface-3)] hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border)]"
>
<FiMoreVertical size={16} />
</MenuButton>
<MenuItems
transition
className="absolute right-0 z-10 mt-2 w-48 origin-top-right overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-2)] backdrop-blur-lg shadow-lg focus:outline-none transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
>
<MenuItem
as="button"
onClick={handleDownload}
className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10"
>
<FaDownload size={14} />
Download
</MenuItem>
<MenuItem
as="button"
onClick={() => setShowRestoreModal(true)}
className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm text-emerald-600 data-focus:bg-emerald-600/10"
>
<FaRotateLeft size={14} />
Restore
</MenuItem>
</MenuItems>
</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>
)}
</>
);
}
// Non-archived patches: show all buttons
return (
<>
{/* Desktop: Show buttons */}
<div className="hidden sm:flex flex-wrap gap-1.5">
{!patch.published && (
<button
onClick={() => setShowPublishModal(true)}
className="inline-flex items-center gap-1.5 rounded-md border border-emerald-600/50 bg-emerald-600/10 px-2 py-1 text-xs font-medium text-emerald-600 hover:bg-emerald-600/20 transition-colors"
title="Publish"
>
<FaCheck size={12} />
Publish
</button>
)}
<button
onClick={handleDownload}
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 text-xs font-medium hover:bg-[var(--surface-3)] transition-colors"
title="Download"
>
<FaDownload size={12} />
Download
</button>
<button
onClick={() => setShowReuploadModal(true)}
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 text-xs font-medium hover:bg-[var(--surface-3)] transition-colors"
title="Re-upload patch file"
>
<FaUpload size={12} />
Re-upload
</button>
{shouldShowRollback && (
<button
onClick={() => setShowRollbackModal(true)}
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 text-xs font-medium hover:bg-[var(--surface-3)] transition-colors"
title="Rollback to this version"
>
<FaRotateLeft size={12} />
Rollback
</button>
)}
{!isCurrent && (
<button
onClick={() => setShowDeleteModal(true)}
className="inline-flex items-center gap-1.5 rounded-md border border-red-600/50 bg-red-600/10 px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-600/20 transition-colors"
title="Archive version"
>
<FaTrash size={12} />
Archive
</button>
)}
</div>
{/* Mobile: Use dropdown menu */}
<Menu as="div" className="relative sm:hidden">
<MenuButton
aria-label="Version actions"
className="inline-flex h-8 w-8 items-center justify-center rounded-md ring-1 ring-[var(--border)] bg-[var(--surface-2)] text-foreground/80 hover:bg-[var(--surface-3)] hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border)]"
>
<FiMoreVertical size={16} />
</MenuButton>
<MenuItems
transition
className="absolute right-0 z-10 mt-2 w-48 origin-top-right overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-2)] backdrop-blur-lg shadow-lg focus:outline-none transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
>
{!patch.published && (
<MenuItem
as="button"
onClick={() => setShowPublishModal(true)}
className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm text-emerald-600 data-focus:bg-emerald-600/10"
>
<FaCheck size={14} />
Publish
</MenuItem>
)}
<MenuItem
as="button"
onClick={handleDownload}
className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10"
>
<FaDownload size={14} />
Download
</MenuItem>
<MenuItem
as="button"
onClick={() => setShowReuploadModal(true)}
className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10"
>
<FaUpload size={14} />
Re-upload
</MenuItem>
{(!isCurrent || shouldShowRollback) && (
<>
<MenuSeparator className="my-1 h-px bg-[var(--border)]" />
{shouldShowRollback && (
<MenuItem
as="button"
onClick={() => setShowRollbackModal(true)}
className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10"
>
<FaRotateLeft size={14} />
Rollback
</MenuItem>
)}
{!isCurrent && (
<MenuItem
as="button"
onClick={() => setShowDeleteModal(true)}
className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm text-red-600 data-focus:bg-red-600/10"
>
<FaTrash size={14} />
Archive
</MenuItem>
)}
</>
)}
</MenuItems>
</Menu>
{/* Delete Modal */}
{showDeleteModal && (
<Modal
title="Archive Version"
onClose={() => !actionLoading && setShowDeleteModal(false)}
>
<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.
</p>
<div className="flex gap-2">
<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>
<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>
)}
{/* 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">
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) {
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);
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>
)}
</>
);
}
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,487 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeSlug from "rehype-slug";
import { FaChevronDown, FaChevronUp, FaStar, FaDownload, FaTrash, FaRotateLeft, FaUpload, FaCheck, FaPlus } from "react-icons/fa6";
import { FiEdit2, FiEdit, FiX } from "react-icons/fi";
import VersionActions from "@/components/Hack/VersionActions";
import { updatePatchChangelog, updatePatchVersion } from "@/app/hack/[slug]/actions";
import { useRouter } from "next/navigation";
import { createClient } from "@/utils/supabase/client";
interface Patch {
id: number;
version: string;
created_at: string;
updated_at: string | null;
changelog: string | null;
published: boolean;
archived: boolean;
}
interface VersionListProps {
patches: Patch[];
currentPatchId: number | null;
canEdit: boolean;
hackSlug: string;
baseRom: string;
}
export default function VersionList({ patches, currentPatchId, canEdit, hackSlug, baseRom }: VersionListProps) {
// Initialize with first patch's changelog expanded if it exists
const getInitialExpanded = () => {
if (patches.length > 0) {
const firstPatch = patches[0];
if (firstPatch.changelog && firstPatch.changelog.trim().length > 0) {
return new Set([firstPatch.id]);
}
}
return new Set<number>();
};
const [expandedChangelogs, setExpandedChangelogs] = useState<Set<number>>(getInitialExpanded);
const [editingChangelog, setEditingChangelog] = useState<number | null>(null);
const [editingVersion, setEditingVersion] = useState<number | null>(null);
const [showArchived, setShowArchived] = useState(false);
const [archivedPatches, setArchivedPatches] = useState<Patch[]>([]);
const [loadingArchived, setLoadingArchived] = useState(false);
const router = useRouter();
const supabase = createClient();
const toggleChangelog = (patchId: number) => {
const newExpanded = new Set(expandedChangelogs);
if (newExpanded.has(patchId)) {
newExpanded.delete(patchId);
} else {
newExpanded.add(patchId);
}
setExpandedChangelogs(newExpanded);
};
// Fetch archived patches when checkbox is checked
useEffect(() => {
if (showArchived && canEdit && archivedPatches.length === 0 && !loadingArchived) {
setLoadingArchived(true);
supabase
.from("patches")
.select("id, version, created_at, updated_at, changelog, published, archived")
.eq("parent_hack", hackSlug)
.eq("archived", true)
.order("created_at", { ascending: false })
.then(({ data, error }) => {
if (!error && data) {
setArchivedPatches(data as Patch[]);
}
setLoadingArchived(false);
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showArchived, canEdit, hackSlug]);
// Combine patches with archived patches when showing archived
// Filter out any archived patches that are already in the regular patches list (e.g., after restore)
const allPatches = showArchived && canEdit
? [...patches, ...archivedPatches.filter(archived => !patches.some(p => p.id === archived.id))].sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
: patches;
if (patches.length === 0 && (!showArchived || archivedPatches.length === 0)) {
return (
<div className="card p-8 text-center">
<p className="text-foreground/60">No versions available yet.</p>
</div>
);
}
return (
<div className="space-y-3 sm:space-y-4">
{canEdit && (
<label className="flex items-center gap-2 cursor-pointer py-2">
<input
type="checkbox"
checked={showArchived}
onChange={(e) => setShowArchived(e.target.checked)}
className="rounded border-[var(--border)] text-[var(--accent)] focus:ring-[var(--accent)]"
/>
<span className="text-sm font-medium">Show archived versions</span>
</label>
)}
{allPatches.map((patch) => {
const isCurrent = currentPatchId === patch.id;
const hasChangelog = patch.changelog && patch.changelog.trim().length > 0;
const isExpanded = expandedChangelogs.has(patch.id);
const isEditing = editingChangelog === patch.id;
const currentPatch = allPatches.find(p => p.id === currentPatchId);
const currentPatchCreatedAt = currentPatch?.created_at || null;
return (
<div
key={patch.id}
className={`card p-4 sm:p-5 ${isCurrent ? "ring-2 ring-emerald-500/50" : ""}`}
>
<div className="space-y-3 sm:space-y-4">
<div className="flex items-start justify-between gap-3 sm:gap-4">
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 mb-1.5 sm:mb-2">
{editingVersion === patch.id ? (
<VersionEditor
patchId={patch.id}
initialVersion={patch.version}
hackSlug={hackSlug}
onSave={() => {
setEditingVersion(null);
router.refresh();
}}
onCancel={() => setEditingVersion(null)}
/>
) : (
<>
<h3 className="text-base sm:text-lg font-semibold">{patch.version}</h3>
{canEdit && (
<button
onClick={() => setEditingVersion(patch.id)}
className="inline-flex items-center justify-center rounded-md p-1.5 text-foreground/60 hover:text-foreground hover:bg-[var(--surface-2)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--accent)] touch-manipulation"
title="Edit version"
aria-label="Edit version"
>
<FiEdit size={14} />
</button>
)}
</>
)}
{isCurrent && editingVersion !== patch.id && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<FaStar size={10} />
Current
</span>
)}
{!patch.published && (
<span className="inline-flex items-center rounded-full bg-amber-500/20 px-2 py-0.5 text-xs font-medium text-amber-600 dark:text-amber-400">
Unpublished
</span>
)}
{patch.archived && (
<span className="inline-flex items-center rounded-full bg-gray-500/20 px-2 py-0.5 text-xs font-medium text-gray-600 dark:text-gray-400">
Archived
</span>
)}
</div>
<div className="text-xs sm:text-sm text-foreground/60">
<p>
Created: {new Date(patch.created_at).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
{patch.updated_at && patch.updated_at !== patch.created_at && (
<p>
Updated: {new Date(patch.updated_at).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
)}
</div>
</div>
<div className="shrink-0">
{canEdit && (
<VersionActions
patch={patch}
isCurrent={isCurrent}
hackSlug={hackSlug}
baseRom={baseRom}
currentPatchCreatedAt={currentPatchCreatedAt}
onActionComplete={() => {
router.refresh();
setEditingChangelog(null);
setEditingVersion(null);
// Clear archived patches to force refetch if checkbox is toggled
// This ensures restored/archived patches don't show duplicates
setArchivedPatches([]);
}}
/>
)}
</div>
</div>
{hasChangelog ? (
<div className="border-t border-[var(--border)] pt-2">
<div className="flex items-center justify-between gap-2">
<button
onClick={() => toggleChangelog(patch.id)}
disabled={isEditing}
className="flex-1 flex items-center justify-between gap-3 py-2 text-left text-sm font-medium text-foreground/80 enabled:hover:text-foreground transition-colors group"
>
<span className="flex items-center gap-2">
<span className="text-foreground/60 group-hover:text-foreground/80 transition-colors group-disabled:hidden" aria-hidden={isEditing}>
{isExpanded ? <FaChevronUp size={12} /> : <FaChevronDown size={12} />}
</span>
<span>{isEditing ? "Edit Changelog" : "Changelog"}</span>
</span>
</button>
{canEdit && !isEditing && (
<button
onClick={() => {
setEditingChangelog(patch.id);
// Expand accordion if not already expanded
if (!isExpanded) {
toggleChangelog(patch.id);
}
}}
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 text-xs font-medium hover:bg-[var(--surface-3)] transition-colors"
title="Edit changelog"
>
<FiEdit2 size={12} />
Edit
</button>
)}
</div>
{isExpanded && (
<div className="ml-5 mt-2">
{isEditing ? (
<ChangelogEditor
patchId={patch.id}
initialChangelog={patch.changelog || ""}
hackSlug={hackSlug}
onSave={() => {
setEditingChangelog(null);
router.refresh();
}}
onCancel={() => setEditingChangelog(null)}
/>
) : (
<div className="prose prose-sm max-w-none text-foreground/80">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSlug]}
components={{
h1: 'h2',
h2: 'h3',
h3: 'h4',
h4: 'h5',
h5: 'h6',
h6: 'h6',
}}
>
{patch.changelog || ""}
</ReactMarkdown>
</div>
)}
</div>
)}
</div>
) : (
canEdit && (
<div className="border-t border-[var(--border)] pt-3">
{isEditing ? (
<ChangelogEditor
patchId={patch.id}
initialChangelog={patch.changelog || ""}
hackSlug={hackSlug}
onSave={() => {
setEditingChangelog(null);
router.refresh();
}}
onCancel={() => setEditingChangelog(null)}
/>
) : (
<button
onClick={() => setEditingChangelog(patch.id)}
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 text-sm font-medium hover:bg-[var(--surface-3)] transition-colors"
>
<FaPlus size={12} />
<span>Add Changelog</span>
</button>
)}
</div>
)
)}
</div>
</div>
);
})}
</div>
);
}
function ChangelogEditor({
patchId,
initialChangelog,
hackSlug,
onSave,
onCancel,
}: {
patchId: number;
initialChangelog: string;
hackSlug: string;
onSave: () => void;
onCancel: () => void;
}) {
const [changelog, setChangelog] = useState(initialChangelog);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSave = async () => {
setSaving(true);
setError(null);
try {
const result = await updatePatchChangelog(hackSlug, patchId, changelog);
if (result.ok) {
onSave();
} else {
setError(result.error || "Failed to update changelog");
}
} catch (e) {
setError("Failed to update changelog");
} finally {
setSaving(false);
}
};
return (
<div>
<label className="hidden">Edit Changelog</label>
<textarea
value={changelog}
onChange={(e) => setChangelog(e.target.value)}
rows={8}
className="w-full rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
placeholder="Enter changelog in Markdown format..."
/>
{error && <p className="mt-2 text-sm text-red-400">{error}</p>}
<div className="mt-3 flex gap-2">
<button
onClick={handleSave}
disabled={saving}
className="inline-flex items-center gap-2 rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<FaCheck size={12} />
Save
</button>
<button
onClick={onCancel}
disabled={saving}
className="inline-flex items-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-1.5 text-sm font-medium hover:bg-[var(--surface-3)] disabled:opacity-50"
>
Cancel
</button>
</div>
</div>
);
}
function VersionEditor({
patchId,
initialVersion,
hackSlug,
onSave,
onCancel,
}: {
patchId: number;
initialVersion: string;
hackSlug: string;
onSave: () => void;
onCancel: () => void;
}) {
const [version, setVersion] = useState(initialVersion);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Auto-focus input on mount
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
const handleSave = async () => {
const trimmedVersion = version.trim();
if (!trimmedVersion) {
setError("Version cannot be empty");
return;
}
if (trimmedVersion === initialVersion) {
onCancel();
return;
}
setSaving(true);
setError(null);
try {
const result = await updatePatchVersion(hackSlug, patchId, trimmedVersion);
if (result.ok) {
onSave();
} else {
setError(result.error || "Failed to update version");
}
} catch (e) {
setError("Failed to update version");
} finally {
setSaving(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
};
return (
<div className="flex flex-col gap-2">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2">
<input
ref={inputRef}
type="text"
value={version}
onChange={(e) => {
setVersion(e.target.value);
setError(null);
}}
onKeyDown={handleKeyDown}
disabled={saving}
className="w-auto min-w-[90px] rounded-md bg-[var(--surface-2)] px-2.5 py-1.5 text-base sm:text-lg font-semibold ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)] disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="Version name"
/>
<div className="flex items-center gap-1.5 shrink-0">
<button
onClick={handleSave}
disabled={saving || !version.trim()}
className="inline-flex items-center justify-center rounded-md bg-emerald-600 px-2.5 py-1.5 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed touch-manipulation"
title="Save version"
aria-label="Save version"
>
<FaCheck size={12} />
</button>
<button
onClick={onCancel}
disabled={saving}
className="inline-flex items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2.5 py-1.5 text-sm font-medium hover:bg-[var(--surface-3)] disabled:opacity-50 touch-manipulation"
title="Cancel editing"
aria-label="Cancel editing"
>
<FiX size={14} />
</button>
</div>
</div>
{error && <p className="mt-1 text-xs text-red-400">{error}</p>}
</div>
);
}

View File

@ -0,0 +1,47 @@
"use client";
import { useState, ReactNode } from "react";
import { FaChevronDown } from "react-icons/fa6";
interface CollapsibleCardProps {
title: string;
children: ReactNode;
defaultExpanded?: boolean;
}
export default function CollapsibleCard({ title, children, defaultExpanded = false }: CollapsibleCardProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
return (
<div className="card-simple p-4 sm:p-5">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between gap-2 text-left hover:opacity-80 transition-opacity"
aria-expanded={isExpanded}
>
<h2 className="text-sm font-semibold text-foreground/90">{title}</h2>
<span
className={`text-foreground/60 shrink-0 transition-transform duration-300 ease-in-out ${
isExpanded ? "rotate-180" : ""
}`}
>
<FaChevronDown size={14} />
</span>
</button>
<div
className={`grid transition-all duration-300 ease-in-out ${
isExpanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
}`}
>
<div className="overflow-hidden">
<div className={`mt-3 transition-opacity duration-300 ${
isExpanded ? "opacity-100" : "opacity-0"
}`}>
{children}
</div>
</div>
</div>
</div>
);
}