diff --git a/src/app/hack/[slug]/actions.ts b/src/app/hack/[slug]/actions.ts index 59e6c76..c82d1d4 100644 --- a/src/app/hack/[slug]/actions.ts +++ b/src/app/hack/[slug]/actions.ts @@ -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,371 @@ 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 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; + if (hack.current_patch) { + const serviceClient = await createServiceClient(); + 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 supabase + .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 }; +} + diff --git a/src/app/hack/[slug]/changelog/page.tsx b/src/app/hack/[slug]/changelog/page.tsx new file mode 100644 index 0000000..96ccc63 --- /dev/null +++ b/src/app/hack/[slug]/changelog/page.tsx @@ -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 ( +
+
+ + + Back to hack + +

Changelog

+

+ {hack.title} +

+
+ + {patchesWithChangelogs.length === 0 ? ( +
+

No changelogs available yet.

+
+ ) : ( +
+ {patchesWithChangelogs.map((patch) => ( +
+
+
+

+ {patch.version} + {hack.current_patch === patch.id && ( + + Current + + )} +

+

+ {new Date(patch.created_at).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + })} +

+
+
+
+ + {patch.changelog || ""} + +
+
+ ))} +
+ )} +
+ ); +} + diff --git a/src/app/hack/[slug]/versions/page.tsx b/src/app/hack/[slug]/versions/page.tsx new file mode 100644 index 0000000..f186973 --- /dev/null +++ b/src/app/hack/[slug]/versions/page.tsx @@ -0,0 +1,100 @@ +import { notFound } from "next/navigation"; +import { createClient } from "@/utils/supabase/server"; +import { canEditAsCreator } from "@/utils/hack"; +import VersionList from "@/components/Hack/VersionList"; +import Link from "next/link"; +import { FaChevronLeft, FaPlus } 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 ( +
+
+ + + Back to hack + +

+ {canEdit ? "Manage Versions" : "Version History"} +

+

+ {hack.title} +

+
+ + View Changelog + + {canEdit && ( + + + Upload New Version + + )} +
+
+ + +
+ ); +} + diff --git a/src/app/submit/actions.ts b/src/app/submit/actions.ts index f9ec8e9..8ec6398 100644 --- a/src/app/submit/actions.ts +++ b/src/app/submit/actions.ts @@ -195,7 +195,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 }, @@ -229,18 +229,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_URL) { const { data: profile } = await supabase.from('profiles').select('*').eq('id', user.id).single(); @@ -269,7 +302,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; } diff --git a/src/components/Hack/HackOptionsMenu.tsx b/src/components/Hack/HackOptionsMenu.tsx index e36ea05..d73d880 100644 --- a/src/components/Hack/HackOptionsMenu.tsx +++ b/src/components/Hack/HackOptionsMenu.tsx @@ -35,6 +35,21 @@ export default function HackOptionsMenu({ 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" > + + Changelog + + {!canUploadPatch && ( + + Version history + + )} + { @@ -82,14 +97,14 @@ export default function HackOptionsMenu({ Edit } - {canUploadPatch && <> + {canUploadPatch && ( - Upload new version + Manage versions - } + )} {children && <> {children} diff --git a/src/components/Hack/HackPatchForm.tsx b/src/components/Hack/HackPatchForm.tsx index 1fede10..99799d1 100644 --- a/src/components/Hack/HackPatchForm.tsx +++ b/src/components/Hack/HackPatchForm.tsx @@ -28,6 +28,7 @@ export default function HackPatchForm(props: HackPatchFormProps) { const [genError, setGenError] = React.useState(""); const [submitting, setSubmitting] = React.useState(false); const [error, setError] = React.useState(""); + const [publishAutomatically, setPublishAutomatically] = React.useState(false); const versionInputRef = React.useRef(null); const patchInputRef = React.useRef(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 &&
{error}
} -
+
+ +
+ +
+ + +
+ + {/* Mobile: Use dropdown menu */} + + + + + + + + + Download + + + 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" + > + + Restore + + + + + {/* Restore Modal */} + {showRestoreModal && ( + !actionLoading && setShowRestoreModal(false)} + > +

+ Restore version {patch.version}? This will make it visible again. +

+
+ + +
+
+ )} + + ); + } + + // Non-archived patches: show all buttons + return ( + <> + {/* Desktop: Show buttons */} +
+ + + {!patch.published && ( + + )} + + + + {!isCurrent && ( + <> + + + + + )} +
+ + {/* Mobile: Use dropdown menu */} + + + + + + + + + Download + + + {!patch.published && ( + 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" + > + + Publish + + )} + + 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" + > + + Re-upload + + + {!isCurrent && ( + <> + + 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" + > + + Rollback + + + 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" + > + + Archive + + + )} + + + + {/* Delete Modal */} + {showDeleteModal && ( + !actionLoading && setShowDeleteModal(false)} + > +

+ Are you sure you want to archive version {patch.version}? This will hide it from public view, but it can be restored later. +

+
+ + +
+
+ )} + + {/* Rollback Modal */} + {showRollbackModal && ( + !actionLoading && setShowRollbackModal(false)} + > +

+ Rollback to version {patch.version}? This will set this version as the current patch and unpublish all newer versions. +

+
+ + +
+
+ )} + + {/* Publish Modal */} + {showPublishModal && ( + !actionLoading && setShowPublishModal(false)} + > +

+ Publish version {patch.version}? This will make it viewable to the public along with its changelog. +

+

+ If this version is newer than the current patch, it will become the primary download used for all users. +

+
+ + +
+
+ )} + + {/* Re-upload Modal */} + {showReuploadModal && ( + { + if (!actionLoading) { + setShowReuploadModal(false); + setReuploadFile(null); + setReuploadError(null); + setChecksumStatus("idle"); + setChecksumError(""); + setGenStatus("idle"); + setGenError(""); + setBaseRomFile(null); + setPatchMode("bps"); + } + }} + > +
+ +
+
+ + +
+ + {patchMode === "bps" && ( +
+ +

Upload a BPS patch file.

+ {checksumStatus === "validating" &&
Validating checksum…
} + {checksumStatus === "valid" &&
Checksum valid.
} + {checksumStatus === "invalid" && !!checksumError &&
{checksumError}
} + {checksumStatus === "unknown" && !!checksumError &&
{checksumError}
} +
+ )} + + {patchMode === "rom" && ( +
+
+
Required base ROM
+
{baseRomEntry ? `${baseRomEntry.name} (${baseRomEntry.platform})` : "Unknown base ROM"}
+
+ + {baseRomReady ? "Ready" : baseRomNeedsPermission ? "Permission needed" : "Base ROM needed"} + + {baseRomNeedsPermission && ( + + )} + {baseRomMissing && ( + + )} +
+ {!!genError &&
{genError}
} +
+ +
+ + +

We'll generate a .bps patch on-device. No ROMs are uploaded.

+ {genStatus === "generating" &&
Generating patch…
} + {genStatus === "ready" && reuploadFile &&
Patch ready: {reuploadFile.name}
} + {genStatus === "error" && !!genError &&
{genError}
} +
+
+ )} +
+ {reuploadError && ( +

{reuploadError}

+ )} +
+
+ + +
+
+ )} + + ); +} + +function Modal({ + title, + children, + onClose, +}: { + title: string; + children: React.ReactNode; + onClose: () => void; +}) { + return ( +
+
+
+ +

{title}

+ {children} +
+
+ ); +} + diff --git a/src/components/Hack/VersionList.tsx b/src/components/Hack/VersionList.tsx new file mode 100644 index 0000000..dd3844a --- /dev/null +++ b/src/components/Hack/VersionList.tsx @@ -0,0 +1,354 @@ +"use client"; + +import React, { useState, useEffect } 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 } from "react-icons/fi"; +import VersionActions from "@/components/Hack/VersionActions"; +import { getPatchDownloadUrl } 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(); + }; + + const [expandedChangelogs, setExpandedChangelogs] = useState>(getInitialExpanded); + const [editingChangelog, setEditingChangelog] = useState(null); + const [showArchived, setShowArchived] = useState(false); + const [archivedPatches, setArchivedPatches] = useState([]); + 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 ( +
+

No versions available yet.

+
+ ); + } + + return ( +
+ {canEdit && ( + + )} + + {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; + + return ( +
+
+
+
+
+

{patch.version}

+ {isCurrent && ( + + + Current + + )} + {!patch.published && ( + + Unpublished + + )} + {patch.archived && ( + + Archived + + )} +
+
+

+ Created: {new Date(patch.created_at).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} +

+ {patch.updated_at && patch.updated_at !== patch.created_at && ( +

+ Updated: {new Date(patch.updated_at).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} +

+ )} +
+
+ +
+ {canEdit && ( + { + router.refresh(); + setEditingChangelog(null); + // Clear archived patches to force refetch if checkbox is toggled + // This ensures restored/archived patches don't show duplicates + setArchivedPatches([]); + }} + /> + )} +
+
+ + {hasChangelog ? ( +
+
+ + {canEdit && !isEditing && ( + + )} +
+ {isExpanded && ( +
+ {isEditing ? ( + { + setEditingChangelog(null); + router.refresh(); + }} + onCancel={() => setEditingChangelog(null)} + /> + ) : ( +
+ + {patch.changelog || ""} + +
+ )} +
+ )} +
+ ) : ( + canEdit && ( +
+ {isEditing ? ( + { + setEditingChangelog(null); + router.refresh(); + }} + onCancel={() => setEditingChangelog(null)} + /> + ) : ( + + )} +
+ ) + )} +
+
+ ); + })} +
+ ); +} + +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(null); + + const handleSave = async () => { + setSaving(true); + setError(null); + try { + const { updatePatchChangelog } = await import("@/app/hack/[slug]/actions"); + 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 ( +
+ +