mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-05-06 04:55:34 -05:00
Implement patch download permissions
This commit is contained in:
parent
bb4fda1579
commit
d773e29f0b
|
|
@ -10,7 +10,11 @@ import { validateEmail } from "@/utils/auth";
|
|||
import { revalidatePath, revalidateTag } from "next/cache";
|
||||
import { unstable_cache as cache } from "next/cache";
|
||||
import { sortOrderedTags, getCoverUrls } from "@/utils/format";
|
||||
import { Database } from "@/types/db";
|
||||
import { Database, Constants } from "@/types/db";
|
||||
|
||||
const PATCHES_DOWNLOAD_PERMISSION_VALUES = Constants.public.Enums[
|
||||
"Patches Download Permission"
|
||||
] as readonly Database["public"]["Enums"]["Patches Download Permission"][];
|
||||
|
||||
export interface HackMetadata {
|
||||
hack: {
|
||||
|
|
@ -415,25 +419,44 @@ export async function getPatchDownloadUrl(patchId: number): Promise<{ ok: true;
|
|||
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 (!patch.parent_hack) {
|
||||
return { ok: false, error: "Patch not found" };
|
||||
}
|
||||
|
||||
if (!hack || hack.created_by !== user.id) {
|
||||
const { data: hack, error: hackError } = await supabase
|
||||
.from("hacks")
|
||||
.select("slug, created_by, original_author, is_archive, current_patch, patches_download_permission")
|
||||
.eq("slug", patch.parent_hack)
|
||||
.maybeSingle();
|
||||
|
||||
if (hackError || !hack) {
|
||||
return { ok: false, error: "Hack not found" };
|
||||
}
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
const isEditor =
|
||||
!!user &&
|
||||
(canEditAsCreator(hack, user.id) || (await canEditAsAdmin(hack, user.id, supabase)));
|
||||
|
||||
// Only allow downloading published, non-archived patches (unless user can edit)
|
||||
if (!patch.published || patch.archived) {
|
||||
if (!isEditor) {
|
||||
return { ok: false, error: "Unauthorized" };
|
||||
}
|
||||
} else if (!isEditor) {
|
||||
const permission = hack.patches_download_permission;
|
||||
if (permission === "None") {
|
||||
return { ok: false, error: "Unauthorized" };
|
||||
}
|
||||
if (permission === "Current") {
|
||||
if (hack.current_patch == null || patch.id !== hack.current_patch) {
|
||||
return { ok: false, error: "Unauthorized" };
|
||||
}
|
||||
}
|
||||
// "All": published + non-archived already satisfied
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -451,6 +474,46 @@ export async function getPatchDownloadUrl(patchId: number): Promise<{ ok: true;
|
|||
}
|
||||
}
|
||||
|
||||
export async function updatePatchesDownloadPermission(
|
||||
slug: string,
|
||||
permission: Database["public"]["Enums"]["Patches Download Permission"],
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
if (!PATCHES_DOWNLOAD_PERMISSION_VALUES.includes(permission)) {
|
||||
return { ok: false, error: "Invalid permission" };
|
||||
}
|
||||
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) return { ok: false, error: "Unauthorized" };
|
||||
|
||||
const { data: hack, error: hErr } = await supabase
|
||||
.from("hacks")
|
||||
.select("slug, created_by, current_patch, original_author, is_archive")
|
||||
.eq("slug", slug)
|
||||
.maybeSingle();
|
||||
if (hErr || !hack) return { ok: false, error: "Hack not found" };
|
||||
|
||||
if (!canEditAsCreator(hack, user.id)) {
|
||||
const editableAsAdmin = await canEditAsAdmin(hack, user.id, supabase);
|
||||
if (!editableAsAdmin) {
|
||||
return { ok: false, error: "Forbidden" };
|
||||
}
|
||||
}
|
||||
|
||||
const serviceClient = await createServiceClient();
|
||||
const { error: updateErr } = await serviceClient
|
||||
.from("hacks")
|
||||
.update({ patches_download_permission: permission })
|
||||
.eq("slug", slug);
|
||||
|
||||
if (updateErr) return { ok: false, error: updateErr.message };
|
||||
|
||||
revalidatePath(`/hack/${slug}/versions`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
|
|||
import { createClient } from "@/utils/supabase/server";
|
||||
import { canEditAsCreator, canEditAsAdmin } from "@/utils/hack";
|
||||
import VersionList from "@/components/Hack/VersionList";
|
||||
import DownloadPermissionSettings from "@/components/Hack/DownloadPermissionSettings";
|
||||
import CollapsibleCard from "@/components/Primitives/CollapsibleCard";
|
||||
import Link from "next/link";
|
||||
import { FaChevronLeft, FaPlus, FaStar } from "react-icons/fa6";
|
||||
|
|
@ -18,7 +19,7 @@ export default async function VersionsPage({ params }: VersionsPageProps) {
|
|||
// Fetch hack
|
||||
const { data: hack } = await supabase
|
||||
.from("hacks")
|
||||
.select("slug, title, created_by, current_patch, original_author, permission_from, base_rom, is_archive")
|
||||
.select("slug, title, created_by, current_patch, original_author, permission_from, base_rom, is_archive, patches_download_permission")
|
||||
.eq("slug", slug)
|
||||
.maybeSingle();
|
||||
|
||||
|
|
@ -88,6 +89,13 @@ export default async function VersionsPage({ params }: VersionsPageProps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<DownloadPermissionSettings
|
||||
hackSlug={slug}
|
||||
initialPermission={hack.patches_download_permission}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CollapsibleCard
|
||||
title="Version Status Guide"
|
||||
className="mb-6 bg-[var(--surface-1)] border border-[var(--border)]/50 rounded-lg"
|
||||
|
|
@ -132,6 +140,7 @@ export default async function VersionsPage({ params }: VersionsPageProps) {
|
|||
canEdit={canEdit}
|
||||
hackSlug={slug}
|
||||
baseRom={hack.base_rom}
|
||||
patchesDownloadPermission={hack.patches_download_permission}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
222
src/components/Hack/DownloadPermissionSettings.tsx
Normal file
222
src/components/Hack/DownloadPermissionSettings.tsx
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { updatePatchesDownloadPermission } from "@/app/hack/[slug]/actions";
|
||||
import type { Database } from "@/types/db";
|
||||
import CollapsibleCard from "@/components/Primitives/CollapsibleCard";
|
||||
import { FaUserGear } from "react-icons/fa6";
|
||||
|
||||
export type PatchesDownloadPermission = Database["public"]["Enums"]["Patches Download Permission"];
|
||||
|
||||
export const PATCH_DOWNLOAD_OPTIONS: {
|
||||
value: PatchesDownloadPermission;
|
||||
label: string;
|
||||
/** Short helper shown next to or below the option */
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "None",
|
||||
label: "None",
|
||||
description: "Users can only download your hack through the built-in patcher.",
|
||||
},
|
||||
{
|
||||
value: "Current",
|
||||
label: "Current only",
|
||||
description: "Only the patch version marked Current can be downloaded directly.",
|
||||
},
|
||||
{
|
||||
value: "All",
|
||||
label: "All published",
|
||||
description: "Every published patch version can be downloaded directly.",
|
||||
},
|
||||
];
|
||||
|
||||
function optionLabel(value: PatchesDownloadPermission): string {
|
||||
return PATCH_DOWNLOAD_OPTIONS.find((o) => o.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
interface DownloadPermissionSettingsProps {
|
||||
hackSlug: string;
|
||||
initialPermission: PatchesDownloadPermission;
|
||||
}
|
||||
|
||||
export default function DownloadPermissionSettings({
|
||||
hackSlug,
|
||||
initialPermission,
|
||||
}: DownloadPermissionSettingsProps) {
|
||||
const [savedPermission, setSavedPermission] = useState<PatchesDownloadPermission>(initialPermission);
|
||||
const [selectedPermission, setSelectedPermission] = useState<PatchesDownloadPermission>(initialPermission);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showSaved, setShowSaved] = useState(false);
|
||||
const [savedFadeOut, setSavedFadeOut] = useState(false);
|
||||
const savedTimersRef = useRef<{
|
||||
hold?: ReturnType<typeof setTimeout>;
|
||||
fade?: ReturnType<typeof setTimeout>;
|
||||
}>({});
|
||||
|
||||
function clearSavedFeedbackTimers() {
|
||||
const t = savedTimersRef.current;
|
||||
if (t.hold) clearTimeout(t.hold);
|
||||
if (t.fade) clearTimeout(t.fade);
|
||||
t.hold = undefined;
|
||||
t.fade = undefined;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setSavedPermission(initialPermission);
|
||||
setSelectedPermission(initialPermission);
|
||||
}, [initialPermission]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearSavedFeedbackTimers();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hasUnsavedChanges = selectedPermission !== savedPermission;
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await updatePatchesDownloadPermission(hackSlug, selectedPermission);
|
||||
if (result.ok) {
|
||||
setSavedPermission(selectedPermission);
|
||||
clearSavedFeedbackTimers();
|
||||
setSavedFadeOut(false);
|
||||
setShowSaved(true);
|
||||
const HOLD_MS = 4000;
|
||||
const FADE_MS = 450;
|
||||
savedTimersRef.current.hold = setTimeout(() => {
|
||||
setSavedFadeOut(true);
|
||||
savedTimersRef.current.fade = setTimeout(() => {
|
||||
setShowSaved(false);
|
||||
setSavedFadeOut(false);
|
||||
}, FADE_MS);
|
||||
}, HOLD_MS);
|
||||
} else {
|
||||
setError(result.error || "Failed to save");
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const summary = (
|
||||
<>
|
||||
<span className="text-foreground/45">Live: </span>
|
||||
<span className="text-foreground/80 font-medium">{optionLabel(savedPermission)}</span>
|
||||
{hasUnsavedChanges && (
|
||||
<>
|
||||
<span className="text-foreground/40"> · Draft: </span>
|
||||
<span className="text-foreground/70 font-medium">{optionLabel(selectedPermission)}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<CollapsibleCard
|
||||
title="Patch Download Settings"
|
||||
titleId="patch-download-permissions-heading"
|
||||
leading={<FaUserGear size={20} />}
|
||||
summary={summary}
|
||||
className="mb-6 rounded-lg border border-[var(--border)]/70 border-l-[3px] border-l-[var(--accent)]/40 bg-[var(--surface-2)]"
|
||||
>
|
||||
<div>
|
||||
<p className="text-xs sm:text-sm text-foreground/60 leading-snug md:-mt-4 mb-6">
|
||||
Changing this setting will allow users to download the patch file directly from this page as an alternative to using the built-in patcher.
|
||||
</p>
|
||||
<RadioCardsBody
|
||||
selectedPermission={selectedPermission}
|
||||
savedPermission={savedPermission}
|
||||
onSelect={setSelectedPermission}
|
||||
/>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !hasUnsavedChanges}
|
||||
className="inline-flex items-center justify-center min-w-20 h-8 px-3 text-xs font-semibold rounded-md bg-[var(--accent)] text-[var(--accent-foreground)] hover:bg-[var(--accent-700)] transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-[var(--accent)] shrink-0"
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<div
|
||||
className="text-xs min-h-[1.25rem] flex items-center flex-1 min-w-0"
|
||||
aria-live="polite"
|
||||
>
|
||||
{showSaved ? (
|
||||
<span
|
||||
className={`text-emerald-600 dark:text-emerald-400 font-medium transition-opacity duration-[450ms] ease-out ${
|
||||
savedFadeOut ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
>
|
||||
Setting updated.
|
||||
</span>
|
||||
) : error ? (
|
||||
<span className="text-red-400">{error}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
);
|
||||
}
|
||||
|
||||
function RadioCardsBody({
|
||||
selectedPermission,
|
||||
savedPermission,
|
||||
onSelect,
|
||||
}: {
|
||||
selectedPermission: PatchesDownloadPermission;
|
||||
savedPermission: PatchesDownloadPermission;
|
||||
onSelect: (v: PatchesDownloadPermission) => void;
|
||||
}) {
|
||||
const dirty = selectedPermission !== savedPermission;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 sm:gap-1" role="radiogroup" aria-label="Who can download patch files">
|
||||
{PATCH_DOWNLOAD_OPTIONS.map((opt) => {
|
||||
const isUiSelected = selectedPermission === opt.value;
|
||||
const showSavedBadge = savedPermission === opt.value && dirty;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isUiSelected}
|
||||
onClick={() => onSelect(opt.value)}
|
||||
className={`w-full text-left rounded-md border-2 px-3 py-2.5 sm:px-2 sm:py-1.5 transition-colors flex gap-3 sm:gap-2 items-center touch-manipulation min-h-[2.75rem] sm:min-h-[2.25rem] ${
|
||||
isUiSelected
|
||||
? "border-[var(--accent)] bg-[var(--surface-2)]"
|
||||
: "border-[var(--border)] bg-[var(--surface-2)]/50 hover:bg-[var(--surface-2)]"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`shrink-0 h-3.5 w-3.5 rounded-full border-2 flex items-center justify-center ${
|
||||
isUiSelected ? "border-[var(--accent)]" : "border-[var(--border)]"
|
||||
}`}
|
||||
aria-hidden
|
||||
>
|
||||
{isUiSelected && <span className="h-1.5 w-1.5 rounded-full bg-[var(--accent)]" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 flex flex-wrap items-baseline gap-x-2 gap-y-0.5 sm:gap-x-1.5 sm:gap-y-0 leading-tight">
|
||||
<span className="text-sm font-semibold sm:text-xs">{opt.label}</span>
|
||||
<span className="text-xs text-foreground/55 sm:text-[11px]">{opt.description}</span>
|
||||
{showSavedBadge && (
|
||||
<span className="inline-flex items-center rounded px-1 py-px text-[10px] font-medium uppercase tracking-wide text-foreground/60 bg-foreground/5 ring-1 ring-[var(--border)]">
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,7 +5,8 @@ import Markdown from "@/components/Markdown/Markdown";
|
|||
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 type { PatchesDownloadPermission } from "@/components/Hack/DownloadPermissionSettings";
|
||||
import { updatePatchChangelog, updatePatchVersion, getPatchDownloadUrl } from "@/app/hack/[slug]/actions";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createClient } from "@/utils/supabase/client";
|
||||
|
||||
|
|
@ -19,15 +20,68 @@ interface Patch {
|
|||
archived: boolean;
|
||||
}
|
||||
|
||||
function shouldShowPublicPatchDownload(
|
||||
permission: PatchesDownloadPermission,
|
||||
patch: Patch,
|
||||
isCurrent: boolean,
|
||||
): boolean {
|
||||
if (permission === "None") return false;
|
||||
if (!patch.published || patch.archived) return false;
|
||||
if (permission === "All") return true;
|
||||
if (permission === "Current") return isCurrent;
|
||||
return false;
|
||||
}
|
||||
|
||||
function PublicPatchDownloadButton({ patchId }: { patchId: number }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleClick = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await getPatchDownloadUrl(patchId);
|
||||
if (result.ok) {
|
||||
window.open(result.url, "_blank");
|
||||
} else {
|
||||
alert(result.error || "Failed to generate download URL");
|
||||
}
|
||||
} catch {
|
||||
alert("Failed to download patch");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
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 disabled:opacity-50 disabled:cursor-not-allowed touch-manipulation"
|
||||
title="Download patch file"
|
||||
>
|
||||
<FaDownload size={12} aria-hidden />
|
||||
{loading ? "Opening…" : "Download Patch"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface VersionListProps {
|
||||
patches: Patch[];
|
||||
currentPatchId: number | null;
|
||||
canEdit: boolean;
|
||||
hackSlug: string;
|
||||
baseRom: string;
|
||||
patchesDownloadPermission: PatchesDownloadPermission;
|
||||
}
|
||||
|
||||
export default function VersionList({ patches, currentPatchId, canEdit, hackSlug, baseRom }: VersionListProps) {
|
||||
export default function VersionList({
|
||||
patches,
|
||||
currentPatchId,
|
||||
canEdit,
|
||||
hackSlug,
|
||||
baseRom,
|
||||
patchesDownloadPermission,
|
||||
}: VersionListProps) {
|
||||
// Initialize with first patch's changelog expanded if it exists
|
||||
const getInitialExpanded = () => {
|
||||
if (patches.length > 0) {
|
||||
|
|
@ -115,6 +169,83 @@ export default function VersionList({ patches, currentPatchId, canEdit, hackSlug
|
|||
const isEditing = editingChangelog === patch.id;
|
||||
const currentPatch = allPatches.find(p => p.id === currentPatchId);
|
||||
const currentPatchCreatedAt = currentPatch?.created_at || null;
|
||||
const showPublicPatchDownload =
|
||||
!canEdit &&
|
||||
shouldShowPublicPatchDownload(patchesDownloadPermission, patch, isCurrent);
|
||||
|
||||
const titleBar = (
|
||||
<div
|
||||
className={`flex flex-wrap items-center gap-2 ${showPublicPatchDownload ? "" : "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>
|
||||
);
|
||||
|
||||
const datesBlock = (
|
||||
<div className="text-xs sm:text-sm text-foreground/60 mt-4 sm:mt-0">
|
||||
<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>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -122,96 +253,42 @@ export default function VersionList({ patches, currentPatchId, canEdit, hackSlug
|
|||
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}
|
||||
{showPublicPatchDownload ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between gap-3 sm:gap-4">
|
||||
<div className="flex-1 min-w-0">{titleBar}</div>
|
||||
<div className="shrink-0">
|
||||
<PublicPatchDownloadButton patchId={patch.id} />
|
||||
</div>
|
||||
</div>
|
||||
{datesBlock}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start justify-between gap-3 sm:gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
{titleBar}
|
||||
{datesBlock}
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{canEdit && (
|
||||
<VersionActions
|
||||
patch={patch}
|
||||
isCurrent={isCurrent}
|
||||
hackSlug={hackSlug}
|
||||
onSave={() => {
|
||||
setEditingVersion(null);
|
||||
baseRom={baseRom}
|
||||
currentPatchCreatedAt={currentPatchCreatedAt}
|
||||
onActionComplete={() => {
|
||||
router.refresh();
|
||||
setEditingChangelog(null);
|
||||
setEditingVersion(null);
|
||||
// Clear archived patches to force refetch if checkbox is toggled
|
||||
setArchivedPatches([]);
|
||||
}}
|
||||
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">
|
||||
|
|
|
|||
|
|
@ -5,24 +5,50 @@ import { FaChevronDown } from "react-icons/fa6";
|
|||
|
||||
interface CollapsibleCardProps {
|
||||
title: string;
|
||||
/** Icon or badge shown before the title (e.g. to signal editable settings) */
|
||||
leading?: ReactNode;
|
||||
/** Always visible under the title (e.g. current summary when collapsed) */
|
||||
summary?: ReactNode;
|
||||
titleId?: string;
|
||||
children: ReactNode;
|
||||
defaultExpanded?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function CollapsibleCard({ title, children, defaultExpanded = false, className }: CollapsibleCardProps) {
|
||||
export default function CollapsibleCard({
|
||||
title,
|
||||
leading,
|
||||
summary,
|
||||
titleId,
|
||||
children,
|
||||
defaultExpanded = false,
|
||||
className,
|
||||
}: CollapsibleCardProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
|
||||
return (
|
||||
<div className={`px-4 sm:px-5 ${className}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="py-4 sm:py-5 hover:cursor-pointer w-full flex items-center justify-between gap-2 text-left group-hover:opacity-80 transition-opacity"
|
||||
className="py-4 sm:py-5 hover:cursor-pointer w-full flex items-start justify-between gap-3 text-left group-hover:opacity-80 transition-opacity"
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<h2 className="text-sm font-semibold text-foreground/90">{title}</h2>
|
||||
<div className="min-w-0 flex gap-2.5 sm:gap-3 items-start">
|
||||
{leading != null && (
|
||||
<span className="shrink-0 mt-0.5 text-[var(--accent)] opacity-90" aria-hidden>
|
||||
{leading}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex flex-col gap-0.5 flex-1">
|
||||
<h2 id={titleId} className="text-sm font-semibold text-foreground/90">
|
||||
{title}
|
||||
</h2>
|
||||
{summary != null && <div className="text-xs text-foreground/55 font-normal">{summary}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`text-foreground/60 shrink-0 transition-transform duration-300 ease-in-out ${
|
||||
className={`text-foreground/60 shrink-0 mt-0.5 transition-transform duration-300 ease-in-out ${
|
||||
isExpanded ? "rotate-180" : ""
|
||||
}`}
|
||||
>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user