From 3c3b104779dc8c683f87b76eab0f73ae6d62d1b0 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Mon, 5 Oct 2026 20:01:41 +0700 Subject: [PATCH] feat : improve UX/UI --- app/page.tsx | 4 +- components/admin/guide-section-editor.tsx | 69 ++++++----- components/admin/structured-dashboard.tsx | 50 ++------ components/public/artifact-image-export.tsx | 66 +++++------ components/public/condition-switcher.tsx | 21 ++-- components/public/structured-directory.tsx | 122 ++++++++++++-------- components/public/structured-guide.tsx | 1 - components/public/team-search.tsx | 13 ++- lib/guides/queries.ts | 4 +- 9 files changed, 168 insertions(+), 182 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index 5f7ce33..e4a9079 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -20,9 +20,9 @@ export default function HomePage() { -
+
-

+

เลือกตัวละครแล้วส่องดูได้เลย

diff --git a/components/admin/guide-section-editor.tsx b/components/admin/guide-section-editor.tsx index dbbeb53..d8924f2 100644 --- a/components/admin/guide-section-editor.tsx +++ b/components/admin/guide-section-editor.tsx @@ -13,6 +13,7 @@ import { CopyIcon, GripVerticalIcon, HeartPulseIcon, + MousePointerClickIcon, PlusIcon, RefreshCcwIcon, SearchIcon, @@ -130,6 +131,7 @@ import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { Toggle } from "@/components/ui/toggle"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Dialog, DialogContent, @@ -918,17 +920,17 @@ function SortableConditionTab({

- + + {activeImportGuide.groups.length > 1 ? ( - { if (values[0]) setGuideImportCondition(values[0]); }} - > - {activeImportGuide.groups.map((group, index) => ( - - {group.name || `Condition ${index + 1}`} - - ))} - + + + {activeImportGuide.groups.map((group, index) => ( + + + ))} + + ) : null}
diff --git a/components/admin/structured-dashboard.tsx b/components/admin/structured-dashboard.tsx index 6593489..f0a5fd4 100644 --- a/components/admin/structured-dashboard.tsx +++ b/components/admin/structured-dashboard.tsx @@ -49,14 +49,6 @@ import { InputGroupAddon, InputGroupInput, } from "@/components/ui/input-group"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { Spinner } from "@/components/ui/spinner"; import { useAdminPresence } from "@/hooks/use-presence"; import type { CatalogSyncJob } from "@/lib/catalog/sync"; @@ -86,7 +78,6 @@ export function StructuredDashboard({ const router = useRouter(); const [pending, startTransition] = useTransition(); const [query, setQuery] = useState(""); - const [sort, setSort] = useState("default"); const [syncJob, setSyncJob] = useState(null); const presence = useAdminPresence(null); const catalogReady = Boolean(catalogVersion); @@ -95,10 +86,7 @@ export function StructuredDashboard({ const search = searchItems(guides, query, (guide) => `${guide.name} ${guide.characterKey} ${guide.overview}`, (guide) => [guide.name]); - const filteredGuides = sort === "default" ? search.items : [...search.items].sort((left, right) => { - const order = left.name.localeCompare(right.name, "th", { numeric: true }); - return sort === "name-asc" ? order : -order; - }); + const filteredGuides = search.items; useEffect(() => { if (!syncJobId || !syncJobStatus || ["completed", "unchanged", "cancelled", "error"].includes(syncJobStatus)) return; @@ -198,31 +186,17 @@ export function StructuredDashboard({ ) : null} {guides.length ? ( -
- - - - - setQuery(event.target.value)} - placeholder="ค้นหาตัวละคร…" - aria-label="ค้นหาตัวละคร" - /> - - -
+ + + + + setQuery(event.target.value)} + placeholder="ค้นหาตัวละคร…" + aria-label="ค้นหาตัวละคร" + /> + ) : null} {filteredGuides.length ? ( diff --git a/components/public/artifact-image-export.tsx b/components/public/artifact-image-export.tsx index 485b30c..228d3b9 100644 --- a/components/public/artifact-image-export.tsx +++ b/components/public/artifact-image-export.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, type ReactNode } from "react"; -import { DownloadIcon } from "lucide-react"; +import { DownloadIcon, MousePointerClickIcon } from "lucide-react"; import { GuideImageExport } from "@/components/public/guide-image-export"; import { Button } from "@/components/ui/button"; @@ -15,7 +15,7 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; const sectionOptions = [ ["tierlist", "อันดับอาร์ติแฟกต์"], @@ -106,53 +106,41 @@ export function ArtifactImageExport({ {sections.has("best-stats") && recommendedConditionLabels.length > 1 ? (

เงื่อนไขค่าสถานะแนะนำ

- { - if (values[0]) setRecommendedCondition(values[0]); - }} - > - {recommendedConditionLabels.map((label, index) => ( - - {label} - - ))} - + + + {recommendedConditionLabels.map((label, index) => ( + + + ))} + +
) : null} {sections.has("tierlist") && tierConditionLabels.length > 1 ? (

เงื่อนไขอันดับอาร์ติแฟกต์

- { if (values[0]) setTierCondition(values[0]); }}> - {tierConditionLabels.map((label, index) => {label})} - + + + {tierConditionLabels.map((label, index) => )} + +
) : null} {sections.has("target-stats") && targetConditionLabels.length > 1 ? (

เงื่อนไขค่าสถานะเป้าหมาย

- { - if (values[0]) setTargetCondition(values[0]); - }} - > - {targetConditionLabels.map((label, index) => ( - - {label} - - ))} - + + + {targetConditionLabels.map((label, index) => ( + + + ))} + +
) : null} diff --git a/components/public/condition-switcher.tsx b/components/public/condition-switcher.tsx index 96a4537..d5232a9 100644 --- a/components/public/condition-switcher.tsx +++ b/components/public/condition-switcher.tsx @@ -1,9 +1,10 @@ "use client"; +import { MousePointerClickIcon } from "lucide-react"; import { Children, type ReactNode, useState } from "react"; import { ScrollableNav } from "@/components/public/scrollable-nav"; -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; export function ConditionSwitcher({ children, @@ -20,23 +21,21 @@ export function ConditionSwitcher({ if (items.length <= 1) return items[0] ?? null; return ( -
+ - { - if (values[0]) setActive(values[0]); - }}> + {items.map((_, index) => ( - + + ))} - + - {items[Number(active)]} -
+ {items.map((item, index) => {item})} + ); } diff --git a/components/public/structured-directory.tsx b/components/public/structured-directory.tsx index 7851ace..0b784e7 100644 --- a/components/public/structured-directory.tsx +++ b/components/public/structured-directory.tsx @@ -5,8 +5,10 @@ import { searchItems } from "@/lib/search"; import Image from "next/image"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; -import { SearchIcon, UsersIcon } from "lucide-react"; -import { useMemo } from "react"; +import { SearchIcon, StarIcon, UsersIcon } from "lucide-react"; +import { useMemo, type ReactNode } from "react"; + +import { ElementIcon } from "@/components/public/element-icon"; import { Card, @@ -28,6 +30,7 @@ import { InputGroupInput, } from "@/components/ui/input-group"; import { useVisitorPresence } from "@/hooks/use-presence"; +import { weaponTypeIconPath } from "@/lib/overview-image"; import { Select, SelectContent, @@ -36,6 +39,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; type GuideCard = { id: string; @@ -46,6 +50,7 @@ type GuideCard = { version: number; aliases: string[]; element: string | null; + elementIconUrl: string | null; weaponType: string | null; rarity: number | null; updatedAt: Date; @@ -70,6 +75,13 @@ const weaponTypeLabels: Record = { WEAPON_CATALYST: "สื่อเวท", }; +const sortOptions = [ + { value: "manual", label: "แนะนำ" }, + { value: "recent", label: "อัปเดตล่าสุด" }, + { value: "name-asc", label: "A - Z" }, + { value: "name-desc", label: "Z - A" }, +] as const; + export function StructuredDirectory({ guides }: { guides: GuideCard[] }) { const searchParams = useSearchParams(); const query = searchParams.get("q") ?? ""; @@ -77,17 +89,16 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) { const elements = useMemo(() => [...new Set(guides.flatMap((guide) => guide.element ? [guide.element] : []))].sort(), [guides]); const weapons = useMemo(() => [...new Set(guides.flatMap((guide) => guide.weaponType ? [guide.weaponType] : []))].sort(), [guides]); const rarities = useMemo(() => [...new Set(guides.flatMap((guide) => guide.rarity ? [guide.rarity] : []))].sort((a, b) => b - a), [guides]); - const elementParam = searchParams.get("element"); - const weaponParam = searchParams.get("weapon"); - const rarityParam = searchParams.get("rarity"); - const element = elementParam && elements.includes(elementParam) ? elementParam : "all"; - const weapon = weaponParam && weapons.includes(weaponParam) ? weaponParam : "all"; - const rarity = rarityParam && rarities.includes(Number(rarityParam)) ? rarityParam : "all"; - const sort = searchParams.get("sort") === "recent" ? "recent" : "manual"; + const elementIconUrls = useMemo(() => Object.fromEntries(guides.flatMap((guide) => guide.element && guide.elementIconUrl ? [[guide.element, guide.elementIconUrl]] : [])), [guides]); + const selectedElements = [...new Set((searchParams.get("element") ?? "").split(",").filter((value) => elements.includes(value)))]; + const selectedWeapons = [...new Set((searchParams.get("weapon") ?? "").split(",").filter((value) => weapons.includes(value)))]; + const selectedRarities = [...new Set((searchParams.get("rarity") ?? "").split(",").filter((value) => rarities.includes(Number(value))))]; + const sortParam = searchParams.get("sort"); + const sort = sortParam === "recent" || sortParam === "name-asc" || sortParam === "name-desc" ? sortParam : "manual"; const matchingGuides = guides.filter((guide) => - (element === "all" || guide.element === element) && - (weapon === "all" || guide.weaponType === weapon) && - (rarity === "all" || guide.rarity === Number(rarity)), + (!selectedElements.length || (guide.element !== null && selectedElements.includes(guide.element))) && + (!selectedWeapons.length || (guide.weaponType !== null && selectedWeapons.includes(guide.weaponType))) && + (!selectedRarities.length || (guide.rarity !== null && selectedRarities.includes(String(guide.rarity)))), ); const search = searchItems(matchingGuides, query, (guide) => `${guide.name} ${guide.overview} ${guide.aliases.join(" ")}`, @@ -95,7 +106,12 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) { const filtered = sort === "recent" ? [...search.items].sort((left, right) => new Date(right.publishedAt ?? right.updatedAt).getTime() - new Date(left.publishedAt ?? left.updatedAt).getTime()) - : search.items; + : sort === "name-asc" || sort === "name-desc" + ? [...search.items].sort((left, right) => { + const order = left.name.localeCompare(right.name, "th", { numeric: true }); + return sort === "name-asc" ? order : -order; + }) + : search.items; function setParam(name: string, value: string) { const next = new URLSearchParams(searchParams.toString()); @@ -106,23 +122,35 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) { } return (
-
- - - - - setParam("q", event.target.value)} - placeholder="ค้นหาไกด์…" - aria-label="ค้นหาไกด์" - /> - -
- setParam("element", value)} /> - setParam("weapon", value)} /> - setParam("rarity", value)} /> - setParam("sort", value)} /> +
+
+ + + + + setParam("q", event.target.value)} + placeholder="ค้นหาไกด์…" + aria-label="ค้นหาไกด์" + /> + + +
+
+ elementIconUrls[option] ? : null} onChange={(values) => setParam("element", values.join(","))} /> + { + const src = weaponTypeIconPath(option); + return src ? : null; + }} onChange={(values) => setParam("weapon", values.join(","))} /> + } optionText={(option) => option} onChange={(values) => setParam("rarity", values.join(","))} />
@@ -176,34 +204,32 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) { ); } -function GuideFilter({ +function GuideToggleFilter({ label, value, options, optionLabels = {}, - includeAll = true, + optionIcon, + optionText, onChange, }: { label: string; - value: string; + value: string[]; options: string[]; optionLabels?: Record; - includeAll?: boolean; - onChange: (value: string) => void; + optionIcon?: (value: string) => ReactNode; + optionText?: (value: string) => string; + onChange: (value: string[]) => void; }) { return ( - +
+ + {options.map((option) => ( + + {optionIcon?.(option)}{optionText?.(option)} + + ))} + +
); } diff --git a/components/public/structured-guide.tsx b/components/public/structured-guide.tsx index d963393..4b4af5d 100644 --- a/components/public/structured-guide.tsx +++ b/components/public/structured-guide.tsx @@ -209,7 +209,6 @@ function GuideOverview({ return (
-

ภาพรวมแนวทาง

{guide.name}

{guide.overview}

diff --git a/components/public/team-search.tsx b/components/public/team-search.tsx index fcdb937..cb4ecc7 100644 --- a/components/public/team-search.tsx +++ b/components/public/team-search.tsx @@ -3,7 +3,7 @@ import Image from "next/image"; import Link from "next/link"; -import { ArrowUpRightIcon, SearchIcon, XIcon } from "lucide-react"; +import { ArrowUpRightIcon, MousePointerClickIcon, SearchIcon, XIcon } from "lucide-react"; import { useMemo, useState, type ReactNode } from "react"; import { GuideImageExport } from "@/components/public/guide-image-export"; @@ -24,6 +24,7 @@ import { } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { elementBackgroundColor } from "@/lib/catalog/element"; import { characterRarityGradientClass, rarityGradientClass } from "@/lib/catalog/rarity"; import { isCustomTravelerKey } from "@/lib/catalog/travelers"; @@ -448,11 +449,11 @@ export function TeamSearch({ groups }: { groups: PublicGuide["teamGroups"] }) { {groups.length > 1 ? ( - { - if (values[0]) setActiveCondition(values[0]); - }}> - {groups.map((group, index) => {group.name || `เงื่อนไข ${index + 1}`})} - + + + {groups.map((group, index) => )} + + ) : null} {activeGroup?.note ?

{activeGroup.note}

: null} diff --git a/lib/guides/queries.ts b/lib/guides/queries.ts index 9445299..cc66b38 100644 --- a/lib/guides/queries.ts +++ b/lib/guides/queries.ts @@ -265,6 +265,7 @@ export async function listPublicGuideCards() { updatedAt: guides.updatedAt, publishedAt: guides.publishedAt, element: catalogCharacters.element, + elementIconKey: catalogCharacters.elementIconKey, weaponType: catalogCharacters.weaponType, rarity: catalogCharacters.rarity, coverObjectKey: media.objectKey, @@ -279,10 +280,11 @@ export async function listPublicGuideCards() { alias: glossaryAliases.alias, }).from(glossaryAliases)]); const grouped = aliasesByItem(aliases); - return rows.map(({ coverObjectKey, ...guide }) => ({ + return rows.map(({ coverObjectKey, elementIconKey, ...guide }) => ({ ...guide, aliases: grouped.character.get(guide.characterKey) ?? [], coverUrl: publicMediaUrl(coverObjectKey), + elementIconUrl: elementIconKey ? publicMediaUrl(elementIconKey) : null, })); }