feat : buzz feedback

This commit is contained in:
2026-08-30 23:48:03 +07:00 Unverified
parent b92348f589
commit fd841057c1
32 changed files with 3932 additions and 265 deletions
+56 -35
View File
@@ -13,6 +13,7 @@ import {
TimerIcon,
Trash2Icon,
UserRoundIcon,
XIcon,
} from "lucide-react";
import { useState, type ReactNode } from "react";
import {
@@ -86,6 +87,7 @@ import {
import {
normalizeTargetStat,
targetStatDefaults,
targetStatIconName,
targetStatOptions,
} from "@/lib/guides/stats";
import { supportsWeaponType } from "@/lib/catalog/lunaris";
@@ -122,24 +124,29 @@ export interface GuideSectionEditorProps {
artifactProfile: ArtifactProfile;
constellations: ConstellationValue[];
teams: TeamValue[];
extras: Array<{ title: string; rows: Array<{ mediaId: string | null; heading: string; body: string; percent: string | null; imageUrl: string | null }> }>;
extras: Array<{ title: string; rows: Array<{ mediaId: string | null; ratio: "1:1" | "16:9"; heading: string; body: string; percent: string | null; imageUrl: string | null }> }>;
};
}
const SELECTED_TOGGLE_CLASS = "aria-pressed:border-primary aria-pressed:ring-2 aria-pressed:ring-primary/30";
function CatalogPicker({ items, value, onChange, placeholder, rarityBackground = false }: { items: CatalogItem[]; value: string; onChange: (value: string) => void; placeholder: string; rarityBackground?: boolean }) {
function CatalogPicker({ items, value, onChange, placeholder, rarityBackground = false, showSelectedImage = false }: { items: CatalogItem[]; value: string; onChange: (value: string) => void; placeholder: string; rarityBackground?: boolean; showSelectedImage?: boolean }) {
const selected = items.find((item) => item.key === value) ?? null;
return (
<Combobox items={items} value={selected} onValueChange={(next) => onChange(next?.key ?? "")} itemToStringLabel={(item) => item.name} itemToStringValue={(item) => item.key} autoHighlight>
<ComboboxInput className="w-full" placeholder={placeholder} showClear />
<ComboboxContent>
<ComboboxEmpty>ไม่พบรายการ</ComboboxEmpty>
<ComboboxList>
{(item) => <ComboboxItem key={item.key} value={item}><div className={cn("relative size-8 shrink-0 overflow-hidden rounded-md", rarityBackground && rarityGradientClass(item.rarity))}><Image src={item.imageUrl} alt="" fill className="object-contain" /></div>{item.name}</ComboboxItem>}
</ComboboxList>
</ComboboxContent>
</Combobox>
<div className="flex min-w-0 items-center gap-2">
{showSelectedImage && selected ? <div className={cn("relative size-10 shrink-0 overflow-hidden rounded-md", rarityBackground && rarityGradientClass(selected.rarity))}><Image src={selected.imageUrl} alt="" fill className="object-contain" /></div> : null}
<div className="min-w-0 flex-1">
<Combobox items={items} value={selected} onValueChange={(next) => onChange(next?.key ?? "")} itemToStringLabel={(item) => item.name} itemToStringValue={(item) => item.key} autoHighlight>
<ComboboxInput className="w-full" placeholder={placeholder} showClear />
<ComboboxContent>
<ComboboxEmpty>ไม่พบรายการ</ComboboxEmpty>
<ComboboxList>
{(item) => <ComboboxItem key={item.key} value={item}><div className={cn("relative size-8 shrink-0 overflow-hidden rounded-md", rarityBackground && rarityGradientClass(item.rarity))}><Image src={item.imageUrl} alt="" fill className="object-contain" /></div>{item.name}</ComboboxItem>}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
</div>
);
}
@@ -148,14 +155,26 @@ function StatSelect({ label, options, value, max, onChange }: { label: string; o
<Field>
<FieldLabel>{label}</FieldLabel>
<Select multiple value={value} onValueChange={(next) => { if (next.length <= max) onChange(next); }}>
<SelectTrigger className="w-full"><SelectValue placeholder="Select recommended stats…" /></SelectTrigger>
<SelectContent><SelectGroup>{options.map((option) => <SelectItem key={option} value={option} disabled={value.length >= max && !value.includes(option)}>{option}</SelectItem>)}</SelectGroup></SelectContent>
<SelectTrigger className="w-full"><StatIcons values={value} /><SelectValue placeholder="Select recommended stats…" /></SelectTrigger>
<SelectContent><SelectGroup>{options.map((option) => <SelectItem key={option} value={option} disabled={value.length >= max && !value.includes(option)}><StatIcons values={[option]} />{option}</SelectItem>)}</SelectGroup></SelectContent>
</Select>
<FieldDescription>เลือกได้สูงสุด {max} ค่า</FieldDescription>
</Field>
);
}
function StatIcons({ values }: { values: readonly string[] }) {
const iconNames = values.flatMap((value) => {
const targetIcon = targetStatIconName(value);
if (targetIcon) return [targetIcon];
if (value === "CRIT Rate%/CRIT DMG%") return ["critrate", "critdmg"];
const base = value.replace(/%$/, "").toLowerCase();
return ["hp", "atk", "def", "em", "er"].includes(base) ? [base] : [];
});
if (!iconNames.length) return null;
return <span className="flex shrink-0 items-center gap-0.5" aria-hidden="true">{iconNames.map((iconName) => <Image key={iconName} src={`/icon/stats/${iconName}.webp`} alt="" width={20} height={20} className="size-5 object-contain brightness-0 invert" />)}</span>;
}
function TalentLevelPicker({ value, onChange }: { value?: string; onChange: (value?: string) => void }) {
const selected = talentLevelOptions.find((level) => level === value) ?? null;
return (
@@ -348,7 +367,7 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
<Card>
<CardHeader><CardTitle>Artifact tier list</CardTitle><CardDescription>จัดชุด Artifact ในกลุ่ม Late Game และ Early Game ระบบจะบันทึกอัตโนมัติ</CardDescription></CardHeader>
<CardContent className="grid gap-5 md:grid-cols-2">
{groups.map((group, groupIndex) => <Card key={group.name} size="sm"><CardHeader><CardTitle>{group.name}</CardTitle></CardHeader><CardContent className="flex flex-col gap-3">{group.rows.map((row, rowIndex) => <div key={rowIndex} className="flex flex-col gap-3 rounded-lg border p-3"><div className="grid gap-3 lg:grid-cols-[1fr_1fr_8rem_auto]"><CatalogPicker items={artifacts} value={row.firstArtifactKey} placeholder="ค้นหาชุด Artifact แรก…" rarityBackground onChange={(firstArtifactKey) => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, firstArtifactKey } : candidate) } : item))} /><CatalogPicker items={artifacts} value={row.secondArtifactKey ?? ""} placeholder="ค้นหาชุดที่สอง (ถ้ามี)…" rarityBackground onChange={(secondArtifactKey) => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, secondArtifactKey: secondArtifactKey || null } : candidate) } : item))} /><Input type="number" min="0" step="0.01" aria-label="Artifact percent" value={row.percent} onChange={(event) => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: event.target.value } : candidate) } : item))} /><Button variant="ghost" size="icon" onClick={() => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.filter((_, inner) => inner !== rowIndex) } : item))}><Trash2Icon /><span className="sr-only">ลบ Artifact</span></Button></div><Field><FieldLabel htmlFor={`artifact-note-${groupIndex}-${rowIndex}`}>Note (optional)</FieldLabel><Textarea id={`artifact-note-${groupIndex}-${rowIndex}`} value={row.note ?? ""} onChange={(event) => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, note: event.target.value } : candidate) } : item))} /></Field></div>)}<Button variant="outline" onClick={() => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: [...item.rows, { firstArtifactKey: "", secondArtifactKey: null, percent: "100", note: "" }] } : item))}><PlusIcon data-icon="inline-start" />เพิ่มชุด Artifact</Button></CardContent></Card>)}
{groups.map((group, groupIndex) => <Card key={group.name} size="sm"><CardHeader><CardTitle>{group.name}</CardTitle></CardHeader><CardContent className="flex flex-col gap-3">{group.rows.map((row, rowIndex) => <div key={rowIndex} className={cn("flex flex-col gap-3 rounded-lg border p-3", rowIndex % 2 === 0 ? "bg-muted/60" : "bg-muted/20")}><div className="grid gap-3 lg:grid-cols-[1fr_1fr_8rem_auto]"><CatalogPicker items={artifacts} value={row.firstArtifactKey} placeholder="ค้นหาชุด Artifact แรก…" rarityBackground showSelectedImage onChange={(firstArtifactKey) => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, firstArtifactKey } : candidate) } : item))} /><CatalogPicker items={artifacts} value={row.secondArtifactKey ?? ""} placeholder="ค้นหาชุดที่สอง (ถ้ามี)…" rarityBackground showSelectedImage onChange={(secondArtifactKey) => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, secondArtifactKey: secondArtifactKey || null } : candidate) } : item))} /><Input type="number" min="0" step="0.01" aria-label="Artifact percent" value={row.percent} onChange={(event) => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: event.target.value } : candidate) } : item))} /><Button variant="ghost" size="icon" onClick={() => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.filter((_, inner) => inner !== rowIndex) } : item))}><Trash2Icon /><span className="sr-only">ลบ Artifact</span></Button></div><Field><FieldLabel htmlFor={`artifact-note-${groupIndex}-${rowIndex}`}>Note (optional)</FieldLabel><Textarea id={`artifact-note-${groupIndex}-${rowIndex}`} value={row.note ?? ""} onChange={(event) => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, note: event.target.value } : candidate) } : item))} /></Field></div>)}<Button variant="outline" onClick={() => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: [...item.rows, { firstArtifactKey: "", secondArtifactKey: null, percent: "100", note: "" }] } : item))}><PlusIcon data-icon="inline-start" />เพิ่มชุด Artifact</Button></CardContent></Card>)}
</CardContent>
</Card>
<Card>
@@ -365,8 +384,8 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
{targets.map((target, index) => (
<div key={`${target.stat}-${index}`} className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<Select value={target.stat} onValueChange={(stat) => { if (stat) setTargets(targets.map((item, itemIndex) => itemIndex === index ? { ...item, stat } : item)); }}>
<SelectTrigger className="w-full" aria-label={`Target stat ${index + 1}`}><SelectValue /></SelectTrigger>
<SelectContent><SelectGroup>{targetStatOptions.map((option) => <SelectItem key={option} value={option} disabled={targets.some((item, itemIndex) => itemIndex !== index && item.stat === option)}>{option}</SelectItem>)}</SelectGroup></SelectContent>
<SelectTrigger className="w-full" aria-label={`Target stat ${index + 1}`}><StatIcons values={[target.stat]} /><SelectValue /></SelectTrigger>
<SelectContent><SelectGroup>{targetStatOptions.map((option) => <SelectItem key={option} value={option} disabled={targets.some((item, itemIndex) => itemIndex !== index && item.stat === option)}><StatIcons values={[option]} />{option}</SelectItem>)}</SelectGroup></SelectContent>
</Select>
<Input value={target.value} placeholder="Value or text" aria-label={`${target.stat} value`} onChange={(event) => setTargets(targets.map((item, itemIndex) => itemIndex === index ? { ...item, value: event.target.value } : item))} />
</div>
@@ -475,28 +494,30 @@ function TeamEditor({ guide, characters, weapons, artifacts, initial }: EditorBa
return (
<SortableTeamItem key={team.clientId} id={team.clientId}>{(dragHandle) => <>
<div className="flex items-center gap-2">
<AccordionTrigger className="min-w-0 py-4 hover:no-underline">
<AccordionTrigger className="min-w-0 flex-1 py-4 hover:no-underline">
<span className="flex min-w-0 flex-col gap-1">
<span className="font-semibold">{team.title || `Team ${teamIndex + 1}`}</span>
<span className="text-xs font-normal text-muted-foreground">Damage {total.toLocaleString()} • DPS {dps.toFixed(0)}</span>
</span>
</AccordionTrigger>
{dragHandle}
<AlertDialog>
<AlertDialogTrigger render={<Button type="button" variant="ghost" size="icon-sm" />}>
<Trash2Icon /><span className="sr-only">ลบ Team</span>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>ลบ {team.title || `Team ${teamIndex + 1}`}?</AlertDialogTitle>
<AlertDialogDescription>ข้อมูล Team และตัวละครทั้ง 4 คนจะถูกลบออกจาก Guide</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>ยกเลิก</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={() => setTeams(teams.filter((item) => item.clientId !== team.clientId))}>ยืนยันลบ Team</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div className="ml-auto flex shrink-0 items-center gap-1">
{dragHandle}
<AlertDialog>
<AlertDialogTrigger render={<Button type="button" variant="ghost" size="icon-sm" />}>
<Trash2Icon /><span className="sr-only">ลบ Team</span>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>ลบ {team.title || `Team ${teamIndex + 1}`}?</AlertDialogTitle>
<AlertDialogDescription>ข้อมูล Team และตัวละครทั้ง 4 คนจะถูกลบออกจาก Guide</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>ยกเลิก</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={() => setTeams(teams.filter((item) => item.clientId !== team.clientId))}>ยืนยันลบ Team</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<AccordionContent className="pb-4">
<FieldGroup>
@@ -544,13 +565,13 @@ function TeamEditor({ guide, characters, weapons, artifacts, initial }: EditorBa
function ExtraEditor({ guide, initial }: EditorBase) {
const [sections, setSections] = useState(initial.extras);
const value = { sections: sections.map((section) => ({ title: section.title, rows: section.rows.map((row) => ({ mediaId: row.mediaId, heading: row.heading, body: row.body, percent: row.percent === null ? null : Number(row.percent) })) })) };
const value = { sections: sections.map((section) => ({ title: section.title, rows: section.rows.map((row) => ({ mediaId: row.mediaId, ratio: row.ratio, heading: row.heading, body: row.body, percent: row.percent === null ? null : Number(row.percent) })) })) };
const autosave = useAutosave({ value, valid: extraSectionSchema.safeParse({ ...value, expectedVersion: guide.version }).success, initialVersion: guide.version, persist: (next, expectedVersion) => saveExtraSectionList(guide.id, { ...next, expectedVersion }) });
return (
<Card>
<CardHeader><CardTitle>Extra sections</CardTitle><CardDescription>เพิ่มส่วนเสริมแบบภาพ หัวข้อ ข้อความสั้น และเปอร์เซ็นต์ ระบบจะบันทึกอัตโนมัติ</CardDescription></CardHeader>
<CardContent className="flex flex-col gap-5">
{sections.map((section, sectionIndex) => <Card key={sectionIndex} size="sm"><CardHeader><CardTitle><Input value={section.title} aria-label="ชื่อ Extra section" onChange={(event) => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, title: event.target.value } : item))} /></CardTitle><CardAction><Button variant="ghost" size="icon-sm" onClick={() => setSections(sections.filter((_, index) => index !== sectionIndex))}><Trash2Icon /><span className="sr-only">ลบส่วน</span></Button></CardAction></CardHeader><CardContent className="flex flex-col gap-4">{section.rows.map((row, rowIndex) => <Card key={rowIndex} size="sm"><CardHeader><CardTitle>{row.heading || `แถว ${rowIndex + 1}`}</CardTitle><CardAction><Button variant="ghost" size="icon-sm" onClick={() => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.filter((_, inner) => inner !== rowIndex) } : item))}><Trash2Icon /><span className="sr-only">ลบแถว</span></Button></CardAction></CardHeader><CardContent><FieldGroup>{row.imageUrl ? <div className="relative aspect-video overflow-hidden rounded-lg border"><Image src={row.imageUrl} alt={row.heading} fill className="object-cover" /></div> : null}<MediaUploader onReady={(asset) => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, mediaId: asset.id, imageUrl: asset.url } : candidate) } : item))} /><Field><FieldLabel>หัวข้อ</FieldLabel><Input value={row.heading} onChange={(event) => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, heading: event.target.value } : candidate) } : item))} /></Field><Field><FieldLabel>ข้อความ</FieldLabel><Textarea value={row.body} onChange={(event) => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, body: event.target.value } : candidate) } : item))} /></Field><Field><FieldLabel>Percent (optional)</FieldLabel><Input type="number" min="0" step="0.01" value={row.percent ?? ""} onChange={(event) => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: event.target.value || null } : candidate) } : item))} /></Field></FieldGroup></CardContent></Card>)}<Button variant="outline" onClick={() => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: [...item.rows, { mediaId: null, imageUrl: null, heading: "", body: "", percent: null }] } : item))}><PlusIcon data-icon="inline-start" />เพิ่มแถว</Button></CardContent></Card>)}
{sections.map((section, sectionIndex) => <Card key={sectionIndex} size="sm"><CardHeader><CardTitle><Input value={section.title} aria-label="ชื่อ Extra section" onChange={(event) => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, title: event.target.value } : item))} /></CardTitle><CardAction><Button variant="ghost" size="icon-sm" onClick={() => setSections(sections.filter((_, index) => index !== sectionIndex))}><Trash2Icon /><span className="sr-only">ลบส่วน</span></Button></CardAction></CardHeader><CardContent className="flex flex-col gap-4">{section.rows.map((row, rowIndex) => <Card key={rowIndex} size="sm"><CardHeader><CardTitle>{row.heading || `แถว ${rowIndex + 1}`}</CardTitle><CardAction><Button variant="ghost" size="icon-sm" onClick={() => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.filter((_, inner) => inner !== rowIndex) } : item))}><Trash2Icon /><span className="sr-only">ลบแถว</span></Button></CardAction></CardHeader><CardContent><FieldGroup>{row.imageUrl ? <div className={cn("relative overflow-hidden rounded-lg border", row.ratio === "1:1" ? "aspect-square" : "aspect-video")}><Image src={row.imageUrl} alt={row.heading} fill className="object-cover" /><Button type="button" variant="destructive" size="icon-sm" className="absolute top-2 right-2" onClick={() => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, mediaId: null, imageUrl: null } : candidate) } : item))} aria-label="ลบภาพ"><XIcon /></Button></div> : null}<MediaUploader onReady={(asset) => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, mediaId: asset.id, imageUrl: asset.url } : candidate) } : item))} /><Field><FieldLabel>อัตราส่วนภาพ</FieldLabel><ToggleGroup variant="outline" value={[row.ratio]} onValueChange={(selected) => { const ratio = selected[0]; if (ratio === "1:1" || ratio === "16:9") setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, ratio } : candidate) } : item)); }} className="flex-wrap justify-start"><ToggleGroupItem className={SELECTED_TOGGLE_CLASS} value="1:1">1:1</ToggleGroupItem><ToggleGroupItem className={SELECTED_TOGGLE_CLASS} value="16:9">16:9</ToggleGroupItem></ToggleGroup></Field><Field><FieldLabel>หัวข้อ</FieldLabel><Input value={row.heading} onChange={(event) => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, heading: event.target.value } : candidate) } : item))} /></Field><Field><FieldLabel>ข้อความ</FieldLabel><Textarea value={row.body} onChange={(event) => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, body: event.target.value } : candidate) } : item))} /></Field><Field><FieldLabel>Percent (optional)</FieldLabel><Input type="number" min="0" step="0.01" value={row.percent ?? ""} onChange={(event) => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: event.target.value || null } : candidate) } : item))} /></Field></FieldGroup></CardContent></Card>)}<Button variant="outline" onClick={() => setSections(sections.map((item, index) => index === sectionIndex ? { ...item, rows: [...item.rows, { mediaId: null, imageUrl: null, ratio: "16:9", heading: "", body: "", percent: null }] } : item))}><PlusIcon data-icon="inline-start" />เพิ่มแถว</Button></CardContent></Card>)}
</CardContent>
<CardFooter className="flex-wrap justify-between gap-3"><Button variant="outline" onClick={() => setSections([...sections, { title: "Extra", rows: [] }])}><PlusIcon data-icon="inline-start" />เพิ่ม Extra section</Button><AutosaveStatus state={autosave} /></CardFooter>
</Card>
+2 -1
View File
@@ -28,10 +28,11 @@ export function GuideSectionNav({
<Link
href={`/admin/${characterKey}`}
className={cn(
"flex min-h-8 shrink-0 items-center rounded-md px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
"flex min-h-8 shrink-0 items-center gap-2 rounded-md px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
current === "overview" ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Image src={navigationIconPath("overview")!} alt="" width={20} height={20} className="size-5 object-contain" />
Overview
</Link>
{sections.map((section) => {
+1 -1
View File
@@ -66,7 +66,7 @@ export function LoginCard() {
<CardHeader>
<CardTitle className="text-xl">เข้าสู่ระบบผู้ดูแล</CardTitle>
<CardDescription>
ใช้อีเมลและรหัสผ่านของบัญชีผู้ดูแล Better Auth
ใช้อีเมลและรหัสผ่านของบัญชีผู้
</CardDescription>
</CardHeader>
<CardContent>
+2 -3
View File
@@ -26,7 +26,7 @@ export function GuideShell({ snapshot }: { snapshot: PageSnapshot }) {
<LiveRefresh endpoint={`/api/events/page/${snapshot.page.id}`} />
)}
<SiteHeader />
<div className="sticky top-0 z-30 border-b bg-background/95 supports-backdrop-filter:backdrop-blur">
<div className="sticky top-0 z-30 bg-background/95 supports-backdrop-filter:backdrop-blur">
<div className="mx-auto w-full max-w-7xl px-4 sm:px-6">
<div className="flex min-h-14 items-center gap-3 py-2">
<Link
@@ -60,8 +60,7 @@ export function GuideShell({ snapshot }: { snapshot: PageSnapshot }) {
aria-current={page.id === snapshot.page.id ? "page" : undefined}
className={cn(
"relative flex min-h-11 shrink-0 items-center gap-2 rounded-md px-3 text-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
page.id === snapshot.page.id &&
"font-medium text-foreground after:absolute after:inset-x-3 after:bottom-0 after:h-0.5 after:bg-primary",
page.id === snapshot.page.id && "font-medium text-foreground",
)}
>
{navigationIconPath(page.slug) ? <Image src={navigationIconPath(page.slug)!} alt="" width={20} height={20} className="size-5 object-contain" /> : null}
+12 -4
View File
@@ -1,17 +1,25 @@
import Image from "next/image";
import Link from "next/link";
export function SiteHeader() {
import { Button } from "@/components/ui/button";
import { getAdminSession } from "@/lib/auth/server";
export async function SiteHeader({ editHref = "/admin" }: { editHref?: string } = {}) {
const isAdmin = Boolean(await getAdminSession());
return (
<header className="border-b bg-background/95 supports-backdrop-filter:backdrop-blur">
<div className="mx-auto flex h-16 w-full max-w-7xl items-center justify-between px-4 sm:px-6">
<div className="mx-auto flex min-h-16 w-full max-w-7xl flex-wrap items-center gap-2 px-4 py-2 sm:px-6">
<Link
href="/"
className="flex min-h-11 items-center gap-3 rounded-lg font-heading text-base font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="mr-auto flex min-h-11 items-center gap-3 rounded-lg font-heading font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Image src="/icon.png" alt="" width={36} height={36} className="size-9 object-contain" />
<span>ไกด์ช่องเกนชินไม่ใช่เกมมือถือ</span>
<span className="hidden sm:inline">ไกด์ช่องเกนชินไม่ใช่เกมมือถือ</span>
</Link>
<nav aria-label="Public navigation" className="flex items-center gap-1">
{isAdmin ? <Button variant="ghost" size="sm" nativeButton={false} render={<Link href={editHref} />}>ไปหน้าแก้ไข</Button> : null}
</nav>
</div>
</header>
);
+22 -144
View File
@@ -1,6 +1,6 @@
import Image from "next/image";
import Link from "next/link";
import { ChevronLeftIcon, DownloadIcon } from "lucide-react";
import { ArrowUpRightIcon, ChevronLeftIcon, DownloadIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
@@ -12,18 +12,17 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Kbd } from "@/components/ui/kbd";
import { Separator } from "@/components/ui/separator";
import { SiteHeader } from "@/components/public/site-header";
import { ScrollableNav } from "@/components/public/scrollable-nav";
import { ElementIcon } from "@/components/public/element-icon";
import { TeamSearch } from "@/components/public/team-search";
import { LiveRefresh } from "@/components/live-refresh";
import { rarityGradientClass, rarityTextClass } from "@/lib/catalog/rarity";
import { isCustomTravelerKey } from "@/lib/catalog/travelers";
import type { PublicGuide } from "@/lib/guides/queries";
import { artifactTierCount } from "@/lib/guides/artifacts";
import { navigationIconPath } from "@/lib/navigation";
import { normalizeTargetStat, statPriorityColorClass, targetStatIconName } from "@/lib/guides/stats";
import { normalizeTalentPriorities, talentRelationSymbol } from "@/lib/guides/talents";
import { normalizeTalentPriorities, talentRelationSymbol, talentTypeLabel } from "@/lib/guides/talents";
import { cn } from "@/lib/utils";
function hasSection(guide: PublicGuide, slug: string): boolean {
@@ -57,11 +56,12 @@ export function StructuredGuideShell({
section?: string;
}) {
const sections = publicSections(guide);
const currentPageTitle = section ? sections.find((item) => item.slug === section)?.title ?? section : "Overview";
return (
<div className="min-h-svh bg-background">
<LiveRefresh endpoint={`/api/events/page/${guide.id}`} />
<SiteHeader />
<div className="sticky top-0 z-10 border-b bg-background/95 supports-backdrop-filter:backdrop-blur">
<SiteHeader editHref={`/admin/${guide.characterKey}`} />
<div className="sticky top-0 z-10 bg-background/95 supports-backdrop-filter:backdrop-blur">
<div className="mx-auto flex min-h-16 w-full max-w-7xl items-center gap-3 px-4 sm:px-6">
<Link
href="/"
@@ -79,15 +79,8 @@ export function StructuredGuideShell({
/>
</div>
<div className="flex min-w-0 flex-1 flex-col items-start gap-1">
<p className="truncate font-heading font-semibold">{guide.name}</p>
{guide.character.element ? (
<Badge variant="secondary">
{guide.character.elementIconUrl ? (
<ElementIcon element={guide.character.element} url={guide.character.elementIconUrl} />
) : null}
{guide.character.element}
</Badge>
) : null}
<p className="truncate text-sm text-muted-foreground">{guide.name}</p>
<p className="truncate font-heading text-2xl font-semibold">{currentPageTitle}</p>
</div>
</div>
<ScrollableNav
@@ -97,10 +90,11 @@ export function StructuredGuideShell({
<Link
href={`/${guide.slug}`}
className={cn(
"flex min-h-8 shrink-0 items-center whitespace-nowrap rounded-md px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
"flex min-h-8 shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
!section ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Image src={navigationIconPath("overview")!} alt="" width={20} height={20} className="size-5 object-contain" />
Overview
</Link>
{sections.map((item) => (
@@ -160,8 +154,12 @@ function GuideOverview({
<Link key={item.id} href={`/${guide.slug}/${item.slug}`}>
<Card className="h-full">
<CardHeader>
{navigationIconPath(item.slug) ? <Image src={navigationIconPath(item.slug)!} alt="" width={40} height={40} className="size-10 object-contain" /> : null}
<CardTitle>{item.title}</CardTitle>
<CardDescription>เปิดดูคำแนะนำฉบับเต็ม</CardDescription>
<CardAction>
<ArrowUpRightIcon aria-hidden="true" className="size-5 text-primary" />
</CardAction>
</CardHeader>
</Card>
</Link>
@@ -309,7 +307,7 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) {
</CardHeader>
<CardContent className="flex flex-col gap-3">
{group.rows.map((row, index) => (
<div key={row.id} className="flex items-center gap-3">
<div key={row.id} className={cn("flex items-center gap-3 rounded-md px-3 py-2", index % 2 === 0 ? "bg-muted/60" : "bg-muted/20")}>
<div className="flex -space-x-3">
<div
className={cn(
@@ -342,12 +340,13 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) {
</div>
<div className="min-w-0 flex-1">
<p className="font-medium">
{index + 1}. {row.first.name}
{artifactTierCount(row.second?.name ?? null)}{" "}
{row.first.name}
{row.second ? ` + ${row.second.name}` : ""}
</p>
{row.note ? <p className="whitespace-pre-wrap text-sm text-muted-foreground">{row.note}</p> : null}
</div>
<p className="text-2xl font-semibold">
<p className="text-2xl font-semibold text-primary">
{metric(row.percent)}
</p>
</div>
@@ -423,6 +422,7 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) {
</div>
<Badge variant="secondary">{talent.level ? `Lv.${talent.level}+` : "—"}</Badge>
<p className="text-xs font-medium sm:text-sm">{talentName}</p>
<p className="text-[11px] text-muted-foreground sm:text-xs">{talentTypeLabel(talent.type)}</p>
</div>
{index < talents.length - 1 ? (
<span className="mt-4 text-xl font-semibold sm:mt-6 sm:text-3xl" aria-label={talent.relationToNext === "gte" ? "มากกว่าหรือเท่ากับ" : "มากกว่า"}>
@@ -521,129 +521,7 @@ function TeamSection({ guide }: { guide: PublicGuide }) {
<p className="text-sm font-medium text-primary">คำนวณความเสียหาย</p>
<h1 className="font-heading text-4xl font-semibold">Team Calc</h1>
</header>
<div className="grid gap-5 xl:grid-cols-2">
{guide.teams.map((team) => {
const total = team.members.reduce(
(sum, member) => sum + Number(member.damage),
0,
);
const seconds = Number(team.rotationSeconds);
return (
<Card key={team.id}>
<CardHeader>
<CardTitle>{team.title}</CardTitle>
<CardDescription>{seconds}s rotation</CardDescription>
<CardAction>
<a
href={`/api/teams/${team.id}/image`}
download
className={buttonVariants({
variant: "outline",
size: "sm",
})}
>
<DownloadIcon data-icon="inline-start" />
Export image
</a>
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{team.members.map((member) => (
<div key={member.position} className="min-w-0 text-center">
<div className="grid grid-cols-3 gap-2">
<div className={cn("relative col-span-2 row-span-2 min-h-0 overflow-hidden rounded-lg bg-muted", rarityGradientClass(isCustomTravelerKey(member.character.key) ? 5 : member.character.rarity))}>
<Image
src={member.character.imageUrl}
alt={member.character.name}
fill
className="object-contain"
/>
</div>
<div className="col-span-1 row-span-2 flex min-w-0 flex-col gap-2">
{member.weapon ? (
<div
className={cn(
"relative aspect-square overflow-hidden rounded-md bg-muted",
rarityGradientClass(member.weapon.rarity),
)}
>
<Image
src={member.weapon.imageUrl}
alt={member.weapon.name}
fill
className="object-contain p-1"
/>
<Kbd className="absolute top-0 left-0">
R{member.refinement}
</Kbd>
</div>
) : (
<div className="aspect-square rounded-md border bg-muted" />
)}
{member.artifact ? (
<div
className={cn(
"relative aspect-square overflow-hidden rounded-md bg-muted",
rarityGradientClass(member.artifact.rarity),
)}
>
<Image
src={member.artifact.imageUrl}
alt={member.artifact.name}
fill
className="object-contain p-1"
/>
</div>
) : (
<div className="aspect-square rounded-md border bg-muted" />
)}
</div>
</div>
<p className="mt-2 truncate text-sm font-medium">
C{member.constellation} {member.character.name}
</p>
<p className="text-sm font-semibold">
{Number(member.damage).toLocaleString(undefined, {
maximumFractionDigits: 2,
})}
</p>
<p className="text-xs text-muted-foreground">
{total
? ((Number(member.damage) / total) * 100).toFixed(2)
: "0.00"}
%
</p>
</div>
))}
</div>
<Separator />
<div className="flex items-end justify-between">
<div>
<p className="text-sm text-muted-foreground">
Total damage
</p>
<p className="font-semibold">{total.toLocaleString()}</p>
</div>
<div className="text-right">
<p className="text-sm text-muted-foreground">Total DPS</p>
<p className="text-3xl font-semibold text-primary">
{seconds > 0
? (total / seconds).toLocaleString(undefined, {
maximumFractionDigits: 0,
})
: "0"}
</p>
</div>
</div>
<p className="rounded-lg bg-muted p-3 text-sm leading-6">
{team.combo}
</p>
</CardContent>
</Card>
);
})}
</div>
<TeamSearch teams={guide.teams} />
</section>
);
}
@@ -659,7 +537,7 @@ function ExtraSection({ extra }: { extra: PublicGuide["extras"][number] }) {
{extra.rows.map((row) => (
<Card key={row.id} className="overflow-hidden py-0">
{row.imageUrl ? (
<div className="relative aspect-video bg-muted">
<div className={cn("relative bg-muted", row.ratio === "1:1" ? "aspect-square" : "aspect-video")}>
<Image
src={row.imageUrl}
alt={row.heading}
+181
View File
@@ -0,0 +1,181 @@
"use client";
import Image from "next/image";
import { DownloadIcon, SearchIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Kbd } from "@/components/ui/kbd";
import { Separator } from "@/components/ui/separator";
import { isCustomTravelerKey } from "@/lib/catalog/travelers";
import { rarityGradientClass } from "@/lib/catalog/rarity";
import type { PublicGuide } from "@/lib/guides/queries";
import { cn } from "@/lib/utils";
export function TeamSearch({ teams }: { teams: PublicGuide["teams"] }) {
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLocaleLowerCase();
const filteredTeams = useMemo(
() => teams.filter((team) => {
if (!normalizedQuery) return true;
const searchableNames = [
team.title,
...team.members.flatMap((member) => [
member.character.name,
member.weapon?.name,
member.artifact?.name,
]),
].filter((name): name is string => Boolean(name));
return searchableNames.some((name) => name.toLocaleLowerCase().includes(normalizedQuery));
}),
[normalizedQuery, teams],
);
return (
<div className="flex flex-col gap-4">
<div className="relative">
<SearchIcon aria-hidden="true" className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label="Search teams, characters, weapons, or artifacts"
className="pl-9"
placeholder="Search teams, characters, weapons, or artifacts"
value={query}
onValueChange={setQuery}
/>
</div>
{filteredTeams.length ? (
<div className="grid gap-5 xl:grid-cols-2">
{filteredTeams.map((team) => {
const total = team.members.reduce(
(sum, member) => sum + Number(member.damage),
0,
);
const seconds = Number(team.rotationSeconds);
return (
<Card key={team.id}>
<CardHeader>
<CardTitle>{team.title}</CardTitle>
<CardDescription>{seconds}s rotation</CardDescription>
<CardAction>
<a
href={`/api/teams/${team.id}/image`}
download
className={buttonVariants({
variant: "outline",
size: "sm",
})}
>
<DownloadIcon data-icon="inline-start" />
Export image
</a>
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-4 gap-1 sm:gap-3">
{team.members.map((member) => (
<div key={member.position} className="min-w-0 text-center">
<div className="grid grid-cols-3 gap-2">
<div className={cn("relative col-span-2 row-span-2 min-h-0 overflow-hidden rounded-lg bg-muted", rarityGradientClass(isCustomTravelerKey(member.character.key) ? 5 : member.character.rarity))}>
<Image
src={member.character.imageUrl}
alt={member.character.name}
fill
className="object-contain"
/>
</div>
<div className="col-span-1 row-span-2 flex min-w-0 flex-col gap-2">
{member.weapon ? (
<div
className={cn(
"relative aspect-square overflow-hidden rounded-md bg-muted",
rarityGradientClass(member.weapon.rarity),
)}
>
<Image
src={member.weapon.imageUrl}
alt={member.weapon.name}
fill
className="object-contain p-1"
/>
<Kbd className="absolute top-0 left-0 bg-transparent text-black">
R{member.refinement}
</Kbd>
</div>
) : (
<div className="aspect-square rounded-md border bg-muted" />
)}
{member.artifact ? (
<div
className={cn(
"relative aspect-square overflow-hidden rounded-md bg-muted",
rarityGradientClass(member.artifact.rarity),
)}
>
<Image
src={member.artifact.imageUrl}
alt={member.artifact.name}
fill
className="object-contain p-1"
/>
</div>
) : (
<div className="aspect-square rounded-md border bg-muted" />
)}
</div>
</div>
<p className="mt-2 truncate text-sm font-medium">
C{member.constellation} {member.character.name}
</p>
<p className="text-sm font-semibold">
{Number(member.damage).toLocaleString(undefined, {
maximumFractionDigits: 2,
})}
</p>
<p className="text-xs text-muted-foreground">
{total
? ((Number(member.damage) / total) * 100).toFixed(2)
: "0.00"}
%
</p>
</div>
))}
</div>
<Separator />
<div className="flex items-end justify-between">
<div>
<p className="text-sm text-muted-foreground">
Total damage
</p>
<p className="font-semibold">{total.toLocaleString()}</p>
</div>
<div className="text-right">
<p className="text-sm text-muted-foreground">Total DPS</p>
<p className="text-3xl font-semibold text-primary">
{seconds > 0
? (total / seconds).toLocaleString(undefined, {
maximumFractionDigits: 0,
})
: "0"}
</p>
</div>
</div>
<p className="rounded-lg bg-muted p-3 text-sm leading-6">
{team.combo}
</p>
</CardContent>
</Card>
);
})}
</div>
) : (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No teams match your search.
</CardContent>
</Card>
)}
</div>
);
}
+6 -1
View File
@@ -3,10 +3,15 @@ import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type InputProps = React.ComponentProps<"input"> & {
onValueChange?: (value: string) => void
}
function Input({ className, type, onValueChange, ...props }: InputProps) {
return (
<InputPrimitive
type={type}
onValueChange={onValueChange}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",