From 682a2b5068c285fcca5b9c4d02b242f4cd7c46ed Mon Sep 17 00:00:00 2001 From: Jared Schoeny Date: Wed, 22 Oct 2025 01:08:27 -1000 Subject: [PATCH] Refactor tag selection in SubmitForm --- src/components/Discover/DiscoverBrowser.tsx | 23 +- src/components/Icons/tagCategories.ts | 24 ++ src/components/Submit/SubmitForm.tsx | 151 +--------- src/components/Submit/TagSelector.tsx | 315 ++++++++++++++++++++ 4 files changed, 347 insertions(+), 166 deletions(-) create mode 100644 src/components/Icons/tagCategories.ts create mode 100644 src/components/Submit/TagSelector.tsx diff --git a/src/components/Discover/DiscoverBrowser.tsx b/src/components/Discover/DiscoverBrowser.tsx index b18a0a8..7e1f423 100644 --- a/src/components/Discover/DiscoverBrowser.tsx +++ b/src/components/Discover/DiscoverBrowser.tsx @@ -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 = { - "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) => ( ({ id: t, name: t }))} values={selectedTags.filter((t) => tagGroups[cat].includes(t))} diff --git a/src/components/Icons/tagCategories.ts b/src/components/Icons/tagCategories.ts new file mode 100644 index 0000000..56f062a --- /dev/null +++ b/src/components/Icons/tagCategories.ts @@ -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 = { + "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]; +} + + diff --git a/src/components/Submit/SubmitForm.tsx b/src/components/Submit/SubmitForm.tsx index 9be9cb9..c59de87 100644 --- a/src/components/Submit/SubmitForm.tsx +++ b/src/components/Submit/SubmitForm.tsx @@ -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([]); - const [tagsInput, setTagsInput] = React.useState(""); const [showMdPreview, setShowMdPreview] = React.useState(false); const [patchFile, setPatchFile] = React.useState(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([]); - const [isTagDropdownOpen, setIsTagDropdownOpen] = React.useState(false); - const tagAreaRef = React.useRef(null); - const tagsInputRef = React.useRef(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 = (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 */}
-
-
- {tags.map((t, i) => ( - - {t} - {!isDummy && ( - - )} - - ))} - {!isDummy ? ( - { 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`} - /> - ) : ( -
{tags.length ? "Add tag" : "Add tags (e.g. QoL, Challenge)"}
- )} -
- {!isDummy && isTagDropdownOpen && filteredTags.length > 0 && ( -
-
- {filteredTags.map((t, idx) => { - const isActive = idx === activeTagIndex; - return ( - - ); - })} -
-
- )} -
+
{/* Summary */} diff --git a/src/components/Submit/TagSelector.tsx b/src/components/Submit/TagSelector.tsx new file mode 100644 index 0000000..0d73e57 --- /dev/null +++ b/src/components/Submit/TagSelector.tsx @@ -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([]); + const [loading, setLoading] = React.useState(false); + const [activeCategory, _setActiveCategory] = React.useState(null); + const searchInputRef = React.useRef(null); + const categoryRefs = React.useRef>({}); + const categoriesContainerRef = React.useRef(null); + const tagsContainerRef = React.useRef(null); + const tagItemRefs = React.useRef<(HTMLDivElement | null)[]>([]); + const [activeTagIndex, setActiveTagIndex] = React.useState(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(); + 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(); + 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 ( +
+ {/* Selected tag pills */} +
+ {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 ( + + {Icon ? : null} + {t} + + + ); + }) :
No tags selected
} +
+ + {/* Persistent selector */} +
+ {/* Search input */} +
+ 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" + /> +
+ +
+ {/* Categories */} +
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" + > +
Categories
+
+ {filtered.categories.map((cat) => { + const Icon = CATEGORY_ICONS[cat]; + return ( +
{ 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' + }`} + > + {Icon ? : null}{cat} +
+ );})} + {filtered.advanced.length > 0 && ( +
{ 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' + }`} + > + Advanced +
+ )} +
+
+ + {/* Tags */} +
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" + > +
{activeCategory === "advanced" ? "Advanced" : (activeCategory || "Pick a category")}
+
+ {(activeCategory + ? (activeCategory === "advanced" ? filtered.advanced : (filtered.byCat.get(activeCategory) || [])) + : [] + ).map((t, idx) => ( +
{ 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'}`} + > + {t.name} + +
+ ))} + {!activeCategory && ( +
Select a category
+ )} + {activeCategory && (activeCategory === "advanced" ? filtered.advanced.length === 0 : (filtered.byCat.get(activeCategory)?.length || 0) === 0) && ( +
No results
+ )} +
+
+
+
+ {loading &&
Loading tags…
} +
+ ); +} + +