diff --git a/src/app/discover/actions.ts b/src/app/discover/actions.ts index 5efde6a..c995e6d 100644 --- a/src/app/discover/actions.ts +++ b/src/app/discover/actions.ts @@ -34,7 +34,7 @@ export async function getDiscoverData(sort: DiscoverSortOption): Promise { const supabase = await createServiceClient(); - + const { data: hack, error } = await supabase .from("hacks") - .select("slug,title,summary,description,base_rom,created_at,updated_at,current_patch,box_art,social_links,created_by,approved,original_author,permission_from,language,is_archive") + .select("slug,title,summary,description,base_rom,created_at,updated_at,current_patch,box_art,social_links,created_by,approved,original_author,permission_from,language,is_archive,completion_status") .eq("slug", slug) .maybeSingle(); - + if (error || !hack) return null; // Fetch covers diff --git a/src/app/hack/[slug]/edit/page.tsx b/src/app/hack/[slug]/edit/page.tsx index 517ab3f..eba7b6d 100644 --- a/src/app/hack/[slug]/edit/page.tsx +++ b/src/app/hack/[slug]/edit/page.tsx @@ -20,7 +20,7 @@ export default async function EditHackPage({ params }: EditPageProps) { const { data: hack } = await supabase .from("hacks") - .select("slug,title,summary,description,base_rom,language,box_art,social_links,created_by,current_patch,original_author,permission_from,is_archive") + .select("slug,title,summary,description,base_rom,language,completion_status,box_art,social_links,created_by,current_patch,original_author,permission_from,is_archive") .eq("slug", slug) .maybeSingle(); if (!hack) return notFound(); @@ -74,6 +74,7 @@ export default async function EditHackPage({ params }: EditPageProps) { description: hack.description, base_rom: hack.base_rom, language: hack.language, + completion_status: hack.completion_status, version: isArchive ? "Archive" : (version || "Pre-release"), box_art: hack.box_art, social_links: (hack.social_links as unknown) as { diff --git a/src/app/hack/[slug]/page.tsx b/src/app/hack/[slug]/page.tsx index 76aaf6e..2dfe750 100644 --- a/src/app/hack/[slug]/page.tsx +++ b/src/app/hack/[slug]/page.tsx @@ -20,6 +20,7 @@ import { headers } from "next/headers"; import { MenuItem } from "@headlessui/react"; import { FaCircleCheck } from "react-icons/fa6"; import { RiArchiveStackFill } from "react-icons/ri"; +import { TbProgressCheck } from "react-icons/tb"; import { isInformationalArchiveHack, isDownloadableArchiveHack, isArchiveHack, checkEditPermission } from "@/utils/hack"; import Avatar from "@/components/Account/Avatar"; import CollapsibleCard from "@/components/Primitives/CollapsibleCard"; @@ -320,7 +321,13 @@ export default async function HackDetail({ params }: HackDetailProps) {

By {author}

)} -

{hack.summary}

+ {hack.completion_status && hack.completion_status !== "Complete" && ( +

+ + {hack.completion_status} +

+ )} +

{hack.summary}

