mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-04-24 15:06:53 -05:00
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:
parent
26b40ca457
commit
627836b448
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
104
src/app/hack/[slug]/changelog/page.tsx
Normal file
104
src/app/hack/[slug]/changelog/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
136
src/app/hack/[slug]/versions/page.tsx
Normal file
136
src/app/hack/[slug]/versions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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" /> },
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
837
src/components/Hack/VersionActions.tsx
Normal file
837
src/components/Hack/VersionActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
487
src/components/Hack/VersionList.tsx
Normal file
487
src/components/Hack/VersionList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
47
src/components/Primitives/CollapsibleCard.tsx
Normal file
47
src/components/Primitives/CollapsibleCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user