diff --git a/src/app/hack/[slug]/page.tsx b/src/app/hack/[slug]/page.tsx index 450b744..7de97da 100644 --- a/src/app/hack/[slug]/page.tsx +++ b/src/app/hack/[slug]/page.tsx @@ -17,6 +17,7 @@ import serialize from "serialize-javascript"; import { headers } from "next/headers"; import { MenuItem } from "@headlessui/react"; import { FaCircleCheck } from "react-icons/fa6"; +import { sortOrderedTags } from "@/utils/format"; interface HackDetailProps { params: Promise<{ slug: string }>; @@ -133,9 +134,16 @@ export default async function HackDetail({ params }: HackDetailProps) { const { data: tagRows } = await supabase .from("hack_tags") - .select("tags(name)") + .select("order,tags(name)") .eq("hack_slug", slug); - const tags = (tagRows || []).map((r: any) => r.tags?.name).filter(Boolean) as string[]; + + const tags = sortOrderedTags( + (tagRows || []) + .map((r) => ({ + name: r.tags.name, + order: r.order, + })) + ).map((t) => t.name); const { data: profile } = await supabase .from("profiles") diff --git a/src/app/hack/actions.ts b/src/app/hack/actions.ts index 8e1e39e..e3bd5f1 100644 --- a/src/app/hack/actions.ts +++ b/src/app/hack/actions.ts @@ -54,14 +54,19 @@ export async function updateHack(args: { } if (args.tags) { - // Resolve desired tag IDs from names and compute diff against current links + // Resolve desired tag IDs from names and upsert links with explicit ordering const { data: existingTags, error: tagErr } = await supabase .from("tags") .select("id, name") .in("name", args.tags); if (tagErr) return { ok: false, error: tagErr.message } as const; - const desiredIds = Array.from(new Set((existingTags || []).map((t) => t.id))); + const byName = new Map((existingTags || []).map((t: any) => [t.name as string, t.id as number])); + + // Desired IDs in the exact order provided by the caller + const desiredIds = args.tags + .map((name) => byName.get(name)) + .filter((id): id is number => typeof id === "number"); const { data: currentLinks, error: curErr } = await supabase .from("hack_tags") @@ -72,9 +77,8 @@ export async function updateHack(args: { const currentIds = new Set((currentLinks || []).map((r: any) => r.tag_id as number)); const desiredSet = new Set(desiredIds); - const toAdd = desiredIds.filter((id) => !currentIds.has(id)); + // Remove links for tags that are no longer present const toRemove = Array.from(currentIds).filter((id) => !desiredSet.has(id)); - if (toRemove.length > 0) { const { error: delErr } = await supabase .from("hack_tags") @@ -84,10 +88,18 @@ export async function updateHack(args: { if (delErr) return { ok: false, error: delErr.message } as const; } - if (toAdd.length > 0) { - const rows = toAdd.map((id) => ({ hack_slug: args.slug, tag_id: id })); - const { error: insErr } = await supabase.from("hack_tags").insert(rows); - if (insErr) return { ok: false, error: insErr.message } as const; + // Upsert links for all desired tags with the correct order + if (desiredIds.length > 0) { + const rows: TablesInsert<"hack_tags">[] = desiredIds.map((id, index) => ({ + hack_slug: args.slug, + tag_id: id, + order: index + 1, + })); + + const { error: upErr } = await supabase + .from("hack_tags") + .upsert(rows, { onConflict: "hack_slug,tag_id" }); + if (upErr) return { ok: false, error: upErr.message } as const; } } diff --git a/src/app/page.tsx b/src/app/page.tsx index 9c1c991..45fd949 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,6 +4,7 @@ import { FaArrowRightLong } from "react-icons/fa6"; import { createClient } from "@/utils/supabase/server"; import HackCard from "@/components/HackCard"; import Button from "@/components/Button"; +import { sortOrderedTags } from "@/utils/format"; export const metadata: Metadata = { alternates: { @@ -61,13 +62,16 @@ export default async function Home() { // Fetch tags const { data: tagRows } = await supabase .from("hack_tags") - .select("hack_slug,tags(name,category)") + .select("hack_slug,order,tags(name,category)") .in("hack_slug", slugs); - const tagsBySlug = new Map(); + const tagsBySlug = new Map(); (tagRows || []).forEach((r: any) => { if (!r.tags?.name) return; const arr = tagsBySlug.get(r.hack_slug) || []; - arr.push(r.tags.name); + arr.push({ + name: r.tags.name, + order: r.order, + }); tagsBySlug.set(r.hack_slug, arr); }); @@ -103,7 +107,7 @@ export default async function Home() { title: r.title, author: usernameById.get(r.created_by as string) || "Unknown", covers: coversBySlug.get(r.slug) || [], - tags: tagsBySlug.get(r.slug) || [], + tags: sortOrderedTags(tagsBySlug.get(r.slug) || []), downloads: r.downloads, baseRomId: r.base_rom, version: mappedVersions.get(r.slug) || "Pre-release", diff --git a/src/app/submit/actions.ts b/src/app/submit/actions.ts index 6127ff6..6934284 100644 --- a/src/app/submit/actions.ts +++ b/src/app/submit/actions.ts @@ -91,7 +91,7 @@ export async function prepareSubmission(formData: FormData) { .in("name", tags); if (tagErr) return { ok: false, error: tagErr.message } as const; if (existingTags && existingTags.length > 0) { - const hackTags = existingTags.map((t) => ({ hack_slug: slug, tag_id: t.id })); + const hackTags = existingTags.map((t, i) => ({ hack_slug: slug, tag_id: t.id, order: i + 1 })); const { error: htErr } = await supabase.from("hack_tags").insert(hackTags); if (htErr) return { ok: false, error: htErr.message } as const; } diff --git a/src/components/Discover/DiscoverBrowser.tsx b/src/components/Discover/DiscoverBrowser.tsx index ba6f45c..89abeca 100644 --- a/src/components/Discover/DiscoverBrowser.tsx +++ b/src/components/Discover/DiscoverBrowser.tsx @@ -11,6 +11,7 @@ import { MdTune } from "react-icons/md"; import { BsSdCardFill } from "react-icons/bs"; import { CATEGORY_ICONS } from "@/components/Icons/tagCategories"; import { useBaseRoms } from "@/contexts/BaseRomContext"; +import { sortOrderedTags, OrderedTag } from "@/utils/format"; export default function DiscoverBrowser() { @@ -88,13 +89,15 @@ export default function DiscoverBrowser() { } const { data: tagRows } = await supabase .from("hack_tags") - .select("hack_slug,tags(name,category)") + .select("hack_slug,order,tags(name,category)") .in("hack_slug", slugs); - const tagsBySlug = new Map(); - (tagRows || []).forEach((r: any) => { - if (!r.tags?.name) return; + const tagsBySlug = new Map(); + (tagRows || []).forEach((r) => { const arr = tagsBySlug.get(r.hack_slug) || []; - arr.push(r.tags.name); + arr.push({ + name: r.tags.name, + order: r.order, + }); tagsBySlug.set(r.hack_slug, arr); }); @@ -126,7 +129,7 @@ export default function DiscoverBrowser() { title: r.title, author: usernameById.get(r.created_by as string) || "Unknown", covers: coversBySlug.get(r.slug) || [], - tags: tagsBySlug.get(r.slug) || [], + tags: sortOrderedTags(tagsBySlug.get(r.slug) || []), downloads: r.downloads, baseRomId: r.base_rom, version: mappedVersions.get(r.slug) || "Pre-release", diff --git a/src/components/Hack/HackSubmitForm.tsx b/src/components/Hack/HackSubmitForm.tsx index 034dd32..e936e47 100644 --- a/src/components/Hack/HackSubmitForm.tsx +++ b/src/components/Hack/HackSubmitForm.tsx @@ -20,7 +20,7 @@ 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"; import { platformAccept, setDraftCovers, getDraftCovers, deleteDraftCovers } from "@/utils/idb"; -import { slugify } from "@/utils/format"; +import { slugify, sortOrderedTags } from "@/utils/format"; function SortableCoverItem({ id, index, url, filename, onRemove }: { id: string; index: number; url: string; filename: string; onRemove: () => void }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); @@ -498,7 +498,7 @@ export default function HackSubmitForm({ dummy = false }: HackSubmitFormProps) { baseRomId: baseRom, downloads: 0, version: version || "v0.0.0", - tags, + tags: sortOrderedTags(tags.map((name, index) => ({ name, order: index + 1 }))), ...(boxArt ? { boxArt } : {}), socialLinks: discord || twitter || pokecommunity diff --git a/src/components/HackCard.tsx b/src/components/HackCard.tsx index 9e64b66..b83e86f 100644 --- a/src/components/HackCard.tsx +++ b/src/components/HackCard.tsx @@ -3,7 +3,7 @@ import PixelImage from "./PixelImage"; import Link from "next/link"; -import { formatCompactNumber } from "@/utils/format"; +import { formatCompactNumber, OrderedTag } from "@/utils/format"; import { useBaseRoms } from "@/contexts/BaseRomContext"; import { baseRoms } from "@/data/baseRoms"; import { useEffect, useRef, useState } from "react"; @@ -17,7 +17,7 @@ type CardHack = { title: string; author: string; covers: string[]; - tags: string[]; + tags: OrderedTag[]; downloads: number; baseRomId?: string; version: string; @@ -123,10 +123,10 @@ export default function HackCard({ hack, clickable = true, className = "" }: { h
{hack.tags.slice(0, 2).map((t) => ( - {t} + {t.name} ))} void; } +type CategoryIconType = React.ComponentType> | null; + +function SortableSelectedTag({ + id, + index, + name, + categoryIcon: Icon, + onRemove, + isPrimary, + isGhost, + insertSide, +}: { + id: string; + index: number; + name: string; + categoryIcon: CategoryIconType; + onRemove: () => void; + isPrimary: boolean; + isGhost: boolean; + insertSide: "left" | "right" | null; +}) { + const { + attributes, + listeners, + setNodeRef, + isDragging, + } = useSortable({ id }); + + const style: React.CSSProperties = { + opacity: isGhost ? 0.5 : undefined, + }; + + return ( + + {insertSide === "left" && ( +
+ )} + {insertSide === "right" && ( +
+ )} + + {Icon ? : null} + {name} + {isPrimary && ( + + {index === 0 ? "First" : "Second"} + + )} + + + ); +} + export default function TagSelector({ value, onChange }: TagSelectorProps) { const supabase = createClient(); const [query, setQuery] = React.useState(""); @@ -31,6 +127,14 @@ export default function TagSelector({ value, onChange }: TagSelectorProps) { const tagItemRefs = React.useRef<(HTMLDivElement | null)[]>([]); const [activeTagIndex, setActiveTagIndex] = React.useState(null); const [categoriesPaneFocused, setCategoriesPaneFocused] = React.useState(false); + const [activeId, setActiveId] = React.useState(null); + const [overId, setOverId] = React.useState(null); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 5 }, + }), + ); const setActiveCategory = React.useCallback((cat: string | "advanced" | null) => { _setActiveCategory(cat); @@ -125,25 +229,123 @@ export default function TagSelector({ value, onChange }: TagSelectorProps) { } }, [activeTagIndex]); + const selectedTagIds = React.useMemo( + () => value.map((t, i) => `${t}__${i}`), + [value], + ); + function toggleTag(name: string) { onChange(value.includes(name) ? value.filter((v) => v !== name) : [...value, name]); } + const handleDragStart = (event: DragStartEvent) => { + const id = event.active.id as string; + setActiveId(id); + setOverId(id); + }; + + const handleDragOver = (event: DragOverEvent) => { + if (event.over) { + setOverId(event.over.id as string); + } + }; + + const handleSelectedDragEnd = (event: DragEndEvent | DragCancelEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) { + setActiveId(null); + setOverId(null); + return; + } + const oldIndex = selectedTagIds.indexOf(active.id as string); + const newIndex = selectedTagIds.indexOf(over.id as string); + if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return; + const next = arrayMove(value, oldIndex, newIndex); + onChange(next); + setActiveId(null); + setOverId(null); + }; + + const handleDragCancel = () => { + setActiveId(null); + setOverId(null); + }; + 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
} +
+ The first two tags appear as badges on your hack card preview. +
+
+ {value.length > 0 ? ( + + + {value.map((t, index) => { + 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) ?? null) as CategoryIconType; + const id = selectedTagIds[index]; + const isPrimary = index === 0 || index === 1; + const isGhost = activeId === id; + const overIndex = overId ? selectedTagIds.indexOf(overId) : -1; + const activeIndex = activeId ? selectedTagIds.indexOf(activeId) : -1; + let insertSide: "left" | "right" | null = null; + if (activeId && overIndex === index && activeIndex !== index && activeIndex !== -1) { + insertSide = overIndex > activeIndex ? "right" : "left"; + } + + return ( + + toggleTag(t)} + isPrimary={isPrimary} + isGhost={isGhost} + insertSide={insertSide} + /> + + ); + })} + + + {activeId ? (() => { + const activeIndex = selectedTagIds.indexOf(activeId); + if (activeIndex === -1) return null; + const t = value[activeIndex]; + 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) ?? null) as CategoryIconType; + const isPrimary = activeIndex === 0 || activeIndex === 1; + return ( + + {Icon ? : null} + {t} + {isPrimary && ( + + {activeIndex === 0 ? "First" : "Second"} + + )} + + ); + })() : null} + + + ) : ( +
No tags selected
+ )}
{/* Persistent selector */} diff --git a/src/utils/format.ts b/src/utils/format.ts index 950a078..69c9da8 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -3,6 +3,21 @@ export function formatCompactNumber(value: number): string { return new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(value); } +export type OrderedTag = { name: string; order: number }; + +export function sortOrderedTags(rows: T[]): T[] { + return [...rows] + .filter((t) => !!t.name) + .sort((a, b) => { + const aHasOrder = a.order > 0; + const bHasOrder = b.order > 0; + if (aHasOrder && !bHasOrder) return -1; + if (!aHasOrder && bHasOrder) return 1; + if (aHasOrder && bHasOrder && a.order !== b.order) return a.order - b.order; + return 0; // fallback to original array order + }); +} + export function slugify(text: string) { return text .normalize("NFD") diff --git a/supabase/migrations/20251129054917_add_hack_tags_update_rls.sql b/supabase/migrations/20251129054917_add_hack_tags_update_rls.sql new file mode 100644 index 0000000..8708343 --- /dev/null +++ b/supabase/migrations/20251129054917_add_hack_tags_update_rls.sql @@ -0,0 +1,10 @@ +create policy "Owners can update tags on own hacks." + on "public"."hack_tags" + as PERMISSIVE + for UPDATE + to public + using ( + (is_admin() OR (EXISTS ( SELECT 1 + FROM hacks h + WHERE ((h.slug = hack_tags.hack_slug) AND (h.created_by = auth.uid()))))) + );