diff --git a/src/app/hack/[slug]/actions.ts b/src/app/hack/[slug]/actions.ts index c73d6a7..05786b1 100644 --- a/src/app/hack/[slug]/actions.ts +++ b/src/app/hack/[slug]/actions.ts @@ -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(); diff --git a/src/app/hack/[slug]/versions/page.tsx b/src/app/hack/[slug]/versions/page.tsx index 57fbaf3..533cdbb 100644 --- a/src/app/hack/[slug]/versions/page.tsx +++ b/src/app/hack/[slug]/versions/page.tsx @@ -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) { + {canEdit && ( + + )} + ); diff --git a/src/components/Hack/DownloadPermissionSettings.tsx b/src/components/Hack/DownloadPermissionSettings.tsx new file mode 100644 index 0000000..86367a8 --- /dev/null +++ b/src/components/Hack/DownloadPermissionSettings.tsx @@ -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(initialPermission); + const [selectedPermission, setSelectedPermission] = useState(initialPermission); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [showSaved, setShowSaved] = useState(false); + const [savedFadeOut, setSavedFadeOut] = useState(false); + const savedTimersRef = useRef<{ + hold?: ReturnType; + fade?: ReturnType; + }>({}); + + 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 = ( + <> + Live: + {optionLabel(savedPermission)} + {hasUnsavedChanges && ( + <> + · Draft: + {optionLabel(selectedPermission)} + + )} + + ); + + return ( + } + summary={summary} + className="mb-6 rounded-lg border border-[var(--border)]/70 border-l-[3px] border-l-[var(--accent)]/40 bg-[var(--surface-2)]" + > +
+

+ Changing this setting will allow users to download the patch file directly from this page as an alternative to using the built-in patcher. +

+ + +
+ +
+ {showSaved ? ( + + Setting updated. + + ) : error ? ( + {error} + ) : null} +
+
+
+
+ ); +} + +function RadioCardsBody({ + selectedPermission, + savedPermission, + onSelect, +}: { + selectedPermission: PatchesDownloadPermission; + savedPermission: PatchesDownloadPermission; + onSelect: (v: PatchesDownloadPermission) => void; +}) { + const dirty = selectedPermission !== savedPermission; + + return ( +
+ {PATCH_DOWNLOAD_OPTIONS.map((opt) => { + const isUiSelected = selectedPermission === opt.value; + const showSavedBadge = savedPermission === opt.value && dirty; + + return ( + + ); + })} +
+ ); +} diff --git a/src/components/Hack/VersionList.tsx b/src/components/Hack/VersionList.tsx index 77f9408..dc0a435 100644 --- a/src/components/Hack/VersionList.tsx +++ b/src/components/Hack/VersionList.tsx @@ -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 ( + + ); +} + 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 = ( +
+ {editingVersion === patch.id ? ( + { + setEditingVersion(null); + router.refresh(); + }} + onCancel={() => setEditingVersion(null)} + /> + ) : ( + <> +

{patch.version}

+ {canEdit && ( + + )} + + )} + {isCurrent && editingVersion !== patch.id && ( + + + Current + + )} + {!patch.published && ( + + Unpublished + + )} + {patch.archived && ( + + Archived + + )} +
+ ); + + const datesBlock = ( +
+

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

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

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

+ )} +
+ ); return (
-
-
-
- {editingVersion === patch.id ? ( - +
+
{titleBar}
+
+ +
+
+ {datesBlock} +
+ ) : ( +
+
+ {titleBar} + {datesBlock} +
+
+ {canEdit && ( + { - 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)} /> - ) : ( - <> -

{patch.version}

- {canEdit && ( - - )} - - )} - {isCurrent && editingVersion !== patch.id && ( - - - Current - - )} - {!patch.published && ( - - Unpublished - - )} - {patch.archived && ( - - Archived - - )} -
-
-

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

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

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

)}
- -
- {canEdit && ( - { - router.refresh(); - setEditingChangelog(null); - setEditingVersion(null); - // Clear archived patches to force refetch if checkbox is toggled - // This ensures restored/archived patches don't show duplicates - setArchivedPatches([]); - }} - /> - )} -
-
+ )} {hasChangelog ? (
diff --git a/src/components/Primitives/CollapsibleCard.tsx b/src/components/Primitives/CollapsibleCard.tsx index 9feb663..ca741ab 100644 --- a/src/components/Primitives/CollapsibleCard.tsx +++ b/src/components/Primitives/CollapsibleCard.tsx @@ -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 (