feat : update
CI / Verify (push) Successful in 3m8s
CI / Build immutable images and deploy (push) Successful in 3m11s

This commit is contained in:
2026-10-05 22:40:59 +07:00 Unverified
parent 7729068219
commit d6153be50d
19 changed files with 304 additions and 84 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ export function ConditionSwitcher({
if (items.length <= 1) return items[0] ?? null;
return (
<Tabs className="min-w-0 gap-4" value={active} onValueChange={setActive}>
<Tabs className="w-full min-w-0 gap-4" value={active} onValueChange={setActive}>
<ScrollableNav ariaLabel={label} className="w-full min-w-0">
<TabsList aria-label={label} className="mx-auto shrink-0">
{items.map((_, index) => (
+13 -11
View File
@@ -1,22 +1,24 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Card, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
export function DirectorySkeleton() {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }, (_, index) => (
<Card key={index} className="gap-0 py-0">
<Skeleton className="aspect-4/3 rounded-b-none" />
<CardHeader className="pt-4">
<div className="flex flex-col gap-6" aria-hidden="true">
<div className="flex flex-col gap-2">
<Skeleton className="h-9 w-full max-w-80" />
<Skeleton className="h-9 w-full sm:max-w-lg" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }, (_, index) => (
<Card key={index} className="h-24 flex-row gap-0 py-0 sm:h-auto sm:flex-col">
<Skeleton className="size-24 shrink-0 rounded-none sm:aspect-square sm:size-auto" />
<CardHeader className="min-w-0 flex-1 justify-center p-3 sm:justify-start sm:pt-5">
<Skeleton className="h-5 w-2/3" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-4/5" />
</CardHeader>
<CardContent className="flex gap-2 pb-4">
<Skeleton className="h-5 w-14" />
<Skeleton className="h-5 w-20" />
</CardContent>
</Card>
))}
</div>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import { usePathname } from "next/navigation";
import { BookOpenIcon, MenuIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
const destinations = [
{ href: "/", label: "หน้ารวม", icon: null },
{ href: "/commission", label: "Commission", icon: "/icon/commission.webp" },
{ href: "/stygian", label: "Stygian Guide", icon: "/icon/stygian.webp" },
] as const;
export function PublicNavigation() {
const pathname = usePathname();
const isCurrent = (href: string) => href === "/" ? pathname === "/" : pathname.startsWith(href);
return <>
<nav aria-label="เมนูหลัก" className="hidden items-center gap-1 lg:flex">
{destinations.slice(1).map(({ href, label, icon }) =>
<Button key={href} variant="ghost" size="sm" nativeButton={false}
render={<Link href={href} aria-current={isCurrent(href) ? "page" : undefined} />}>
{icon ? <Image src={icon} alt="" width={24} height={24} className="size-6 object-contain" /> : null}{label}
</Button>)}
</nav>
<div className="lg:hidden">
<Drawer>
<DrawerTrigger render={<Button type="button" variant="outline" size="sm" aria-label="เปิดเมนูหลัก" />}>
<MenuIcon data-icon="inline-start" aria-hidden="true" />เมนู
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>เมนูหลัก</DrawerTitle>
<DrawerDescription>เลือกหน้าที่ต้องการดู</DrawerDescription>
</DrawerHeader>
<nav aria-label="เมนูหลักบนมือถือ" className="grid gap-1 p-4">
{destinations.map(({ href, label, icon }) =>
<DrawerClose key={href} render={<Link href={href} aria-current={isCurrent(href) ? "page" : undefined}
className="flex min-h-11 items-center gap-3 rounded-lg px-3 text-sm hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" />}>
{icon ? <Image src={icon} alt="" width={20} height={20} className="size-5 object-contain" /> : <BookOpenIcon aria-hidden="true" />}{label}
{isCurrent(href) ? <span className="ml-auto text-xs text-primary">ปัจจุบัน</span> : null}
</DrawerClose>)}
</nav>
</DrawerContent>
</Drawer>
</div>
</>;
}
+5 -19
View File
@@ -5,6 +5,7 @@ import { PencilIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { VisitorPresence } from "@/components/public/visitor-presence";
import { PublicNavigation } from "@/components/public/public-navigation";
import { AccountMenu } from "@/components/auth/account-menu";
import { getAdminSession } from "@/lib/auth/server";
@@ -31,25 +32,10 @@ export function SiteHeader({ editHref = "/admin", guideId }: { editHref?: string
<Image src="/icon.png" alt="" width={36} height={36} className="size-9 object-contain" />
<span className="hidden lg:inline">ไกด์เกนชินไม่ใช่เกมมือถือ</span>
</Link>
<nav aria-label="Public navigation" className="flex min-w-0 items-center gap-1 overflow-x-auto lg:overflow-visible">
<Button variant="ghost" size="sm" nativeButton={false} render={<Link href="/commission" />} aria-label="Commission">
<Image src="/icon/commission.webp" alt="" width={24} height={24} className="size-6 object-contain" />
<span className="hidden lg:inline">Commission</span>
</Button>
<Button
variant="ghost"
size="sm"
nativeButton={false}
render={<Link href="/stygian" />}
aria-label="Stygian Guide"
>
<Image src="/icon/stygian.webp" alt="" width={24} height={24} className="size-6 object-contain" />
<span className="hidden lg:inline">Stygian Guide</span>
</Button>
<Suspense fallback={null}>
<AdminEditLink editHref={editHref} />
</Suspense>
</nav>
<PublicNavigation />
<Suspense fallback={null}>
<AdminEditLink editHref={editHref} />
</Suspense>
<div className="hidden lg:block">
<VisitorPresence guideId={guideId} />
</div>
+70 -23
View File
@@ -5,7 +5,7 @@ import { searchItems } from "@/lib/search";
import Image from "next/image";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { SearchIcon, StarIcon, UsersIcon } from "lucide-react";
import { SearchIcon, SlidersHorizontalIcon, StarIcon, UsersIcon, XIcon } from "lucide-react";
import { useMemo, type ReactNode } from "react";
import { ElementIcon } from "@/components/public/element-icon";
@@ -18,6 +18,8 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, DrawerTrigger } from "@/components/ui/drawer";
import {
Empty,
EmptyDescription,
@@ -112,6 +114,11 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
return sort === "name-asc" ? order : -order;
})
: search.items;
const activeFilters = [
...selectedElements.map((value) => ({ name: "element", value, label: elementLabels[value] ?? value })),
...selectedWeapons.map((value) => ({ name: "weapon", value, label: weaponTypeLabels[value] ?? value })),
...selectedRarities.map((value) => ({ name: "rarity", value, label: `${value} ดาว` })),
];
function setParam(name: string, value: string) {
const next = new URLSearchParams(searchParams.toString());
@@ -120,11 +127,29 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
const suffix = next.toString();
window.history.replaceState(null, "", suffix ? `?${suffix}` : window.location.pathname);
}
function clearParams(includeSearch = false) {
const next = new URLSearchParams(searchParams.toString());
for (const name of ["element", "weapon", "rarity", ...(includeSearch ? ["q"] : [])]) next.delete(name);
const suffix = next.toString();
window.history.replaceState(null, "", suffix ? `?${suffix}` : window.location.pathname);
}
function removeFilter(name: string, value: string) {
const values = name === "element" ? selectedElements : name === "weapon" ? selectedWeapons : selectedRarities;
setParam(name, values.filter((item) => item !== value).join(","));
}
const filterControls = (mobile: boolean) => <>
<GuideToggleFilter label="ธาตุ" value={selectedElements} options={elements} optionLabels={elementLabels} showLabels={mobile} 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} showLabels={mobile} 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)} showLabels={mobile} optionIcon={() => <StarIcon className="size-4 fill-current text-yellow-500" />} optionText={(option) => option} onChange={(values) => setParam("rarity", values.join(","))} />
</>;
return (
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<InputGroup className="h-9 w-48 shrink-0">
<InputGroup className="h-9 min-w-0 flex-1 sm:w-48 sm:flex-none">
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
@@ -136,7 +161,7 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
/>
</InputGroup>
<Select value={sort} onValueChange={(next) => next && setParam("sort", next)}>
<SelectTrigger aria-label="เรียงตาม"><SelectValue>{sortOptions.find((option) => option.value === sort)?.label}</SelectValue></SelectTrigger>
<SelectTrigger aria-label="เรียงตาม" className="w-32 sm:w-auto"><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>)}
@@ -144,13 +169,29 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
</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 className="sm:hidden">
<Drawer>
<DrawerTrigger render={<Button variant="outline" className="w-full" />}>
<SlidersHorizontalIcon data-icon="inline-start" />ตัวกรอง{activeFilters.length ? ` (${activeFilters.length})` : ""}
</DrawerTrigger>
<DrawerContent>
<DrawerHeader><DrawerTitle>ตัวกรองไกด์</DrawerTitle><DrawerDescription>เลือกธาตุ อาวุธ หรือจำนวนดาว</DrawerDescription></DrawerHeader>
<div className="flex max-h-[60svh] flex-col gap-5 overflow-y-auto px-4 pb-4">{filterControls(true)}</div>
<div className="flex gap-2 border-t p-4">
<Button variant="outline" disabled={!activeFilters.length} onClick={() => clearParams()}>ล้างตัวกรอง</Button>
<DrawerClose render={<Button className="flex-1" />}>ดู {filtered.length} ไกด์</DrawerClose>
</div>
</DrawerContent>
</Drawer>
</div>
<div className="hidden min-w-0 items-center gap-2 overflow-x-auto pb-1 sm:flex" aria-label="ตัวกรองไกด์">{filterControls(false)}</div>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground" aria-live="polite">
<span>พบ {filtered.length} ไกด์</span>
{activeFilters.map((filter) => <Button key={`${filter.name}-${filter.value}`} variant="secondary" size="xs"
onClick={() => removeFilter(filter.name, filter.value)} aria-label={`ลบตัวกรอง ${filter.label}`}>
{filter.label}<XIcon data-icon="inline-end" />
</Button>)}
{activeFilters.length ? <Button variant="link" size="sm" onClick={() => clearParams()}>ล้างตัวกรอง</Button> : null}
</div>
</div>
@@ -163,30 +204,32 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
prefetch={false}
className="group rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Card className="h-full overflow-hidden py-0 transition-transform group-hover:-translate-y-0.5">
<div className="relative aspect-square bg-muted">
<Card className="h-full flex-row gap-0 overflow-hidden py-0 transition-transform group-hover:-translate-y-0.5 sm:flex-col sm:gap-4">
<div className="relative size-24 shrink-0 bg-muted sm:aspect-square sm:size-auto">
<Image
src={guide.coverUrl}
alt={`ไกด์ ${guide.name}`}
fill
fetchPriority={index === 0 ? "high" : undefined}
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 96px"
className="object-cover"
/>
</div>
<CardHeader className="pt-5">
<CardTitle>{guide.name}</CardTitle>
<CardHeader className="min-w-0 flex-1 p-3 sm:p-4 sm:pt-5">
<div className="min-w-0 space-y-1">
<CardTitle className="line-clamp-1">{guide.name}</CardTitle>
<CardDescription className="line-clamp-2 sm:line-clamp-3">
{guide.overview}
</CardDescription>
</div>
<CardAction
className={(presence?.byGuide[guide.id] ?? 0) > 0 ? undefined : "invisible"}
className={(presence?.byGuide[guide.id] ?? 0) > 0 ? "hidden sm:block" : "hidden sm:block sm:invisible"}
>
<Badge variant="secondary" className="min-w-14 tabular-nums">
<UsersIcon data-icon="inline-start" />
{presence?.byGuide[guide.id] ?? 0} คน
</Badge>
</CardAction>
<CardDescription className="line-clamp-3">
{guide.overview}
</CardDescription>
</CardHeader>
</Card>
</Link>
@@ -196,8 +239,9 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
<Empty className="border">
<EmptyHeader>
<EmptyTitle>ไม่พบไกด์</EmptyTitle>
<EmptyDescription>ลองค้นหาด้วยชื่อตัวละครอื่น</EmptyDescription>
<EmptyDescription>ลองค้นหาด้วยชื่อตัวละครอื่น หรือล้างตัวกรอง</EmptyDescription>
</EmptyHeader>
<Button variant="outline" onClick={() => clearParams(true)}>ล้างการค้นหาและตัวกรอง</Button>
</Empty>
)}
</div>
@@ -211,6 +255,7 @@ function GuideToggleFilter({
optionLabels = {},
optionIcon,
optionText,
showLabels = false,
onChange,
}: {
label: string;
@@ -219,14 +264,16 @@ function GuideToggleFilter({
optionLabels?: Record<string, string>;
optionIcon?: (value: string) => ReactNode;
optionText?: (value: string) => string;
showLabels?: boolean;
onChange: (value: string[]) => void;
}) {
return (
<div className="shrink-0 border-l pl-2">
<ToggleGroup aria-label={label} variant="outline" size="sm" value={value} onValueChange={onChange}>
<div className={showLabels ? "flex flex-col gap-2" : "shrink-0 border-l pl-2"}>
{showLabels ? <p className="text-sm font-medium">{label}</p> : null}
<ToggleGroup aria-label={label} variant="outline" size="sm" multiple value={value} onValueChange={onChange} className={showLabels ? "flex flex-wrap justify-start" : undefined}>
{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)}
{optionIcon?.(option)}{showLabels ? optionLabels[option] ?? option : optionText?.(option)}
</ToggleGroupItem>
))}
</ToggleGroup>
+5 -5
View File
@@ -713,7 +713,7 @@ function ArtifactSectionContent({
return (
<section
className={cn(
"artifact-export-root flex flex-col gap-6",
"artifact-export-root flex min-w-0 flex-col gap-6",
exporting &&
"relative isolate overflow-hidden bg-[#070b20] p-10 text-white",
)}
@@ -736,12 +736,12 @@ function ArtifactSectionContent({
height={56}
loading={exporting ? "eager" : "lazy"}
className={cn(
"size-14 shrink-0 object-contain",
exporting ? "size-14 shrink-0 object-contain" : "size-10 shrink-0 object-contain sm:size-14",
exporting &&
"drop-shadow-[0_4px_3px_rgba(0,0,0,0.8)]",
)}
/>
<h1 className={cn("whitespace-nowrap text-4xl font-semibold", !exporting && "font-heading", exporting && "drop-shadow-[0_3px_2px_rgba(0,0,0,0.9)]")}>
<h1 className={cn("whitespace-nowrap font-semibold", exporting ? "text-4xl" : "font-heading text-xl sm:text-4xl", exporting && "drop-shadow-[0_3px_2px_rgba(0,0,0,0.9)]")}>
Artifacts & Stats
</h1>
</div>
@@ -750,12 +750,12 @@ function ArtifactSectionContent({
</header>
<div
className={cn(
"artifact-export-grid relative z-10 grid gap-5",
"artifact-export-grid relative z-10 grid min-w-0 gap-5",
exporting ? "grid-cols-[1.2fr_1fr]" : "lg:grid-cols-[1.2fr_1fr]",
)}
>
{" "}
<div className="artifact-export-left flex flex-col gap-5">
<div className="artifact-export-left flex min-w-0 flex-col gap-5">
{exporting ? recommendedCards : (
<ConditionSwitcher label="เลือกเงื่อนไขค่าสถานะแนะนำ" labels={recommendedConditions.map((condition, index) => condition.name || `เงื่อนไข ${index + 1}`)} iconUrls={recommendedConditions.map((condition) => condition.iconUrl)}>
{recommendedCards}
+2 -2
View File
@@ -109,12 +109,12 @@ export function StygianGuide({ schedule, catalog }: { schedule: Schedule; catalo
{([105, 110] as const).map((level) => (
<TabsContent key={level} value={String(level)}>
<Tabs defaultValue="0">
<TabsList variant="line" className="grid w-full grid-cols-3 overflow-hidden group-data-horizontal/tabs:h-auto group-data-horizontal/tabs:min-h-96" aria-label={`Level ${level} bosses`}>
<TabsList variant="line" className="grid w-full grid-cols-3 overflow-hidden group-data-horizontal/tabs:h-auto" aria-label={`Level ${level} bosses`}>
{[0, 1, 2].map((position) => {
const boss = schedule.bosses.find((candidate) => candidate.level === level && candidate.position === position);
const label = boss?.name ?? `Boss ${position + 1}`;
const positionLabel = ["บน", "กลาง", "ล่าง"][position];
return <TabsTrigger key={position} value={String(position)} aria-label={`${positionLabel}: ${label}`} title={label} className="h-full min-w-0 flex-col justify-start overflow-hidden rounded-none px-2 py-3 data-active:bg-accent/50 data-active:ring-2 data-active:ring-primary data-active:ring-inset">
return <TabsTrigger key={position} value={String(position)} aria-label={`${positionLabel}: ${label}`} title={label} className="h-full min-w-0 flex-col justify-start overflow-hidden rounded-none px-1 py-2 data-active:bg-accent/50 data-active:ring-2 data-active:ring-primary data-active:ring-inset sm:px-2 sm:py-3">
<StygianBossTab boss={boss} position={position} priority={level === 110} />
</TabsTrigger>;
})}