diff --git a/src/app/hack/actions.ts b/src/app/hack/actions.ts index 7268da1..2a7b03b 100644 --- a/src/app/hack/actions.ts +++ b/src/app/hack/actions.ts @@ -1,7 +1,7 @@ "use server"; import { createClient, createServiceClient } from "@/utils/supabase/server"; -import type { TablesInsert } from "@/types/db"; +import type { TablesInsert, Database } from "@/types/db"; import { getMinioClient, PATCHES_BUCKET, COVERS_BUCKET } from "@/utils/minio/server"; import { revalidatePath, revalidateTag } from "next/cache"; import { redirect } from "next/navigation"; @@ -16,6 +16,7 @@ export async function updateHack(args: { description?: string; base_rom?: string; language?: string; + completion_status?: Database["public"]["Enums"]["Completion Status"] | null; version?: string; box_art?: string | null; social_links?: { @@ -51,6 +52,12 @@ export async function updateHack(args: { if (args.description !== undefined) updatePayload.description = args.description; if (args.base_rom !== undefined) updatePayload.base_rom = args.base_rom; if (args.language !== undefined) updatePayload.language = args.language; + if (args.completion_status !== undefined) { + if (args.completion_status === null) { + return { ok: false, error: "Completion status is required" } as const; + } + updatePayload.completion_status = args.completion_status; + } if (args.version !== undefined) updatePayload.version = args.version; if (args.box_art !== undefined) updatePayload.box_art = args.box_art; if (args.social_links !== undefined) updatePayload.social_links = args.social_links; diff --git a/src/app/submit/actions.ts b/src/app/submit/actions.ts index e28a729..94fc674 100644 --- a/src/app/submit/actions.ts +++ b/src/app/submit/actions.ts @@ -1,7 +1,7 @@ "use server"; import { createClient } from "@/utils/supabase/server"; -import type { TablesInsert } from "@/types/db"; +import type { TablesInsert, Database } from "@/types/db"; import { getMinioClient, PATCHES_BUCKET } from "@/utils/minio/server"; import { sendDiscordMessageEmbed } from "@/utils/discord"; import { APIEmbed } from "discord-api-types/v10"; @@ -40,6 +40,7 @@ export async function prepareSubmission(formData: FormData) { const description = (formData.get("description") as string)?.trim(); const base_rom = (formData.get("base_rom") as string)?.trim(); const language = (formData.get("language") as string)?.trim(); + const completion_status = (formData.get("completion_status") as string)?.trim() || null; const version = (formData.get("version") as string)?.trim(); const box_art = (formData.get("box_art") as string)?.trim() || null; const discord = (formData.get("discord") as string)?.trim(); @@ -52,7 +53,7 @@ export async function prepareSubmission(formData: FormData) { const is_archive = formData.get("is_archive") === "true"; // For archives, version is not required; for regular hacks, it is - if (!title || !summary || !description || !base_rom || !language || (!is_archive && !version)) { + if (!title || !summary || !description || !base_rom || !language || !completion_status || (!is_archive && !version)) { return { ok: false, error: "Missing required fields" } as const; } @@ -81,6 +82,7 @@ export async function prepareSubmission(formData: FormData) { description, base_rom, language, + completion_status: completion_status as Database["public"]["Enums"]["Completion Status"], version: version || "Archive", created_by: user.id, downloads: 0, diff --git a/src/components/Discover/DiscoverBrowser.tsx b/src/components/Discover/DiscoverBrowser.tsx index 717c565..675ed7c 100644 --- a/src/components/Discover/DiscoverBrowser.tsx +++ b/src/components/Discover/DiscoverBrowser.tsx @@ -16,6 +16,7 @@ import { MdChevronLeft, MdChevronRight, } from "react-icons/md"; +import { TbProgressCheck } from "react-icons/tb"; import { IoEllipsisHorizontal } from "react-icons/io5"; import { BsSdCardFill } from "react-icons/bs"; import { CATEGORY_ICONS } from "@/components/Icons/tagCategories"; @@ -56,6 +57,7 @@ export default function DiscoverBrowser({ initialSort = "trending" }: DiscoverBr const [query, setQuery] = React.useState(""); const [selectedTags, setSelectedTags] = React.useState([]); const [selectedBaseRoms, setSelectedBaseRoms] = React.useState([]); + const [selectedCompletionStatuses, setSelectedCompletionStatuses] = React.useState([]); const [sort, setSort] = React.useState(initialSort ?? "trending"); const [hacks, setHacks] = React.useState([]); const [tagGroups, setTagGroups] = React.useState>({}); @@ -83,7 +85,7 @@ export default function DiscoverBrowser({ initialSort = "trending" }: DiscoverBr React.useEffect(() => { // Reset to first page when filters or sort change setCurrentPage(1); - }, [query, selectedTags, selectedBaseRoms, onlyReady, sort]); + }, [query, selectedTags, selectedBaseRoms, selectedCompletionStatuses, onlyReady, sort]); React.useEffect(() => { const run = async () => { @@ -125,12 +127,23 @@ export default function DiscoverBrowser({ initialSort = "trending" }: DiscoverBr if (selectedBaseRoms.length > 0) { out = out.filter((h) => h.baseRomId && selectedBaseRoms.includes(h.baseRomId)); } + // OR filter across completion statuses: hack's completion_status must be in selectedCompletionStatuses + // If "Complete" is selected, also include hacks with null completion_status + if (selectedCompletionStatuses.length > 0) { + out = out.filter((h) => { + if (!h.completion_status) { + // Include null completion_status if "Complete" is selected + return selectedCompletionStatuses.includes("Complete"); + } + return selectedCompletionStatuses.includes(h.completion_status); + }); + } // Filter to hacks whose base ROM is ready (linked with permission or cached) if (onlyReady) { out = out.filter((h) => !h.is_archive && h.baseRomId && readyBaseRomIds.has(h.baseRomId)); } return out; - }, [hacks, query, selectedTags, selectedBaseRoms, onlyReady, readyBaseRomIds]); + }, [hacks, query, selectedTags, selectedBaseRoms, selectedCompletionStatuses, onlyReady, readyBaseRomIds]); const totalPages = React.useMemo( () => Math.max(1, Math.ceil(filtered.length / HACKS_PER_PAGE)), @@ -308,6 +321,13 @@ export default function DiscoverBrowser({ initialSort = "trending" }: DiscoverBr if (vals.length > 0) setOnlyReady(false); }} /> + ({ id: s, name: s }))} + values={selectedCompletionStatuses} + onChange={setSelectedCompletionStatuses} + /> {loadingTags ? ( <> {[ @@ -353,11 +373,12 @@ export default function DiscoverBrowser({ initialSort = "trending" }: DiscoverBr )} )} - {(selectedTags.length > 0 || selectedBaseRoms.length > 0 || onlyReady) && ( + {(selectedTags.length > 0 || selectedBaseRoms.length > 0 || selectedCompletionStatuses.length > 0 || onlyReady) && ( )} - {(selectedTags.length > 0 || selectedBaseRoms.length > 0 || onlyReady) && ( + {(selectedTags.length > 0 || selectedBaseRoms.length > 0 || selectedCompletionStatuses.length > 0 || onlyReady) && (
+ {!completionStatus && ( +
+
+
+
+
+
+

Missing Completion Status

+

+ This is a new required field. Please select a completion status in the Details section to save your changes. +

+
+
+
+ )}
@@ -424,6 +447,35 @@ export default function HackEditForm({ slug, initial }: HackEditFormProps) { }))} />
+
+
+ + {completionStatusChanged && ( +
+ Modified + +
+ )} +
+ setCompletionStatus(value as any)} + placeholder="Select completion status" + options={['Complete','Demo','Alpha','Beta'].map(s => ({ + value: s, + label: s, + }))} + /> + ) : ( +
{completionStatus || "Select completion status"}
+ )} +
+ {isArchive && (
diff --git a/src/components/HackCard.tsx b/src/components/HackCard.tsx index e819989..90ab8d3 100644 --- a/src/components/HackCard.tsx +++ b/src/components/HackCard.tsx @@ -12,6 +12,7 @@ import { usePathname } from "next/navigation"; import { FaRegImages } from "react-icons/fa6"; import { ImDownload } from "react-icons/im"; import { RiArchiveStackFill } from "react-icons/ri"; +import type { Database } from "@/types/db"; export interface HackCardAttributes { slug: string; @@ -25,6 +26,7 @@ export interface HackCardAttributes { summary?: string; description?: string; is_archive: boolean; + completion_status?: Database["public"]["Enums"]["Completion Status"] | null; }; export default function HackCard({ hack, clickable = true, className = "" }: { hack: HackCardAttributes; clickable?: boolean; className?: string }) { @@ -63,7 +65,7 @@ export default function HackCard({ hack, clickable = true, className = "" }: { h setIsClicked(false); }, []); - const cardClass = `rounded-[12px] overflow-hidden h-full ${ + const cardClass = `rounded-[12px] overflow-hidden h-full flex flex-col ${ clickable ? `transition-transform duration-300 hover:-translate-y-0.5 hover:shadow-xl ${isClicked ? "anim-float" : ""}` : "" } ring-1 ${ready ? "ring-emerald-400/50 bg-emerald-500/10" : "card ring-[var(--border)]"}`; const gradientBgClass = `bg-gradient-to-b ${ready ? 'from-emerald-300/5 to-emerald-400/30 dark:from-emerald-950/10 dark:to-emerald-600/40' : 'from-black/30 to-black/10 dark:from-black/80 dark:to-black/40'}`; @@ -181,7 +183,7 @@ export default function HackCard({ hack, clickable = true, className = "" }: { h
)}
-
+
@@ -207,7 +209,10 @@ export default function HackCard({ hack, clickable = true, className = "" }: { h return text.length > 120 ? text.slice(0, 120).trimEnd() + "…" : text; })()}

-
Base: {baseName ?? "Unknown"}
+
+

Base: {baseName ?? "Unknown"}

+ {hack.completion_status && hack.completion_status !== "Complete" &&

{hack.completion_status}

} +
);