Merge branch 'Hackdex-App:main' into feat/version-management

This commit is contained in:
Jared Schoeny
2025-12-22 16:25:36 -10:00
committed by GitHub
7 changed files with 232 additions and 91 deletions

View File

@@ -1,4 +1,4 @@
import { createClient } from "@/utils/supabase/server";
import { createClient, createServiceClient } from "@/utils/supabase/server";
import Link from "next/link";
import { redirect } from "next/navigation";
import { FiAlertTriangle, FiExternalLink } from "react-icons/fi";
@@ -14,7 +14,12 @@ export default async function DashboardPage() {
if (!user) redirect("/login");
const { data: isAdmin } = await supa.rpc("is_admin");
let pendingHacks: (HackRow & { created_by: string; creator_username: string | null; creator_full_name: string | null })[] = [];
let pendingHacks: (HackRow & {
created_by: string;
creator_username: string | null;
creator_full_name: string | null;
creator_email: string | null
})[] = [];
if (isAdmin) {
const { data: pendingHacksData } = await supa
.from("hacks")
@@ -37,10 +42,26 @@ export default async function DashboardPage() {
fullNameById.set(p.id, p.full_name);
});
// Fetch creator emails using service client (admin API)
const serviceClient = await createServiceClient();
const emailById = new Map<string, string | null>();
for (const userId of creatorIds) {
try {
const { data: userData, error } = await serviceClient.auth.admin.getUserById(userId);
if (!error && userData?.user?.email) {
emailById.set(userId, userData.user.email);
}
} catch (error) {
// Silently fail if we can't get the email
console.error(`Failed to get email for user ${userId}:`, error);
}
}
pendingHacks = pendingHacksData.map((h) => ({
...h,
creator_username: usernameById.get(h.created_by as string) || null,
creator_full_name: fullNameById.get(h.created_by as string) || null,
creator_email: emailById.get(h.created_by as string) || null,
}));
}
}
@@ -89,8 +110,8 @@ export default async function DashboardPage() {
<div className="overflow-hidden rounded-lg border border-amber-600/30 bg-amber-500/5">
{/* Header row (desktop only) */}
<div className="hidden lg:grid grid-cols-12 bg-amber-500/5 px-4 py-2 text-xs text-amber-900/80 dark:text-amber-200/80">
<div className="col-span-5">Title</div>
<div className="col-span-3">Creator</div>
<div className="col-span-4">Title</div>
<div className="col-span-4">Creator</div>
<div className="col-span-4">Created</div>
</div>
<div className="divide-y divide-amber-600/20">
@@ -119,7 +140,7 @@ export default async function DashboardPage() {
>
{/* Desktop row */}
<div className="hidden lg:grid grid-cols-12 items-center">
<div className="col-span-5 min-w-0">
<div className="col-span-4 min-w-0">
<div className="flex items-center gap-3">
<div className="flex flex-col items-start min-w-0">
<div className="flex items-center gap-2 flex-wrap">
@@ -135,9 +156,12 @@ export default async function DashboardPage() {
</div>
</div>
</div>
<div className="col-span-3 flex flex-col">
<div className="col-span-4 flex flex-col min-w-0">
{h.creator_full_name && <div className="text-xs text-amber-900/70 dark:text-amber-200/70">{h.creator_full_name}</div>}
<div className="text-amber-900/90 dark:text-amber-200/90">{creator}</div>
{h.creator_email && (
<div className="text-xs text-amber-900/60 dark:text-amber-200/60 truncate mt-0.5">{h.creator_email}</div>
)}
</div>
<div className="col-span-4 min-w-0">
<div className="flex items-center gap-3">
@@ -160,10 +184,19 @@ export default async function DashboardPage() {
)}
</div>
<div className="mt-0.5 text-xs text-foreground/60 break-all">/{h.slug}</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-amber-900/90 dark:text-amber-200/90 mt-2">
<span>{creator}</span>
<span></span>
<span>{createdDate}</span>
<div className="flex flex-col gap-1 text-xs text-amber-900/90 dark:text-amber-200/90 mt-2">
{(h.creator_full_name || h.creator_email) && (
<div className="text-amber-900/70 dark:text-amber-200/70 break-all flex flex-wrap items-center gap-2">
{h.creator_full_name && <span>{h.creator_full_name}</span>}
{h.creator_full_name && h.creator_email && <span></span>}
{h.creator_email && <span>{h.creator_email}</span>}
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<span>{creator}</span>
<span></span>
<span>{createdDate}</span>
</div>
</div>
</div>
<FiExternalLink className="h-4 w-4 text-foreground/80 flex-shrink-0" />

View File

@@ -4,6 +4,7 @@ import React, { useActionState } from "react";
import { useSearchParams } from "next/navigation";
import { validateEmail } from "@/utils/auth";
import { sendContact, type ContactActionState } from "@/app/contact/actions";
import Select from "@/components/Primitives/Select";
type Topic =
| "general"
@@ -70,17 +71,16 @@ export default function ContactForm() {
)}
<div className="grid gap-2">
<label htmlFor="topic" className="text-sm text-foreground/80">Topic</label>
<select
<Select
id="topic"
name="topic"
value={topic}
onChange={(e) => setTopic(e.target.value as Topic)}
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)]"
>
{(Object.keys(topicLabels) as Topic[]).map((key) => (
<option key={key} value={key}>{topicLabels[key]}</option>
))}
</select>
onChange={(value) => setTopic(value as Topic)}
options={(Object.keys(topicLabels) as Topic[]).map((key) => ({
value: key,
label: topicLabels[key],
}))}
/>
<span className="text-xs text-foreground/60">
Choose the most relevant topic so we can help you better.
</span>

View File

@@ -5,6 +5,7 @@ import Link from "next/link";
import { FiExternalLink, FiEdit2, FiTrash2, FiChevronLeft, FiChevronRight, FiArrowDown, FiSearch, FiLoader, FiDownload, FiInfo, FiBarChart2 } from "react-icons/fi";
import { getArchives, deleteArchive } from "@/app/dashboard/archives/actions";
import { baseRoms } from "@/data/baseRoms";
import Select from "@/components/Primitives/Select";
type Archive = {
slug: string;
@@ -118,24 +119,26 @@ export default function ArchivesList({ initialData, isAdmin = false }: { initial
)}
</div>
<div className="flex items-center gap-2 w-full md:w-auto">
<select
<Select
value={filter}
onChange={(e) => setFilter(e.target.value as "all" | "downloadable" | "informational")}
className="w-full md:w-auto rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
>
<option value="all">All Archives</option>
<option value="downloadable">Downloadable</option>
<option value="informational">Informational</option>
</select>
<select
onChange={(value) => setFilter(value as "all" | "downloadable" | "informational")}
className="w-full md:w-auto"
options={[
{ value: "all", label: "All Archives" },
{ value: "downloadable", label: "Downloadable" },
{ value: "informational", label: "Informational" },
]}
/>
<Select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="w-full md:w-auto rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
>
<option value="created_at">Sort by date</option>
<option value="title">Sort by title</option>
<option value="original_author">Sort by author</option>
</select>
onChange={(value) => setSortBy(value as any)}
className="w-full md:w-auto"
options={[
{ value: "created_at", label: "Sort by date" },
{ value: "title", label: "Sort by title" },
{ value: "original_author", label: "Sort by author" },
]}
/>
<button
type="button"
onClick={() => setSortOrder(sortOrder === "asc" ? "desc" : "asc")}

View File

@@ -24,6 +24,7 @@ import { HackCardAttributes } from "@/components/HackCard";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { getDiscoverData } from "@/app/discover/actions";
import type { DiscoverSortOption } from "@/types/discover";
import Select, { SelectOption } from "@/components/Primitives/Select";
const SORT_ICON_MAP: Record<DiscoverSortOption, IconType> = {
trending: MdWhatshot,
@@ -33,6 +34,14 @@ const SORT_ICON_MAP: Record<DiscoverSortOption, IconType> = {
alphabetical: MdSortByAlpha,
};
const SORT_OPTIONS: SelectOption[] = [
{ value: "trending", label: "Trending", icon: MdWhatshot },
{ value: "popular", label: "Most popular", icon: MdTrendingUp },
{ value: "new", label: "Newest", icon: MdNewReleases },
{ value: "updated", label: "Recently updated", icon: MdUpdate },
{ value: "alphabetical", label: "Alphabetical", icon: MdSortByAlpha },
];
const HACKS_PER_PAGE = 9;
interface DiscoverBrowserProps {
@@ -241,30 +250,26 @@ export default function DiscoverBrowser({ initialSort = "trending" }: DiscoverBr
className="h-11 w-full rounded-md bg-[var(--surface-2)] px-3 text-sm text-foreground placeholder:text-foreground/60 ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
/>
</div>
<div
className="inline-flex h-11 items-center gap-1.5 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus-within:ring-2 focus-within:ring-[var(--ring)]"
>
<div className="flex h-11 w-full items-center gap-1.5 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] sm:inline-flex sm:w-auto">
{sortIcon}
<select
value={sort}
onChange={(e) => {
const nextSort = e.target.value as DiscoverSortOption;
setSort(nextSort);
// Keep URL query param in sync so refresh/back preserves sort
const current = searchParams ? new URLSearchParams(searchParams.toString()) : new URLSearchParams();
current.set("sort", nextSort);
const queryString = current.toString();
const url = queryString ? `${pathname}?${queryString}` : pathname;
router.replace(url);
}}
className="w-full h-full bg-transparent pl-1 pr-0 text-sm text-foreground focus:outline-none focus:ring-0"
>
<option value="trending">Trending</option>
<option value="popular">Most popular</option>
<option value="new">Newest</option>
<option value="updated">Recently updated</option>
<option value="alphabetical">Alphabetical</option>
</select>
<div className="relative flex-1 sm:flex-none">
<Select
value={sort}
onChange={(value) => {
const nextSort = value as DiscoverSortOption;
setSort(nextSort);
const current = searchParams ? new URLSearchParams(searchParams.toString()) : new URLSearchParams();
current.set("sort", nextSort);
const queryString = current.toString();
const url = queryString ? `${pathname}?${queryString}` : pathname;
router.replace(url);
}}
options={SORT_OPTIONS}
// this css gets a little janky, but it gets the job done
className="flex h-11 w-full items-center rounded-none bg-transparent px-0 pl-1 pr-8 text-left sm:w-fit !ring-0 focus:ring-0"
dropdownClassName="-left-[2.313rem] top-[44px] !min-w-0 !max-w-none !w-[calc(100%_+_3.125rem)] sm:left-auto sm:right-[-12px] sm:!w-max"
/>
</div>
</div>
</div>

View File

@@ -10,6 +10,7 @@ import Image from "next/image";
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";
interface HackEditFormProps {
slug: string;
@@ -393,11 +394,15 @@ export default function HackEditForm({ slug, initial }: HackEditFormProps) {
</div>
)}
</div>
<select value={language} onChange={(e) => setLanguage(e.target.value)} className={`h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${languageChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'}`}>
{['English','Spanish','French','German','Italian','Portuguese','Japanese','Chinese','Korean','Other'].map(l => (
<option key={l} value={l}>{l}</option>
))}
</select>
<Select
value={language}
onChange={setLanguage}
className={languageChanged ? 'ring-[var(--ring)]' : ''}
options={['English','Spanish','French','German','Italian','Portuguese','Japanese','Chinese','Korean','Other'].map(l => ({
value: l,
label: l,
}))}
/>
</div>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Current version</label>

View File

@@ -18,6 +18,7 @@ import { useAuthContext } from "@/contexts/AuthContext";
import { useBaseRoms } from "@/contexts/BaseRomContext";
import TagSelector from "@/components/Submit/TagSelector";
import BinFile from "rom-patcher-js/rom-patcher-js/modules/BinFile.js";
import Select from "@/components/Primitives/Select";
import BPS from "rom-patcher-js/rom-patcher-js/modules/RomPatcher.format.bps.js";
import { sha1Hex } from "@/utils/hash";
import { platformAccept, setDraftCovers, getDraftCovers, deleteDraftCovers } from "@/utils/idb";
@@ -733,17 +734,16 @@ export default function HackSubmitForm({
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Platform <span className="text-red-500">*</span></label>
{!isDummy ? (
<select
<Select
value={platform}
onChange={(e) => { if ((newCoverFiles.length) > 0) return; setPlatform(e.target.value as any); setBaseRom(""); }}
onChange={(value) => { if ((newCoverFiles.length) > 0) return; setPlatform(value as any); setBaseRom(""); }}
disabled={newCoverFiles.length > 0}
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"
>
<option value="" disabled>Select platform</option>
{(["GB","GBC","GBA","NDS"] as const).map(p => (
<option key={p} value={p}>{p}</option>
))}
</select>
placeholder="Select platform"
options={(["GB","GBC","GBA","NDS"] as const).map(p => ({
value: p,
label: p,
}))}
/>
) : (
<div 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">{platform || ""}</div>
)}
@@ -755,19 +755,16 @@ export default function HackSubmitForm({
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Base ROM <span className="text-red-500">*</span></label>
{!isDummy ? (
<select
<Select
value={baseRom}
onChange={(e) => setBaseRom(e.target.value)}
onChange={setBaseRom}
disabled={!platform}
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"
>
<option value="" disabled>{platform ? "Select base rom" : "Select platform first"}</option>
{baseRoms.filter(r => !platform || r.platform === platform).map(({ id, name, region }) => (
<option key={id} value={id}>
{name.replace('Pokémon ', '')} ({region})
</option>
))}
</select>
placeholder={platform ? "Select base rom" : "Select platform first"}
options={baseRoms.filter(r => !platform || r.platform === platform).map(({ id, name, region }) => ({
value: id,
label: `${name.replace('Pokémon ', '')} (${region})`,
}))}
/>
) : (
<div 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">{baseRoms.find(r=>r.id===baseRom)?.name || baseRom}</div>
)}
@@ -776,16 +773,15 @@ export default function HackSubmitForm({
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Language <span className="text-red-500">*</span></label>
{!isDummy ? (
<select
<Select
value={language}
onChange={(e) => setLanguage(e.target.value)}
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)]"
>
<option value="" disabled>Select language</option>
{['English','Spanish','French','German','Italian','Portuguese','Japanese','Chinese','Korean','Other'].map(l => (
<option key={l} value={l}>{l}</option>
))}
</select>
onChange={setLanguage}
placeholder="Select language"
options={['English','Spanish','French','German','Italian','Portuguese','Japanese','Chinese','Korean','Other'].map(l => ({
value: l,
label: l,
}))}
/>
) : (
<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">{language}</div>
)}

View File

@@ -0,0 +1,99 @@
"use client";
import React, { Fragment } from "react";
import { Listbox, ListboxButton, ListboxOptions, ListboxOption, Transition } from "@headlessui/react";
import { FiChevronDown, FiCheck } from "react-icons/fi";
export interface SelectOption {
value: string;
label: string;
disabled?: boolean;
icon?: React.ComponentType<{ className?: string }>;
}
interface SelectProps {
value: string;
onChange: (value: string) => void;
options: SelectOption[];
placeholder?: string;
disabled?: boolean;
className?: string;
dropdownClassName?: string;
dropdownAlign?: "left" | "right";
id?: string;
name?: string;
}
export default function Select({
value,
onChange,
options,
placeholder = "Select an option",
disabled = false,
className = "",
dropdownClassName = "",
dropdownAlign = "left",
id,
name,
}: SelectProps) {
const selectedOption = options.find((opt) => opt.value === value);
return (
<Listbox value={value} onChange={onChange} disabled={disabled}>
{({ open }) => (
<div className="relative">
<ListboxButton
id={id}
className={`relative h-11 w-full cursor-pointer rounded-md bg-[var(--surface-2)] px-3 pr-10 text-left text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)] disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
>
<span className={`block truncate ${selectedOption ? "" : "text-foreground/60"}`}>
{selectedOption?.label || placeholder}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<FiChevronDown className={`h-4 w-4 text-foreground/60 transition-transform ${open ? "rotate-180" : ""}`} aria-hidden="true" />
</span>
</ListboxButton>
{name && <input type="hidden" name={name} value={value} />}
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions className={`absolute z-50 mt-1 max-h-60 min-w-full max-w-[400px] w-max overflow-auto rounded-md bg-background/95 backdrop-blur-sm py-1 text-sm shadow-lg ring-1 ring-[var(--border)] focus:outline-none ${dropdownAlign === "right" ? "right-0" : ""} ${dropdownClassName}`}>
{options.map((option) => {
const Icon = option.icon;
return (
<ListboxOption
key={option.value}
value={option.value}
disabled={option.disabled}
className={({ focus }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${
focus ? "bg-black/5 dark:bg-white/10" : ""
} ${option.disabled ? "opacity-50 cursor-not-allowed" : ""}`
}
>
{({ selected }) => (
<>
<span className={`flex items-center gap-2 truncate ${selected ? "font-medium" : "font-normal"}`}>
{Icon && <Icon className="h-4 w-4 shrink-0" />}
{option.label}
</span>
{selected && (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-foreground">
<FiCheck className="h-4 w-4" aria-hidden="true" />
</span>
)}
</>
)}
</ListboxOption>
);
})}
</ListboxOptions>
</Transition>
</div>
)}
</Listbox>
);
}