mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-08-22 08:34:13 -05:00
Require completion status for hacks
This commit is contained in:
@@ -34,7 +34,7 @@ export async function getDiscoverData(sort: DiscoverSortOption): Promise<Discove
|
||||
// Build base query for hacks (public/anon view: only approved hacks)
|
||||
let query = supabase
|
||||
.from("hacks")
|
||||
.select("slug,title,summary,description,base_rom,downloads,created_by,updated_at,current_patch,original_author,approved_at,is_archive")
|
||||
.select("slug,title,summary,description,base_rom,downloads,created_by,updated_at,current_patch,original_author,approved_at,is_archive,completion_status")
|
||||
.eq("approved", true);
|
||||
|
||||
// Apply sorting based on sort type
|
||||
@@ -256,6 +256,7 @@ export async function getDiscoverData(sort: DiscoverSortOption): Promise<Discove
|
||||
summary: r.summary,
|
||||
description: r.description,
|
||||
is_archive: r.is_archive,
|
||||
completion_status: r.completion_status,
|
||||
}));
|
||||
|
||||
// Sort by current patch published_at for "updated" sort
|
||||
|
||||
@@ -9,6 +9,7 @@ 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";
|
||||
|
||||
export interface HackMetadata {
|
||||
hack: {
|
||||
@@ -28,6 +29,7 @@ export interface HackMetadata {
|
||||
permission_from: string | null;
|
||||
language: string | null;
|
||||
is_archive: boolean;
|
||||
completion_status: Database["public"]["Enums"]["Completion Status"] | null;
|
||||
};
|
||||
images: string[];
|
||||
tags: string[];
|
||||
@@ -54,13 +56,13 @@ export async function getHackMetadata(slug: string): Promise<HackMetadata | null
|
||||
const runner = cache(
|
||||
async () => {
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
<p className="text-[16px] md:text-[18px] text-foreground/70">By {author}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className={`${!isArchive ? "mt-4" : "mt-2"} text-sm text-foreground/75`}>{hack.summary}</p>
|
||||
{hack.completion_status && hack.completion_status !== "Complete" && (
|
||||
<p className="w-fit rounded-full bg-[var(--surface-2)] mt-4 px-2 py-0.5 font-bold text-foreground/85 ring-1 ring-[var(--border)]">
|
||||
<TbProgressCheck className="inline-block align-middle mr-1 text-foreground/85" size={18} />
|
||||
{hack.completion_status}
|
||||
</p>
|
||||
)}
|
||||
<p className={`${!isArchive && (!hack.completion_status || hack.completion_status === "Complete") ? "mt-4" : "mt-2"} text-sm text-foreground/75`}>{hack.summary}</p>
|
||||
</div>
|
||||
<div className="w-full mt-2 flex flex-col justify-between gap-6 md:flex-row md:items-end">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const [selectedBaseRoms, setSelectedBaseRoms] = React.useState<string[]>([]);
|
||||
const [selectedCompletionStatuses, setSelectedCompletionStatuses] = React.useState<string[]>([]);
|
||||
const [sort, setSort] = React.useState<DiscoverSortOption>(initialSort ?? "trending");
|
||||
const [hacks, setHacks] = React.useState<HackCardAttributes[]>([]);
|
||||
const [tagGroups, setTagGroups] = React.useState<Record<string, string[]>>({});
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
<MultiSelectDropdown
|
||||
icon={TbProgressCheck}
|
||||
label="Completion"
|
||||
options={['Complete','Demo','Alpha','Beta'].map((s) => ({ 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) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
clearTags();
|
||||
clearBaseRoms();
|
||||
setSelectedCompletionStatuses([]);
|
||||
setOnlyReady(false);
|
||||
}}
|
||||
className="ml-2 rounded-full px-3 py-1 text-sm ring-1 ring-inset transition-colors bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10"
|
||||
@@ -384,7 +405,7 @@ export default function DiscoverBrowser({ initialSort = "trending" }: DiscoverBr
|
||||
) : (
|
||||
<>No results</>
|
||||
)}
|
||||
{(selectedTags.length > 0 || selectedBaseRoms.length > 0) && (
|
||||
{(selectedTags.length > 0 || selectedBaseRoms.length > 0 || selectedCompletionStatuses.length > 0) && (
|
||||
<>
|
||||
{" "}with the selected filters
|
||||
</>
|
||||
@@ -399,11 +420,12 @@ export default function DiscoverBrowser({ initialSort = "trending" }: DiscoverBr
|
||||
Clear search
|
||||
</button>
|
||||
)}
|
||||
{(selectedTags.length > 0 || selectedBaseRoms.length > 0 || onlyReady) && (
|
||||
{(selectedTags.length > 0 || selectedBaseRoms.length > 0 || selectedCompletionStatuses.length > 0 || onlyReady) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
clearTags();
|
||||
clearBaseRoms();
|
||||
setSelectedCompletionStatuses([]);
|
||||
setOnlyReady(false);
|
||||
}}
|
||||
className="rounded-full px-3 py-1 text-sm ring-1 ring-inset transition-colors bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10"
|
||||
|
||||
@@ -11,6 +11,7 @@ import { createClient } from "@/utils/supabase/client";
|
||||
import { updateHack, saveHackCovers, presignCoverUpload } from "@/app/hack/actions";
|
||||
import SortableCovers from "@/components/Hack/SortableCovers";
|
||||
import Select from "@/components/Primitives/Select";
|
||||
import type { Database } from "@/types/db";
|
||||
|
||||
interface HackEditFormProps {
|
||||
slug: string;
|
||||
@@ -20,6 +21,7 @@ interface HackEditFormProps {
|
||||
description: string;
|
||||
base_rom: string;
|
||||
language: string;
|
||||
completion_status: Database["public"]["Enums"]["Completion Status"] | null;
|
||||
version: string;
|
||||
box_art: string | null;
|
||||
social_links: {
|
||||
@@ -43,6 +45,7 @@ export default function HackEditForm({ slug, initial }: HackEditFormProps) {
|
||||
const [showMdPreview, setShowMdPreview] = React.useState(false);
|
||||
const [baseRom, setBaseRom] = React.useState(initial.base_rom);
|
||||
const [language, setLanguage] = React.useState(initial.language);
|
||||
const [completionStatus, setCompletionStatus] = React.useState<Database["public"]["Enums"]["Completion Status"] | null>(initial.completion_status);
|
||||
const [version, setVersion] = React.useState(initial.version);
|
||||
const [boxArt, setBoxArt] = React.useState(initial.box_art || "");
|
||||
const [discord, setDiscord] = React.useState(initial.social_links?.discord || "");
|
||||
@@ -57,6 +60,7 @@ export default function HackEditForm({ slug, initial }: HackEditFormProps) {
|
||||
summary: initial.summary,
|
||||
description: initial.description,
|
||||
language: initial.language,
|
||||
completionStatus: initial.completion_status,
|
||||
boxArt: initial.box_art || "",
|
||||
tags: initial.tags || [],
|
||||
discord: initial.social_links?.discord || "",
|
||||
@@ -116,12 +120,13 @@ export default function HackEditForm({ slug, initial }: HackEditFormProps) {
|
||||
const summaryChanged = summary !== baseline.summary;
|
||||
const descriptionChanged = description !== baseline.description;
|
||||
const languageChanged = language !== baseline.language;
|
||||
const completionStatusChanged = completionStatus !== baseline.completionStatus;
|
||||
const boxArtChanged = boxArt !== baseline.boxArt;
|
||||
const discordChanged = discord !== baseline.discord;
|
||||
const twitterChanged = twitter !== baseline.twitter;
|
||||
const pokeChanged = pokecommunity !== baseline.pokecommunity;
|
||||
const githubChanged = github !== baseline.github;
|
||||
const contentChanged = titleChanged || summaryChanged || descriptionChanged || languageChanged || boxArtChanged || tagsChanged || discordChanged || twitterChanged || pokeChanged || githubChanged;
|
||||
const contentChanged = titleChanged || summaryChanged || descriptionChanged || languageChanged || completionStatusChanged || boxArtChanged || tagsChanged || discordChanged || twitterChanged || pokeChanged || githubChanged;
|
||||
|
||||
const newItemsCount = coverItems.filter((i) => i.type === "new").length;
|
||||
const currentExistingKeys = coverItems.filter((i): i is { type: "existing"; key: string; url: string } => i.type === "existing").map((i) => i.key);
|
||||
@@ -169,6 +174,7 @@ export default function HackEditForm({ slug, initial }: HackEditFormProps) {
|
||||
if (summaryChanged) updateArgs.summary = summary.trim();
|
||||
if (descriptionChanged) updateArgs.description = description.trim();
|
||||
if (languageChanged) updateArgs.language = language;
|
||||
if (completionStatusChanged) updateArgs.completion_status = completionStatus;
|
||||
if (boxArtChanged) updateArgs.box_art = boxArt ? boxArt.trim() : null;
|
||||
if (tagsChanged) updateArgs.tags = tags.slice();
|
||||
if (discordChanged || twitterChanged || pokeChanged || githubChanged) updateArgs.social_links = social; // may be null to clear
|
||||
@@ -181,6 +187,7 @@ export default function HackEditForm({ slug, initial }: HackEditFormProps) {
|
||||
summary,
|
||||
description,
|
||||
language,
|
||||
completionStatus,
|
||||
boxArt,
|
||||
tags: tags.slice(),
|
||||
discord,
|
||||
@@ -229,7 +236,7 @@ export default function HackEditForm({ slug, initial }: HackEditFormProps) {
|
||||
|
||||
const summaryLimit = 120;
|
||||
const summaryTooLong = summary.length > summaryLimit;
|
||||
const contentHasErrors = summaryTooLong || (!!boxArt && !urlLike(boxArt));
|
||||
const contentHasErrors = summaryTooLong || (!!boxArt && !urlLike(boxArt)) || !completionStatus;
|
||||
|
||||
return (
|
||||
<div className="mt-6 flex flex-col gap-6 lg:grid lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
@@ -246,6 +253,7 @@ export default function HackEditForm({ slug, initial }: HackEditFormProps) {
|
||||
setSummary(baseline.summary);
|
||||
setDescription(baseline.description);
|
||||
setLanguage(baseline.language);
|
||||
setCompletionStatus(baseline.completionStatus);
|
||||
setBoxArt(baseline.boxArt);
|
||||
setTags(baseline.tags.slice());
|
||||
setDiscord(baseline.discord);
|
||||
@@ -263,6 +271,21 @@ export default function HackEditForm({ slug, initial }: HackEditFormProps) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{!completionStatus && (
|
||||
<div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/10 p-3 text-sm text-amber-900 dark:text-amber-100">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex items-center justify-center w-5 h-5 shrink-0 mt-0.5">
|
||||
<div className="inline-block h-2 w-2 rounded-full bg-amber-400" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-semibold">Missing Completion Status</p>
|
||||
<p className="text-xs text-amber-800 dark:text-amber-200">
|
||||
This is a new required field. Please select a completion status in the Details section to save your changes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -424,6 +447,35 @@ export default function HackEditForm({ slug, initial }: HackEditFormProps) {
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className={`text-sm ${!completionStatus ? 'text-red-500 font-semibold' : 'text-foreground/80'}`}>
|
||||
Completion Status <span className="text-red-500">*</span>
|
||||
{!completionStatus && <span className="ml-2 text-xs font-normal text-red-500/80">(Required)</span>}
|
||||
</label>
|
||||
{completionStatusChanged && (
|
||||
<div className="ml-auto flex items-center gap-2 text-[11px] text-foreground/70">
|
||||
<span>Modified</span>
|
||||
<button type="button" onClick={() => setCompletionStatus(baseline.completionStatus)} className="inline-flex items-center underline underline-offset-2 text-[11px] cursor-pointer">Revert</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Select
|
||||
value={completionStatus || ""}
|
||||
onChange={(value) => setCompletionStatus(value as Database["public"]["Enums"]["Completion Status"] | null)}
|
||||
placeholder="Select completion status"
|
||||
className={!completionStatus ? 'ring-2 ring-red-500/60 bg-red-500/10 dark:ring-red-400/60 dark:bg-red-950/20' : completionStatusChanged ? 'ring-[var(--ring)]' : ''}
|
||||
options={['Complete','Demo','Alpha','Beta'].map(s => ({
|
||||
value: s,
|
||||
label: s,
|
||||
}))}
|
||||
/>
|
||||
{!completionStatus && (
|
||||
<p className="text-xs text-red-500/80 dark:text-red-400/80">
|
||||
This is a new required field. Please select a completion status to save your changes.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-foreground/80">Current version</label>
|
||||
<p className="flex items-center 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)] text-foreground/60 select-none cursor-not-allowed">
|
||||
|
||||
@@ -102,6 +102,7 @@ export default function HackSubmitForm({
|
||||
const [platform, setPlatform] = React.useState<"GB" | "GBC" | "GBA" | "NDS" | "">(() => (initialDraftRef.current?.platform as any) || "");
|
||||
const [version, setVersion] = React.useState(() => initialDraftRef.current?.version || "");
|
||||
const [language, setLanguage] = React.useState(() => initialDraftRef.current?.language || "");
|
||||
const [completionStatus, setCompletionStatus] = React.useState<"Complete" | "Demo" | "Alpha" | "Beta" | "">(() => (initialDraftRef.current?.completionStatus as any) || "");
|
||||
const [boxArt, setBoxArt] = React.useState(() => initialDraftRef.current?.boxArt || "");
|
||||
const [discord, setDiscord] = React.useState(() => initialDraftRef.current?.discord || "");
|
||||
const [twitter, setTwitter] = React.useState(() => initialDraftRef.current?.twitter || "");
|
||||
@@ -285,7 +286,7 @@ export default function HackSubmitForm({
|
||||
const data = JSON.parse(raw);
|
||||
if (data && typeof data === "object") {
|
||||
const isEmpty =
|
||||
!title && !summary && !description && !baseRom && !platform && !version && !language && !boxArt && !discord && !twitter && !pokecommunity && !github && (!tags || tags.length === 0) && !originalAuthor;
|
||||
!title && !summary && !description && !baseRom && !platform && !version && !language && !completionStatus && !boxArt && !discord && !twitter && !pokecommunity && !github && (!tags || tags.length === 0) && !originalAuthor;
|
||||
if (isEmpty) {
|
||||
let applied = false;
|
||||
if (typeof data.title === "string") setTitle(data.title);
|
||||
@@ -302,6 +303,8 @@ export default function HackSubmitForm({
|
||||
if (typeof data.version === "string") applied = applied || !!data.version;
|
||||
if (typeof data.language === "string") setLanguage(data.language);
|
||||
if (typeof data.language === "string") applied = applied || !!data.language;
|
||||
if (["Complete","Demo","Alpha","Beta",""].includes(data.completionStatus)) setCompletionStatus(data.completionStatus);
|
||||
if (["Complete","Demo","Alpha","Beta",""].includes(data.completionStatus)) applied = applied || !!data.completionStatus;
|
||||
if (typeof data.boxArt === "string") setBoxArt(data.boxArt);
|
||||
if (typeof data.boxArt === "string") applied = applied || !!data.boxArt;
|
||||
if (typeof data.discord === "string") setDiscord(data.discord);
|
||||
@@ -342,7 +345,7 @@ export default function HackSubmitForm({
|
||||
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 || d.github || (Array.isArray(d.tags) && d.tags.length > 0) || (!customCreator && d.originalAuthor)
|
||||
d.title || d.summary || d.description || d.baseRom || d.platform || d.version || d.language || d.completionStatus || d.boxArt || d.discord || d.twitter || d.pokecommunity || d.github || (Array.isArray(d.tags) && d.tags.length > 0) || (!customCreator && d.originalAuthor)
|
||||
);
|
||||
if (hasAny) { hydratedFromDraftRef.current = true; setRestoredDraft(true); }
|
||||
}, [dummy, draftKey, customCreator]);
|
||||
@@ -359,6 +362,7 @@ export default function HackSubmitForm({
|
||||
platform,
|
||||
version,
|
||||
language,
|
||||
completionStatus,
|
||||
boxArt,
|
||||
discord,
|
||||
twitter,
|
||||
@@ -391,6 +395,7 @@ export default function HackSubmitForm({
|
||||
platform,
|
||||
version,
|
||||
language,
|
||||
completionStatus,
|
||||
boxArt,
|
||||
discord,
|
||||
twitter,
|
||||
@@ -413,7 +418,7 @@ export default function HackSubmitForm({
|
||||
|
||||
const allSocialValid = [discord, twitter, pokecommunity, github].every((s) => !s || urlLike(s));
|
||||
|
||||
const step1Valid = !!title.trim() && !!platform && !!baseRom.trim() && !!language.trim() && (isArchive ? !!originalAuthor.trim() : true);
|
||||
const step1Valid = !!title.trim() && !!platform && !!baseRom.trim() && !!language.trim() && !!completionStatus.trim() && (isArchive ? !!originalAuthor.trim() : true);
|
||||
const step2Valid = (isArchive ? true : !!version.trim()) && !!summary.trim() && !summaryTooLong && !!description.trim() && tags.length > 0;
|
||||
const step3Valid = (newCoverFiles.length > 0) && !overLimit && coverErrors.length === 0 && (!boxArt.trim() || urlLike(boxArt)) && allSocialValid;
|
||||
const isValid = step1Valid && step2Valid && step3Valid && (isArchive ? true : !!patchFile);
|
||||
@@ -428,6 +433,7 @@ export default function HackSubmitForm({
|
||||
fd.set('description', description);
|
||||
fd.set('base_rom', baseRom);
|
||||
fd.set('language', language);
|
||||
fd.set('completion_status', completionStatus);
|
||||
fd.set('version', version);
|
||||
if (boxArt) fd.set('box_art', boxArt);
|
||||
if (discord) fd.set('discord', discord);
|
||||
@@ -692,6 +698,7 @@ export default function HackSubmitForm({
|
||||
setPlatform("");
|
||||
setVersion("");
|
||||
setLanguage("");
|
||||
setCompletionStatus("");
|
||||
setBoxArt("");
|
||||
setDiscord("");
|
||||
setTwitter("");
|
||||
@@ -797,6 +804,23 @@ export default function HackSubmitForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-foreground/80">Completion Status <span className="text-red-500">*</span></label>
|
||||
{!isDummy ? (
|
||||
<Select
|
||||
value={completionStatus}
|
||||
onChange={(value) => setCompletionStatus(value as any)}
|
||||
placeholder="Select completion status"
|
||||
options={['Complete','Demo','Alpha','Beta'].map(s => ({
|
||||
value: s,
|
||||
label: s,
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<div role="textbox" aria-disabled className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] flex items-center text-foreground/60 select-none">{completionStatus || "Select completion status"}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isArchive && (
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-foreground/80">Original Author <span className="text-red-500">*</span></label>
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="p-4 flex flex-col flex-1 min-h-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 w-full">
|
||||
<div className={`flex items-center gap-2 ${isArchive ? "justify-between" : "justify-start"}`}>
|
||||
@@ -207,7 +209,10 @@ export default function HackCard({ hack, clickable = true, className = "" }: { h
|
||||
return text.length > 120 ? text.slice(0, 120).trimEnd() + "…" : text;
|
||||
})()}
|
||||
</p>
|
||||
<div className="mt-3 text-xs text-foreground/60">Base: {baseName ?? "Unknown"}</div>
|
||||
<div className="flex justify-between items-end mt-auto pt-3 text-xs text-foreground/60">
|
||||
<p>Base: {baseName ?? "Unknown"}</p>
|
||||
{hack.completion_status && hack.completion_status !== "Complete" && <p className="font-bold text-sm">{hack.completion_status}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user