feat : improve UX/UI
This commit is contained in:
@@ -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 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">เงื่อนไขค่าสถานะแนะนำ</p>
|
||||
<ToggleGroup
|
||||
variant="outline"
|
||||
value={[recommendedCondition]}
|
||||
onValueChange={(values) => {
|
||||
if (values[0]) setRecommendedCondition(values[0]);
|
||||
}}
|
||||
>
|
||||
{recommendedConditionLabels.map((label, index) => (
|
||||
<ToggleGroupItem
|
||||
key={index}
|
||||
value={String(index)}
|
||||
className="aria-pressed:bg-primary aria-pressed:text-primary-foreground"
|
||||
>
|
||||
{label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<Tabs value={recommendedCondition} onValueChange={setRecommendedCondition}>
|
||||
<TabsList aria-label="เงื่อนไขค่าสถานะแนะนำ">
|
||||
{recommendedConditionLabels.map((label, index) => (
|
||||
<TabsTrigger key={index} value={String(index)}>
|
||||
<MousePointerClickIcon aria-hidden="true" data-icon="inline-start" />
|
||||
{label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
) : null}
|
||||
{sections.has("tierlist") && tierConditionLabels.length > 1 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">เงื่อนไขอันดับอาร์ติแฟกต์</p>
|
||||
<ToggleGroup variant="outline" value={[tierCondition]} onValueChange={(values) => { if (values[0]) setTierCondition(values[0]); }}>
|
||||
{tierConditionLabels.map((label, index) => <ToggleGroupItem key={index} value={String(index)} className="aria-pressed:bg-primary aria-pressed:text-primary-foreground">{label}</ToggleGroupItem>)}
|
||||
</ToggleGroup>
|
||||
<Tabs value={tierCondition} onValueChange={setTierCondition}>
|
||||
<TabsList aria-label="เงื่อนไขอันดับอาร์ติแฟกต์">
|
||||
{tierConditionLabels.map((label, index) => <TabsTrigger key={index} value={String(index)}><MousePointerClickIcon aria-hidden="true" data-icon="inline-start" />{label}</TabsTrigger>)}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
) : null}
|
||||
{sections.has("target-stats") && targetConditionLabels.length > 1 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">เงื่อนไขค่าสถานะเป้าหมาย</p>
|
||||
<ToggleGroup
|
||||
variant="outline"
|
||||
value={[targetCondition]}
|
||||
onValueChange={(values) => {
|
||||
if (values[0]) setTargetCondition(values[0]);
|
||||
}}
|
||||
>
|
||||
{targetConditionLabels.map((label, index) => (
|
||||
<ToggleGroupItem
|
||||
key={index}
|
||||
value={String(index)}
|
||||
className="aria-pressed:bg-primary aria-pressed:text-primary-foreground"
|
||||
>
|
||||
{label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<Tabs value={targetCondition} onValueChange={setTargetCondition}>
|
||||
<TabsList aria-label="เงื่อนไขค่าสถานะเป้าหมาย">
|
||||
{targetConditionLabels.map((label, index) => (
|
||||
<TabsTrigger key={index} value={String(index)}>
|
||||
<MousePointerClickIcon aria-hidden="true" data-icon="inline-start" />
|
||||
{label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<Tabs className="min-w-0 gap-4" value={active} onValueChange={setActive}>
|
||||
<ScrollableNav ariaLabel={label} className="w-full min-w-0">
|
||||
<ToggleGroup aria-label={label} variant="outline" value={[active]} onValueChange={(values) => {
|
||||
if (values[0]) setActive(values[0]);
|
||||
}}>
|
||||
<TabsList aria-label={label} className="mx-auto shrink-0">
|
||||
{items.map((_, index) => (
|
||||
<ToggleGroupItem
|
||||
<TabsTrigger
|
||||
key={index}
|
||||
value={String(index)}
|
||||
className="aria-pressed:bg-primary aria-pressed:text-primary-foreground"
|
||||
>
|
||||
<MousePointerClickIcon className="text-primary group-aria-pressed/tabs:text-primary-foreground" aria-hidden="true" data-icon="inline-start" />
|
||||
{labels?.[index] || `Condition ${index + 1}`}
|
||||
</ToggleGroupItem>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</TabsList>
|
||||
</ScrollableNav>
|
||||
{items[Number(active)]}
|
||||
</div>
|
||||
{items.map((item, index) => <TabsContent key={index} value={String(index)} className="text-base">{item}</TabsContent>)}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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 (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<InputGroup className="h-11 min-w-64 flex-1">
|
||||
<InputGroupAddon>
|
||||
<SearchIcon />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={query}
|
||||
onChange={(event) => setParam("q", event.target.value)}
|
||||
placeholder="ค้นหาไกด์…"
|
||||
aria-label="ค้นหาไกด์"
|
||||
/>
|
||||
</InputGroup>
|
||||
<div className="flex flex-wrap gap-2" aria-label="ตัวกรองไกด์">
|
||||
<GuideFilter label="ธาตุ" value={element} options={elements} optionLabels={elementLabels} onChange={(value) => setParam("element", value)} />
|
||||
<GuideFilter label="อาวุธ" value={weapon} options={weapons} optionLabels={weaponTypeLabels} onChange={(value) => setParam("weapon", value)} />
|
||||
<GuideFilter label="ดาว" value={rarity} options={rarities.map(String)} onChange={(value) => setParam("rarity", value)} />
|
||||
<GuideFilter label="เรียงตาม" value={sort} options={["manual", "recent"]} optionLabels={{ manual: "แนะนำ", recent: "อัปเดตล่าสุด" }} includeAll={false} onChange={(value) => setParam("sort", value)} />
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<InputGroup className="h-9 w-48 shrink-0">
|
||||
<InputGroupAddon>
|
||||
<SearchIcon />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={query}
|
||||
onChange={(event) => setParam("q", event.target.value)}
|
||||
placeholder="ค้นหาไกด์…"
|
||||
aria-label="ค้นหาไกด์"
|
||||
/>
|
||||
</InputGroup>
|
||||
<Select value={sort} onValueChange={(next) => next && setParam("sort", next)}>
|
||||
<SelectTrigger aria-label="เรียงตาม"><SelectValue>{sortOptions.find((option) => option.value === sort)?.label}</SelectValue></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{sortOptions.map((option) => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2 overflow-x-auto pb-1" aria-label="ตัวกรองไกด์">
|
||||
<GuideToggleFilter label="ธาตุ" value={selectedElements} options={elements} optionLabels={elementLabels} optionIcon={(option) => elementIconUrls[option] ? <ElementIcon element={option} url={elementIconUrls[option]} /> : null} onChange={(values) => setParam("element", values.join(","))} />
|
||||
<GuideToggleFilter label="อาวุธ" value={selectedWeapons} options={weapons} optionLabels={weaponTypeLabels} optionIcon={(option) => {
|
||||
const src = weaponTypeIconPath(option);
|
||||
return src ? <Image src={src} alt="" width={16} height={16} className="size-4 object-contain" /> : null;
|
||||
}} onChange={(values) => setParam("weapon", values.join(","))} />
|
||||
<GuideToggleFilter label="ดาว" value={selectedRarities} options={rarities.map(String)} optionIcon={() => <StarIcon className="size-4 fill-current text-yellow-500" />} optionText={(option) => option} onChange={(values) => setParam("rarity", values.join(","))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<string, string>;
|
||||
includeAll?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
optionIcon?: (value: string) => ReactNode;
|
||||
optionText?: (value: string) => string;
|
||||
onChange: (value: string[]) => void;
|
||||
}) {
|
||||
return (
|
||||
<Select value={value} onValueChange={(next) => next && onChange(next)}>
|
||||
<SelectTrigger aria-label={label}>
|
||||
<SelectValue>{optionLabels[value] ?? (value === "all" ? label : value)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{includeAll ? <SelectItem value="all">{label}: ทั้งหมด</SelectItem> : null}
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option} value={option}>{optionLabels[option] ?? option}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="shrink-0 border-l pl-2">
|
||||
<ToggleGroup aria-label={label} variant="outline" size="sm" value={value} onValueChange={onChange}>
|
||||
{options.map((option) => (
|
||||
<ToggleGroupItem key={option} value={option} aria-label={`${label}: ${optionLabels[option] ?? option}`} title={`${label}: ${optionLabels[option] ?? option}`} className="aria-pressed:border-primary aria-pressed:bg-transparent aria-pressed:text-primary">
|
||||
{optionIcon?.(option)}{optionText?.(option)}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -209,7 +209,6 @@ function GuideOverview({
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-3 text-center">
|
||||
<p className="text-sm font-medium text-primary">ภาพรวมแนวทาง</p>
|
||||
<h1 className="font-heading text-4xl font-bold">{guide.name}</h1>
|
||||
<p className="text-lg font-semibold">{guide.overview}</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
|
||||
@@ -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 ? (
|
||||
<ScrollableNav ariaLabel="เลือกเงื่อนไขทีม" className="w-full min-w-0">
|
||||
<ToggleGroup aria-label="เลือกเงื่อนไขทีม" variant="outline" value={[activeCondition]} onValueChange={(values) => {
|
||||
if (values[0]) setActiveCondition(values[0]);
|
||||
}}>
|
||||
{groups.map((group, index) => <ToggleGroupItem key={group.id} value={String(index)} className="aria-pressed:bg-primary aria-pressed:text-primary-foreground">{group.name || `เงื่อนไข ${index + 1}`}</ToggleGroupItem>)}
|
||||
</ToggleGroup>
|
||||
<Tabs className="mx-auto" value={activeCondition} onValueChange={setActiveCondition}>
|
||||
<TabsList aria-label="เลือกเงื่อนไขทีม">
|
||||
{groups.map((group, index) => <TabsTrigger key={group.id} value={String(index)}><MousePointerClickIcon aria-hidden="true" data-icon="inline-start" />{group.name || `เงื่อนไข ${index + 1}`}</TabsTrigger>)}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</ScrollableNav>
|
||||
) : null}
|
||||
{activeGroup?.note ? <p className="whitespace-pre-wrap text-muted-foreground">{activeGroup.note}</p> : null}
|
||||
|
||||
Reference in New Issue
Block a user