From 549c59246bfc79196fa955d3b0fc254917388e58 Mon Sep 17 00:00:00 2001 From: Jared Schoeny Date: Fri, 5 Dec 2025 21:54:09 -1000 Subject: [PATCH] Change default archive behavior to allow patches --- src/app/submit/actions.ts | 2 + src/components/Hack/HackForm.tsx | 9 +- src/components/Hack/HackSubmitForm.tsx | 72 ++++- src/components/Submit/ArchiveModeSelector.tsx | 247 ++++++++++++++++-- src/components/Submit/SubmitPageClient.tsx | 19 +- src/types/db.ts | 7 + ...0251205211218_add_hack_permission_from.sql | 1 + ...51206022214_archiver_role_rls_policies.sql | 41 +++ 8 files changed, 356 insertions(+), 42 deletions(-) create mode 100644 supabase/migrations/20251205211218_add_hack_permission_from.sql create mode 100644 supabase/migrations/20251206022214_archiver_role_rls_policies.sql diff --git a/src/app/submit/actions.ts b/src/app/submit/actions.ts index a39b678..6615e3c 100644 --- a/src/app/submit/actions.ts +++ b/src/app/submit/actions.ts @@ -46,6 +46,7 @@ export async function prepareSubmission(formData: FormData) { const pokecommunity = (formData.get("pokecommunity") as string)?.trim(); const tags = (formData.get("tags") as string)?.split(",").map((t) => t.trim()).filter(Boolean) || []; const original_author = (formData.get("original_author") as string)?.trim() || null; + const permission_from = (formData.get("permission_from") as string)?.trim() || null; const isArchive = formData.get("isArchive") === "true"; // For archives, version is not required; for regular hacks, it is @@ -85,6 +86,7 @@ export async function prepareSubmission(formData: FormData) { approved: isArchive, // Auto-approve archives patch_url: "", original_author: original_author || null, + permission_from: permission_from || null, current_patch: null, // Archives don't have patches } as HackInsert; diff --git a/src/components/Hack/HackForm.tsx b/src/components/Hack/HackForm.tsx index a63163f..e60d3f8 100644 --- a/src/components/Hack/HackForm.tsx +++ b/src/components/Hack/HackForm.tsx @@ -10,6 +10,8 @@ interface HackFormCreateProps { mode: "create"; dummy?: boolean; isArchive?: boolean; + permissionFrom?: string; + customCreator?: string; } interface HackFormEditProps { @@ -22,7 +24,12 @@ export type HackFormProps = HackFormCreateProps | HackFormEditProps; export default function HackForm(props: HackFormProps) { if (props.mode === "create") { - return ; + return ; } return ; } diff --git a/src/components/Hack/HackSubmitForm.tsx b/src/components/Hack/HackSubmitForm.tsx index 3af6dc5..877889f 100644 --- a/src/components/Hack/HackSubmitForm.tsx +++ b/src/components/Hack/HackSubmitForm.tsx @@ -61,11 +61,15 @@ function SortableCoverItem({ id, index, url, filename, onRemove }: { id: string; interface HackSubmitFormProps { dummy?: boolean; isArchive?: boolean; + permissionFrom?: string; + customCreator?: string; } export default function HackSubmitForm({ dummy = false, isArchive = false, + permissionFrom = undefined, + customCreator = undefined, }: HackSubmitFormProps) { const MAX_COVERS = 10; const { profile, user } = useAuthContext(); @@ -101,7 +105,11 @@ export default function HackSubmitForm({ const [pokecommunity, setPokecommunity] = React.useState(() => initialDraftRef.current?.pokecommunity || ""); const [tags, setTags] = React.useState(() => (Array.isArray(initialDraftRef.current?.tags) ? initialDraftRef.current.tags : [])); const [showMdPreview, setShowMdPreview] = React.useState(() => !!initialDraftRef.current?.showMdPreview); - const [originalAuthor, setOriginalAuthor] = React.useState(() => initialDraftRef.current?.originalAuthor || ""); + const [originalAuthor, setOriginalAuthor] = React.useState(() => { + // If customCreator is provided, use it; otherwise use draft or empty string + if (customCreator) return customCreator; + return initialDraftRef.current?.originalAuthor || ""; + }); const [patchFile, setPatchFile] = React.useState(null); const [patchMode, setPatchMode] = React.useState<"bps" | "rom">(() => (initialDraftRef.current?.patchMode === "rom" ? "rom" : "bps")); const [genStatus, setGenStatus] = React.useState<"idle" | "generating" | "ready" | "error">("idle"); @@ -162,6 +170,13 @@ export default function HackSubmitForm({ modifiedRomInputRef.current && (modifiedRomInputRef.current.value = ""); }, [patchMode]); + // Sync originalAuthor with customCreator if provided + React.useEffect(() => { + if (customCreator) { + setOriginalAuthor(customCreator); + } + }, [customCreator]); + const uploadCovers = async (slug: string) => { if (!newCoverFiles || newCoverFiles.length === 0) return [] as string[]; const urls: string[] = []; @@ -291,8 +306,11 @@ export default function HackSubmitForm({ if (typeof data.pokecommunity === "string") applied = applied || !!data.pokecommunity; if (Array.isArray(data.tags)) setTags(data.tags.filter((t: any) => typeof t === "string")); if (Array.isArray(data.tags)) applied = applied || data.tags.length > 0; - if (typeof data.originalAuthor === "string") setOriginalAuthor(data.originalAuthor); - if (typeof data.originalAuthor === "string") applied = applied || !!data.originalAuthor; + // Only load originalAuthor from draft if customCreator is not provided + if (!customCreator && typeof data.originalAuthor === "string") { + setOriginalAuthor(data.originalAuthor); + applied = applied || !!data.originalAuthor; + } if (data.step && Number.isInteger(data.step)) setStep(Math.min(maxSteps, Math.max(1, data.step))); if (typeof data.showMdPreview === "boolean") setShowMdPreview(data.showMdPreview); if (data.patchMode === "bps" || data.patchMode === "rom") setPatchMode(data.patchMode); @@ -314,17 +332,18 @@ export default function HackSubmitForm({ if (dummy || !draftKey || hydratedFromDraftRef.current) return; const d = initialDraftRef.current; if (!d || typeof d !== "object") return; + // Don't count originalAuthor if customCreator is provided const hasAny = Boolean( - d.title || d.summary || d.description || d.baseRom || d.platform || d.version || d.language || d.boxArt || d.discord || d.twitter || d.pokecommunity || (Array.isArray(d.tags) && d.tags.length > 0) || d.originalAuthor + d.title || d.summary || d.description || d.baseRom || d.platform || d.version || d.language || d.boxArt || d.discord || d.twitter || d.pokecommunity || (Array.isArray(d.tags) && d.tags.length > 0) || (!customCreator && d.originalAuthor) ); if (hasAny) { hydratedFromDraftRef.current = true; setRestoredDraft(true); } - }, [dummy, draftKey]); + }, [dummy, draftKey, customCreator]); React.useEffect(() => { if (dummy || !draftKey || isHydrating) return; const handle = setTimeout(() => { try { - const data = { + const data: any = { title, summary, description, @@ -337,11 +356,14 @@ export default function HackSubmitForm({ twitter, pokecommunity, tags, - originalAuthor, step, showMdPreview, patchMode, }; + // Only save originalAuthor if customCreator is not provided + if (!customCreator) { + data.originalAuthor = originalAuthor; + } localStorage.setItem(draftKey, JSON.stringify(data)); } catch { // ignore @@ -366,6 +388,7 @@ export default function HackSubmitForm({ pokecommunity, tags, originalAuthor, + customCreator, step, showMdPreview, patchMode, @@ -402,9 +425,14 @@ export default function HackSubmitForm({ if (pokecommunity) fd.set('pokecommunity', pokecommunity); if (tags.length) fd.set('tags', tags.join(',')); if (isArchive) { - fd.set('original_author', originalAuthor); fd.set('isArchive', 'true'); } + if (originalAuthor) { + fd.set('original_author', originalAuthor); + } + if (permissionFrom) { + fd.set('permission_from', permissionFrom); + } const prepared = await prepareSubmission(fd); if (!prepared.ok) throw new Error(prepared.error || 'Failed to prepare'); @@ -521,7 +549,9 @@ export default function HackSubmitForm({ const preview = { slug: slug || "preview", title: title || "Your hack title", - author: isArchive ? (originalAuthor || "Unknown") : (profile?.username ? `@${profile.username}` : "You"), + author: (isArchive || customCreator) ? + (originalAuthor || "Unknown") : + (profile?.username ? `@${profile.username}` : "You"), summary: (summary || "Short description, max 100 characters.") as string, description: (description || "Write a longer markdown description here.") as string, covers: coverPreviews, @@ -551,6 +581,25 @@ export default function HackSubmitForm({ Checking for existing draft… )} + {customCreator && permissionFrom && ( +
+
+
+
+
+

+ {customCreator === permissionFrom + ? `Submitting on behalf of ${customCreator} with their permission.` + : `Submitting on behalf of ${customCreator}`} +

+ {customCreator !== permissionFrom && ( +

+ You are submitting this hack with permission from {permissionFrom}. +

+ )} +
+
+ )} {!isHydrating && restoredDraft && (
@@ -584,7 +633,7 @@ export default function HackSubmitForm({ setNewCoverFiles([]); setCoverErrors([]); setPatchFile(null); - setOriginalAuthor(""); + setOriginalAuthor(customCreator || ""); setShowMdPreview(false); setStep(1); // Clear file inputs if present @@ -692,8 +741,9 @@ export default function HackSubmitForm({ setOriginalAuthor(e.target.value)} + disabled={!!customCreator} placeholder="Name of the original hack creator" - className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]" + className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)] disabled:opacity-50 disabled:cursor-not-allowed" /> ) : (
Original author name
diff --git a/src/components/Submit/ArchiveModeSelector.tsx b/src/components/Submit/ArchiveModeSelector.tsx index 38bc8bd..b49cd2c 100644 --- a/src/components/Submit/ArchiveModeSelector.tsx +++ b/src/components/Submit/ArchiveModeSelector.tsx @@ -1,12 +1,22 @@ "use client"; -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; type ArchiveModeSelectorProps = { - onSelect: (isArchive: boolean) => void; + onSelect: (options?: { + customCreator?: string, + permissionFrom?: string, + isArchive?: boolean, + }) => void; }; const ArchiveModeSelector: React.FC = ({ onSelect }) => { + const [currentPage, setCurrentPage] = useState<"first" | "second">("first"); + const [hasPermission, setHasPermission] = useState(null); + const [permissionFrom, setPermissionFrom] = useState(null); + const [isSamePerson, setIsSamePerson] = useState(null); + const [customCreator, setCustomCreator] = useState(null); + useEffect(() => { const html = document.documentElement; const body = document.body; @@ -28,39 +38,220 @@ const ArchiveModeSelector: React.FC = ({ onSelect }) = }; }, []); + const canProceed = () => { + if (hasPermission === false) return false; + if (hasPermission === true) { + if (!permissionFrom?.trim()) return false; + if (isSamePerson === true) return true; + if (isSamePerson === false && customCreator) { + return customCreator.trim().length > 0; + } + return false; + } + return false; + }; + + const handleGetStarted = () => { + if (!canProceed()) return; + + if (isSamePerson === true) { + onSelect({ + permissionFrom: permissionFrom?.trim(), + customCreator: permissionFrom?.trim(), + }); + } else { + onSelect({ + permissionFrom: permissionFrom?.trim(), + customCreator: customCreator?.trim(), + }); + } + }; + + const renderFirstPage = () => ( +
+
+
What would you like to create?
+

+ Choose whether you're creating a new hack for yourself or uploading on behalf of another creator without an account. +

+
+
+ + +
+
+ ); + + const renderSecondPage = () => ( +
+
+
Permission Confirmation
+

+ We need to confirm that you have permission to upload this romhack on behalf of the original creator. +

+
+ +
+
+ +

+ You should be able to provide evidence of this permission if asked. +

+
+ + +
+
+ + {hasPermission === true && ( + <> +
+ + setPermissionFrom(e.target.value)} + placeholder="Enter name" + className="w-full px-3 py-2 rounded-md border border-[var(--border)] bg-[var(--surface-1)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)]" + /> +
+ + {permissionFrom && permissionFrom.trim() && ( +
+ +
+ + +
+
+ )} + + {isSamePerson === false ? ( +
+ +

+ This will appear on the hack's page as by {customCreator || "username"}. +

+ setCustomCreator(e.target.value)} + placeholder="Enter creator or team name" + className="w-full px-3 py-2 rounded-md border border-[var(--border)] bg-[var(--surface-1)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)]" + /> +
+ ) : isSamePerson === true && permissionFrom && permissionFrom.trim() && ( +
+

+ This will appear on the hack's page as by {permissionFrom}. +

+
+ )} + + )} + + {hasPermission === false && ( +
+

+ You need explicit permission from the original creator in order to upload on their behalf. + Alternatively, you could ask if they are interested in creating a Hackdex account to submit their own hack. +

+
+ )} +
+ +
+ + +
+
+ ); + return (
-
-
-
What would you like to create?
-

- Choose whether you're creating a new hack for yourself or archiving an existing hack for preservation purposes. -

-
-
- - -
-
+ {currentPage === "first" ? renderFirstPage() : renderSecondPage()}
); diff --git a/src/components/Submit/SubmitPageClient.tsx b/src/components/Submit/SubmitPageClient.tsx index 5146493..97a0ca2 100644 --- a/src/components/Submit/SubmitPageClient.tsx +++ b/src/components/Submit/SubmitPageClient.tsx @@ -6,11 +6,26 @@ import ArchiveModeSelector from "@/components/Submit/ArchiveModeSelector"; export default function SubmitPageClient({ canCreateArchive, dummy }: { canCreateArchive: boolean; dummy: boolean }) { const [showModeSelector, setShowModeSelector] = React.useState(canCreateArchive); + const [customCreator, setCustomCreator] = React.useState(undefined); + const [permissionFrom, setPermissionFrom] = React.useState(undefined); const [isArchive, setIsArchive] = React.useState(false); if (showModeSelector) { - return { setIsArchive(archive); setShowModeSelector(false); }} />; + return { + setCustomCreator(options?.customCreator); + setPermissionFrom(options?.permissionFrom); + setIsArchive(options?.isArchive ?? false); + setShowModeSelector(false); + }} + />; } - return ; + return ; } diff --git a/src/types/db.ts b/src/types/db.ts index 80e6a68..aac5b8b 100644 --- a/src/types/db.ts +++ b/src/types/db.ts @@ -144,6 +144,7 @@ export type Database = { language: string original_author: string | null patch_url: string + permission_from: string | null published: boolean search: unknown slug: string @@ -168,6 +169,7 @@ export type Database = { language: string original_author?: string | null patch_url: string + permission_from?: string | null published?: boolean search?: unknown slug: string @@ -192,6 +194,7 @@ export type Database = { language?: string original_author?: string | null patch_url?: string + permission_from?: string | null published?: boolean search?: unknown slug?: string @@ -349,6 +352,10 @@ export type Database = { get_my_claim: { Args: { claim: string }; Returns: Json } get_my_claims: { Args: never; Returns: Json } is_admin: { Args: never; Returns: boolean } + is_archive_hack_for_archiver: { + Args: { hack_slug: string } + Returns: boolean + } is_archiver: { Args: never; Returns: boolean } is_claims_admin: { Args: never; Returns: boolean } set_claim: { diff --git a/supabase/migrations/20251205211218_add_hack_permission_from.sql b/supabase/migrations/20251205211218_add_hack_permission_from.sql new file mode 100644 index 0000000..3818788 --- /dev/null +++ b/supabase/migrations/20251205211218_add_hack_permission_from.sql @@ -0,0 +1 @@ +alter table "public"."hacks" add column "permission_from" text; diff --git a/supabase/migrations/20251206022214_archiver_role_rls_policies.sql b/supabase/migrations/20251206022214_archiver_role_rls_policies.sql new file mode 100644 index 0000000..7966b7f --- /dev/null +++ b/supabase/migrations/20251206022214_archiver_role_rls_policies.sql @@ -0,0 +1,41 @@ +-- Helper function to check if a hack qualifies for archiver access +-- SECURITY DEFINER allows this function to bypass RLS when checking hack properties, +-- preventing circular dependencies in RLS policies +CREATE OR REPLACE FUNCTION public.is_archive_hack_for_archiver(hack_slug text) +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +AS $$ + SELECT EXISTS ( + SELECT 1 + FROM public.hacks h + WHERE h.slug = hack_slug + AND h.original_author IS NOT NULL + AND (h.current_patch IS NULL OR h.permission_from IS NOT NULL) + ); +$$; + +-- Archivers can view archive hacks (including unapproved ones) +CREATE POLICY "Archivers can view archive hacks." ON "public"."hacks" FOR SELECT USING (("public"."is_archiver"() AND "public"."is_archive_hack_for_archiver"("slug"))); + +-- Archivers can update archive hacks +CREATE POLICY "Archivers can update archive hacks." ON "public"."hacks" FOR UPDATE USING (("public"."is_archiver"() AND "public"."is_archive_hack_for_archiver"("slug"))) WITH CHECK (("public"."is_archiver"() AND "public"."is_archive_hack_for_archiver"("slug"))); + +-- Archivers can delete archive hacks +CREATE POLICY "Archivers can delete archive hacks." ON "public"."hacks" FOR DELETE USING (("public"."is_archiver"() AND "public"."is_archive_hack_for_archiver"("slug"))); + +-- Archivers can add covers to archive hacks +CREATE POLICY "Archivers can add covers to archive hacks." ON "public"."hack_covers" FOR INSERT WITH CHECK (("public"."is_archiver"() AND "public"."is_archive_hack_for_archiver"("hack_slug"))); + +-- Archivers can remove covers from archive hacks +CREATE POLICY "Archivers can remove covers from archive hacks." ON "public"."hack_covers" FOR DELETE USING (("public"."is_archiver"() AND "public"."is_archive_hack_for_archiver"("hack_slug"))); + +-- Archivers can update covers on archive hacks +CREATE POLICY "Archivers can update covers on archive hacks." ON "public"."hack_covers" FOR UPDATE USING (("public"."is_archiver"() AND "public"."is_archive_hack_for_archiver"("hack_slug"))); + +-- Archivers can add tags to archive hacks +CREATE POLICY "Archivers can add tags to archive hacks." ON "public"."hack_tags" FOR INSERT WITH CHECK (("public"."is_archiver"() AND "public"."is_archive_hack_for_archiver"("hack_slug"))); + +-- Archivers can remove tags from archive hacks +CREATE POLICY "Archivers can remove tags from archive hacks." ON "public"."hack_tags" FOR DELETE USING (("public"."is_archiver"() AND "public"."is_archive_hack_for_archiver"("hack_slug")));