Refactor tag selection in SubmitForm

This commit is contained in:
Jared Schoeny
2025-10-22 01:08:27 -10:00
parent 6e759b9446
commit 682a2b5068
4 changed files with 347 additions and 166 deletions

View File

@@ -7,29 +7,10 @@ import { baseRoms } from "@/data/baseRoms";
import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from "@headlessui/react";
import { useFloating, offset, flip, shift, size, autoUpdate } from "@floating-ui/react";
import { IconType } from "react-icons";
import {
MdCatchingPokemon,
MdNewReleases,
MdAutoFixHigh,
MdSettingsSuggest,
} from "react-icons/md";
import { MdTune } from "react-icons/md";
import { BiSolidGame } from "react-icons/bi";
import { FaClock, FaGaugeHigh, FaMasksTheater } from "react-icons/fa6";
import { BsSdCardFill } from "react-icons/bs";
import { IoLogoGameControllerA } from "react-icons/io";
import { CATEGORY_ICONS } from "@/components/Icons/tagCategories";
const CATEGORY_ICON: Record<string, IconType> = {
"Pokédex": MdCatchingPokemon,
"Sprites": BiSolidGame,
"New": MdNewReleases,
"Altered": MdAutoFixHigh,
"Quality of Life": MdSettingsSuggest,
"Gameplay": IoLogoGameControllerA,
"Difficulty": FaGaugeHigh,
"Scale": FaClock,
"Tone": FaMasksTheater,
};
export default function DiscoverBrowser() {
const supabase = createClient();
@@ -220,7 +201,7 @@ export default function DiscoverBrowser() {
.map((cat) => (
<MultiSelectDropdown
key={cat}
icon={CATEGORY_ICON[cat]}
icon={CATEGORY_ICONS[cat]}
label={cat}
options={tagGroups[cat].map((t) => ({ id: t, name: t }))}
values={selectedTags.filter((t) => tagGroups[cat].includes(t))}

View File

@@ -0,0 +1,24 @@
import { IconType } from "react-icons";
import { MdCatchingPokemon, MdNewReleases, MdAutoFixHigh, MdSettingsSuggest } from "react-icons/md";
import { BiSolidGame } from "react-icons/bi";
import { FaClock, FaGaugeHigh, FaMasksTheater } from "react-icons/fa6";
import { IoLogoGameControllerA } from "react-icons/io";
export const CATEGORY_ICONS: Record<string, IconType> = {
"Pokédex": MdCatchingPokemon,
"Sprites": BiSolidGame,
"New": MdNewReleases,
"Altered": MdAutoFixHigh,
"Quality of Life": MdSettingsSuggest,
"Gameplay": IoLogoGameControllerA,
"Difficulty": FaGaugeHigh,
"Scale": FaClock,
"Tone": FaMasksTheater,
};
export function getCategoryIcon(category: string | null | undefined): IconType | undefined {
if (!category) return undefined;
return CATEGORY_ICONS[category];
}

View File

@@ -15,6 +15,7 @@ import { RxDragHandleDots2, RxPlus } from "react-icons/rx";
import { useAuthContext } from "@/contexts/AuthContext";
import { Database } from "@/types/db";
import { useBaseRoms } from "@/contexts/BaseRomContext";
import TagSelector from "@/components/Submit/TagSelector";
import BinFile from "rom-patcher-js/rom-patcher-js/modules/BinFile.js";
import BPS from "rom-patcher-js/rom-patcher-js/modules/RomPatcher.format.bps.js";
import { sha1Hex } from "@/utils/hash";
@@ -59,9 +60,7 @@ interface SubmitFormProps {
dummy?: boolean;
}
type TagSortable = Database["public"]["Tables"]["tags"]["Row"] & {
popularity: number;
};
// Tag selection handled by TagSelector component
export default function SubmitForm({ dummy = false }: SubmitFormProps) {
const MAX_COVERS = 10;
@@ -82,7 +81,6 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
const [twitter, setTwitter] = React.useState("");
const [pokecommunity, setPokecommunity] = React.useState("");
const [tags, setTags] = React.useState<string[]>([]);
const [tagsInput, setTagsInput] = React.useState("");
const [showMdPreview, setShowMdPreview] = React.useState(false);
const [patchFile, setPatchFile] = React.useState<File | null>(null);
const [patchMode, setPatchMode] = React.useState<"bps" | "rom">("bps");
@@ -185,53 +183,9 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
setNewCoverFiles((prev) => arrayMove(prev, oldIndex, newIndex));
};
// Existing tags (from DB)
const [allTags, setAllTags] = React.useState<TagSortable[]>([]);
const [isTagDropdownOpen, setIsTagDropdownOpen] = React.useState(false);
const tagAreaRef = React.useRef<HTMLDivElement | null>(null);
const tagsInputRef = React.useRef<HTMLInputElement | null>(null);
const [activeTagIndex, setActiveTagIndex] = React.useState(0);
// Tags handled in TagSelector
React.useEffect(() => {
// Fetch all tags once on mount
const fetchTags = async () => {
try {
const { data, error } = await supabase.from('tags').select('id, name, usage: hack_tags (count)');
if (error) return;
const fetchedTags: TagSortable[] = (data || []).map((t: any) => ({ id: t.id, name: t.name, popularity: t.usage[0].count || 0, category: t.category }));
setAllTags(
fetchedTags.sort((a, b) => {
if (b.popularity !== a.popularity) {
return b.popularity - a.popularity;
}
// If popularity is equal, sort by id
if (a.id < b.id) return -1;
if (a.id > b.id) return 1;
return 0;
})
);
} catch {}
};
fetchTags();
}, [supabase]);
React.useEffect(() => {
const onDocClick = (e: MouseEvent) => {
if (!tagAreaRef.current) return;
if (!tagAreaRef.current.contains(e.target as Node)) {
setIsTagDropdownOpen(false);
}
};
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, []);
const filteredTags = React.useMemo(() => {
const q = tagsInput.trim().toLowerCase();
const pool = allTags.filter((t) => !tags.includes(t.name));
if (!q) return pool.slice(0, 15);
return pool.filter((t) => t.name.toLowerCase().includes(q)).slice(0, 8);
}, [allTags, tags, tagsInput]);
// Tag fetching and filtering moved to TagSelector
// Focus the first input on each step when step changes
React.useEffect(() => {
@@ -247,54 +201,7 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
}
}, [step, isDummy]);
React.useEffect(() => {
if (!isTagDropdownOpen) return;
if (filteredTags.length === 0) {
setActiveTagIndex(0);
} else {
setActiveTagIndex((i) => Math.min(Math.max(0, i), filteredTags.length - 1));
}
}, [filteredTags, isTagDropdownOpen]);
const addTag = (value: string) => {
const tag = value.trim();
if (!tag) return;
if (!allTags.some((t) => t.name === tag)) return; // only allow existing tags
if (tags.includes(tag)) return;
setTags((prev) => [...prev, tag]);
setTagsInput("");
};
const removeTagAt = (index: number) => {
setTags((prev) => prev.filter((_, i) => i !== index));
};
const onTagsKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!isTagDropdownOpen) setIsTagDropdownOpen(true);
if (filteredTags.length > 0) setActiveTagIndex((i) => (i + 1) % filteredTags.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (!isTagDropdownOpen) setIsTagDropdownOpen(true);
if (filteredTags.length > 0) setActiveTagIndex((i) => (i - 1 + filteredTags.length) % filteredTags.length);
} else if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
const choice = filteredTags[activeTagIndex] || filteredTags[0];
if (choice) {
addTag(choice.name);
setIsTagDropdownOpen(true);
}
} else if (e.key === 'Tab') {
// Close dropdown and allow tabbing to next focusable element
setIsTagDropdownOpen(false);
} else if (e.key === 'Escape') {
setIsTagDropdownOpen(false);
} else if (e.key === 'Backspace' && !tagsInput && tags.length > 0) {
e.preventDefault();
setTags((prev) => prev.slice(0, prev.length - 1));
}
};
// Removed legacy key handling; TagSelector manages interactions
const slugify = (text: string) =>
text
@@ -566,53 +473,7 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
{/* Tags */}
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Tags <span className="text-red-500">*</span></label>
<div ref={tagAreaRef} className="rounded-md ring-1 ring-inset ring-[var(--border)] bg-[var(--surface-2)] px-2 py-2 relative">
<div className="flex flex-wrap gap-2">
{tags.map((t, i) => (
<span key={`${t}-${i}`} className="inline-flex items-center gap-1 rounded-full bg-[var(--surface-2)] px-2 py-1 text-xs ring-1 ring-[var(--border)]">
{t}
{!isDummy && (
<button type="button" onClick={() => removeTagAt(i)} className="ml-1 text-foreground/70 hover:text-foreground">×</button>
)}
</span>
))}
{!isDummy ? (
<input
ref={tagsInputRef}
value={tagsInput}
onChange={(e) => { setTagsInput(e.target.value); setIsTagDropdownOpen(true); }}
onKeyDown={onTagsKeyDown}
onFocus={() => setIsTagDropdownOpen(true)}
placeholder={tags.length ? "Add tag" : "Add tags (e.g. QoL, Challenge)"}
className={`flex-1 min-w-[8rem] bg-transparent px-2 text-sm placeholder:text-foreground/50 focus:outline-none`}
/>
) : (
<div className="flex-1 min-w-[8rem] px-2 text-sm text-foreground/50 select-none">{tags.length ? "Add tag" : "Add tags (e.g. QoL, Challenge)"}</div>
)}
</div>
{!isDummy && isTagDropdownOpen && filteredTags.length > 0 && (
<div className="absolute left-0 right-0 z-20 mt-2 max-h-64 overflow-auto rounded-md border border-[var(--border)] bg-[var(--surface-1)] backdrop-blur-xl p-1 shadow-xl">
<div className="grid">
{filteredTags.map((t, idx) => {
const isActive = idx === activeTagIndex;
return (
<button
key={t.name}
type="button"
tabIndex={-1}
onMouseEnter={() => setActiveTagIndex(idx)}
onClick={() => {addTag(t.name); tagsInputRef.current?.focus();}}
className={`flex items-center justify-between gap-2 rounded px-2 py-2 text-left text-sm transition-colors ${isActive ? 'bg-black/5 dark:bg-white/10' : 'hover:bg-black/5 dark:hover:bg-white/10'}`}
>
<span className="truncate">{t.name}</span>
<RxPlus className="shrink-0 opacity-80" />
</button>
);
})}
</div>
</div>
)}
</div>
<TagSelector value={tags} onChange={setTags} />
</div>
{/* Summary */}

View File

@@ -0,0 +1,315 @@
"use client";
import React from "react";
import { createClient } from "@/utils/supabase/client";
import { MdTune } from "react-icons/md";
import { CATEGORY_ICONS, getCategoryIcon } from "@/components/Icons/tagCategories";
import { FaTimes } from "react-icons/fa";
type TagRow = {
id: number;
name: string;
category: string | null;
popularity: number;
};
export interface TagSelectorProps {
value: string[];
onChange: (next: string[]) => void;
}
export default function TagSelector({ value, onChange }: TagSelectorProps) {
const supabase = createClient();
const [query, setQuery] = React.useState("");
const [allTags, setAllTags] = React.useState<TagRow[]>([]);
const [loading, setLoading] = React.useState(false);
const [activeCategory, _setActiveCategory] = React.useState<string | "advanced" | null>(null);
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
const categoryRefs = React.useRef<Record<string, HTMLDivElement | null>>({});
const categoriesContainerRef = React.useRef<HTMLDivElement | null>(null);
const tagsContainerRef = React.useRef<HTMLDivElement | null>(null);
const tagItemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const [activeTagIndex, setActiveTagIndex] = React.useState<number | null>(null);
const [categoriesPaneFocused, setCategoriesPaneFocused] = React.useState(false);
const setActiveCategory = React.useCallback((cat: string | "advanced" | null) => {
_setActiveCategory(cat);
setActiveTagIndex(null);
setCategoriesPaneFocused(true);
}, []);
React.useEffect(() => {
(async () => {
try {
setLoading(true);
const { data } = await supabase
.from("tags")
.select("id,name,category,usage: hack_tags (count)");
const rows: TagRow[] = (data || []).map((t: any) => ({
id: t.id,
name: t.name,
category: t.category ?? null,
popularity: t.usage?.[0]?.count || 0,
}));
rows.sort((a, b) => (b.popularity - a.popularity) || a.name.localeCompare(b.name));
setAllTags(rows);
} finally {
setLoading(false);
}
})();
}, [supabase]);
const grouped = React.useMemo(() => {
const map = new Map<string, TagRow[]>();
const advanced: TagRow[] = [];
for (const t of allTags) {
if (!t.category) {
advanced.push(t);
} else {
const arr = map.get(t.category) || [];
arr.push(t);
map.set(t.category, arr);
}
}
// sort tags inside categories
for (const [, arr] of map) arr.sort((a, b) => (b.popularity - a.popularity) || a.name.localeCompare(b.name));
advanced.sort((a, b) => (b.popularity - a.popularity) || a.name.localeCompare(b.name));
return { categories: Array.from(map.keys()).sort((a, b) => a.localeCompare(b)), byCat: map, advanced };
}, [allTags]);
// Filter categories and tags by query; hide categories with zero results. Keep selected tags visible.
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
const categories: string[] = [];
const byCat = new Map<string, TagRow[]>();
for (const cat of grouped.categories) {
const pool = (grouped.byCat.get(cat) || []);
const list = q ? pool.filter((t) => t.name.toLowerCase().includes(q)) : pool;
if (list.length > 0) {
categories.push(cat);
byCat.set(cat, list);
}
}
const advPool = grouped.advanced;
const advanced = q ? advPool.filter((t) => t.name.toLowerCase().includes(q)) : advPool;
return { categories, byCat, advanced };
}, [grouped, query, value]);
// Ensure active category always has results; pick the first available when query changes
React.useEffect(() => {
const hasActive = activeCategory === "advanced"
? filtered.advanced.length > 0
: !!activeCategory && filtered.byCat.get(activeCategory)?.length;
if (!hasActive && categoriesPaneFocused) {
if (filtered.categories.length > 0) setActiveCategory(filtered.categories[0]);
else if (filtered.advanced.length > 0) setActiveCategory("advanced");
else setActiveCategory(null);
}
}, [filtered, activeCategory, categoriesPaneFocused]);
// Scroll category into view when active changes
React.useEffect(() => {
const el = activeCategory ? categoryRefs.current[activeCategory] : null;
if (el) {
try { el.scrollIntoView({ block: 'nearest' }); } catch {}
}
}, [activeCategory]);
// Scroll active tag into view
React.useEffect(() => {
if (activeTagIndex == null) return;
const el = tagItemRefs.current[activeTagIndex];
if (el) {
try { el.scrollIntoView({ block: 'nearest' }); } catch {}
}
}, [activeTagIndex]);
function toggleTag(name: string) {
onChange(value.includes(name) ? value.filter((v) => v !== name) : [...value, name]);
}
return (
<div className="grid gap-2">
{/* Selected tag pills */}
<div className="flex max-h-24 flex-wrap gap-2 overflow-auto pr-1">
{value.length > 0 ? value.map((t) => {
const cat = grouped.categories.find((c) => (grouped.byCat.get(c) || []).some((r) => r.name === t)) || (grouped.advanced.some((r) => r.name === t) ? "Advanced" : undefined);
const Icon = getCategoryIcon(cat === "Advanced" ? null : cat);
return (
<span key={t} className="inline-flex items-center gap-1 rounded-full bg-[var(--surface-2)] px-2 py-1 text-xs ring-1 ring-[var(--border)]">
{Icon ? <Icon className="h-3.5 w-3.5 opacity-80" /> : null}
{t}
<button type="button" onClick={() => toggleTag(t)} className="ml-1 text-foreground/70 hover:text-foreground hover:cursor-pointer"><FaTimes className="h-3 w-3" /></button>
</span>
);
}) : <div className="px-2 py-0.5 text-sm text-foreground/60">No tags selected</div>}
</div>
{/* Persistent selector */}
<div className="overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-2)]/80 backdrop-blur-xl shadow-xl">
{/* Search input */}
<div className="border-b border-[var(--border)] p-2">
<input
ref={searchInputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
const first = filtered.categories[0] || (filtered.advanced.length > 0 ? 'advanced' : null);
if (!activeCategory && first) setActiveCategory(first);
categoriesContainerRef.current?.focus();
}
}}
placeholder={value.length ? "Search tags" : "Search tags (e.g. QoL, Challenge)"}
className="w-full bg-transparent px-2 text-sm placeholder:text-foreground/50 focus:outline-none"
/>
</div>
<div className="flex h-80 divide-x divide-[var(--border)]">
{/* Categories */}
<div
ref={categoriesContainerRef}
tabIndex={0}
onFocus={() => setCategoriesPaneFocused(true)}
onBlur={() => setCategoriesPaneFocused(false)}
onMouseLeave={() => setCategoriesPaneFocused(false)}
onKeyDown={(e) => {
const cats = [...filtered.categories, ...(filtered.advanced.length > 0 ? ['advanced'] : [])];
const idx = activeCategory ? cats.indexOf(activeCategory) : -1;
if (e.key === 'ArrowDown') {
e.preventDefault();
const next = Math.min(cats.length - 1, (idx < 0 ? 0 : idx + 1));
if (cats[next]) setActiveCategory(cats[next]);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const prev = Math.max(0, (idx < 0 ? 0 : idx - 1));
if (idx === 0) {
setActiveCategory(null);
setActiveTagIndex(null);
searchInputRef.current?.focus();
} else if (cats[prev]) {
setActiveCategory(cats[prev]);
}
} else if (e.key === 'ArrowRight') {
e.preventDefault();
const key = cats[idx >= 0 ? idx : 0];
if (key) setActiveCategory(key);
setActiveTagIndex(0);
tagsContainerRef.current?.focus();
} else if (e.key === 'Enter') {
e.preventDefault();
const key = cats[idx >= 0 ? idx : 0];
if (key) setActiveCategory(key);
}
}}
role="listbox"
aria-label="Tag categories"
className="w-52 max-w-[60vw] overflow-auto p-2 outline-none"
>
<div className="mb-1 px-1 text-xs uppercase tracking-wider text-foreground/60">Categories</div>
<div className="flex flex-col">
{filtered.categories.map((cat) => {
const Icon = CATEGORY_ICONS[cat];
return (
<div
key={cat}
ref={(el) => { categoryRefs.current[cat] = el; }}
role="option"
aria-selected={activeCategory === cat}
onMouseEnter={() => setActiveCategory(cat)}
onClick={() => setActiveCategory(cat)}
className={`flex items-center justify-between rounded px-2 py-1.5 text-left text-sm ${
activeCategory === cat
? (categoriesPaneFocused ? 'bg-black/5 dark:bg-white/10' : 'bg-zinc-800/5 dark:bg-zinc-200/5 ring-1 ring-black/10 dark:ring-white/20')
: 'hover:bg-black/5 dark:hover:bg-white/10'
}`}
>
<span className="truncate inline-flex items-center gap-2">{Icon ? <Icon className="h-4 w-4 opacity-80" /> : null}{cat}</span>
</div>
);})}
{filtered.advanced.length > 0 && (
<div
ref={(el) => { categoryRefs.current['advanced'] = el; }}
role="option"
aria-selected={activeCategory === 'advanced'}
onMouseEnter={() => setActiveCategory('advanced')}
onClick={() => setActiveCategory('advanced')}
className={`mt-1 flex items-center justify-between rounded px-2 py-1.5 text-left text-sm ${
activeCategory === 'advanced'
? (categoriesPaneFocused ? 'bg-black/5 dark:bg-white/10' : 'bg-zinc-800/5 dark:bg-zinc-200/5 ring-1 ring-black/10 dark:ring-white/20')
: 'hover:bg-black/5 dark:hover:bg-white/10'
}`}
>
<span className="inline-flex items-center gap-2"><MdTune className="h-4 w-4" />Advanced</span>
</div>
)}
</div>
</div>
{/* Tags */}
<div
ref={tagsContainerRef}
tabIndex={0}
onMouseLeave={() => setActiveTagIndex(null)}
onBlur={() => setActiveTagIndex(null)}
onKeyDown={(e) => {
const list = activeCategory
? (activeCategory === 'advanced' ? filtered.advanced : (filtered.byCat.get(activeCategory) || []))
: [];
if (e.key === 'ArrowDown') {
e.preventDefault();
if (list.length > 0) setActiveTagIndex((i) => (i == null ? 0 : Math.min(list.length - 1, (i ?? 0) + 1)));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (list.length > 0) setActiveTagIndex((i) => (i == null ? 0 : Math.max(0, (i ?? 0) - 1)));
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeTagIndex != null && list[activeTagIndex]) toggleTag(list[activeTagIndex].name);
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
setActiveTagIndex(null);
(document.activeElement as HTMLElement | null)?.blur?.();
categoriesContainerRef.current?.focus();
}
}}
role="listbox"
aria-label="Tags"
className="min-w-[18rem] max-w-[70vw] overflow-auto p-2 outline-none"
>
<div className="mb-1 px-1 text-xs uppercase tracking-wider text-foreground/60">{activeCategory === "advanced" ? "Advanced" : (activeCategory || "Pick a category")}</div>
<div className="grid gap-1 pr-1">
{(activeCategory
? (activeCategory === "advanced" ? filtered.advanced : (filtered.byCat.get(activeCategory) || []))
: []
).map((t, idx) => (
<div
key={t.id}
ref={(el) => { tagItemRefs.current[idx] = el; }}
role="option"
aria-selected={activeTagIndex === idx}
onMouseEnter={() => setActiveTagIndex(idx)}
onClick={() => toggleTag(t.name)}
className={`flex items-center justify-between rounded px-2 py-1.5 text-sm ${activeTagIndex === idx ? 'bg-black/5 dark:bg-white/10' : 'hover:bg-black/5 dark:hover:bg-white/10'}`}
>
<span className="truncate">{t.name}</span>
<input type="checkbox" readOnly checked={value.includes(t.name)} className="h-4 w-4 accent-[var(--accent)]" />
</div>
))}
{!activeCategory && (
<div className="px-2 py-1.5 text-sm text-foreground/60">Select a category</div>
)}
{activeCategory && (activeCategory === "advanced" ? filtered.advanced.length === 0 : (filtered.byCat.get(activeCategory)?.length || 0) === 0) && (
<div className="px-2 py-1.5 text-sm text-foreground/60">No results</div>
)}
</div>
</div>
</div>
</div>
{loading && <div className="text-xs text-foreground/60">Loading tags</div>}
</div>
);
}