Require completion status for hacks

This commit is contained in:
Jared Schoeny
2025-12-27 12:05:18 -10:00
parent 6c41c3ee36
commit 15555f47c7
10 changed files with 145 additions and 22 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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">

View File

@@ -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;

View File

@@ -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,

View File

@@ -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"

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>
);