feat : buzz feedback
CI / Verify (push) Successful in 1m5s
CI / Build immutable images and deploy (push) Successful in 1m41s

This commit is contained in:
2026-08-30 15:11:35 +00:00 Unverified
parent b2e9b69619
commit 14a98c55f1
13 changed files with 418 additions and 96 deletions
+9 -5
View File
@@ -1,3 +1,4 @@
import Image from "next/image";
import Link from "next/link";
import { ChevronLeftIcon, ScrollTextIcon } from "lucide-react";
@@ -6,6 +7,7 @@ import {
PublicBlockRenderer,
} from "@/components/public/block-renderer";
import { SectionDrawer } from "@/components/public/section-drawer";
import { ScrollableNav } from "@/components/public/scrollable-nav";
import { SiteHeader } from "@/components/public/site-header";
import { LiveRefresh } from "@/components/live-refresh";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@@ -13,6 +15,7 @@ import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import type { PageSnapshot } from "@/lib/content/types";
import { cn } from "@/lib/utils";
import { navigationIconPath } from "@/lib/navigation";
export function GuideShell({ snapshot }: { snapshot: PageSnapshot }) {
const sections = collectGuideSections(snapshot.blocks);
@@ -46,9 +49,9 @@ export function GuideShell({ snapshot }: { snapshot: PageSnapshot }) {
</Badge>
) : null}
</div>
<nav
aria-label={`หน้าคู่มือ ${snapshot.character.nameTh}`}
className="flex min-h-11 gap-1 overflow-x-auto border-t [scrollbar-width:none]"
<ScrollableNav
ariaLabel={`หน้าคู่มือ ${snapshot.character.nameTh}`}
className="min-h-11 border-t"
>
{snapshot.pages.map((page) => (
<Link
@@ -56,15 +59,16 @@ export function GuideShell({ snapshot }: { snapshot: PageSnapshot }) {
href={`/${snapshot.character.slug}/${page.slug}`}
aria-current={page.id === snapshot.page.id ? "page" : undefined}
className={cn(
"relative flex min-h-11 shrink-0 items-center rounded-md px-3 text-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
"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",
)}
>
{navigationIconPath(page.slug) ? <Image src={navigationIconPath(page.slug)!} alt="" width={20} height={20} className="size-5 object-contain" /> : null}
{page.title}
</Link>
))}
</nav>
</ScrollableNav>
</div>
</div>
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function ScrollableNav({
ariaLabel,
children,
className,
}: {
ariaLabel: string;
children: ReactNode;
className?: string;
}) {
const navRef = useRef<HTMLElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const updateScrollState = useCallback(() => {
const nav = navRef.current;
if (!nav) return;
setCanScrollLeft(nav.scrollLeft > 0);
setCanScrollRight(nav.scrollLeft + nav.clientWidth < nav.scrollWidth - 1);
}, []);
useEffect(() => {
const nav = navRef.current;
if (!nav) return;
updateScrollState();
nav.addEventListener("scroll", updateScrollState, { passive: true });
window.addEventListener("resize", updateScrollState);
return () => {
nav.removeEventListener("scroll", updateScrollState);
window.removeEventListener("resize", updateScrollState);
};
}, [updateScrollState]);
const scroll = (direction: -1 | 1) => {
navRef.current?.scrollBy({ left: direction * navRef.current.clientWidth * 0.75, behavior: "smooth" });
};
return (
<div className={cn("flex items-center gap-1", className)}>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0 sm:hidden"
disabled={!canScrollLeft}
onClick={() => scroll(-1)}
aria-label="เลื่อนเมนูไปทางซ้าย"
>
<ChevronLeftIcon />
</Button>
<nav
ref={navRef}
aria-label={ariaLabel}
className="flex min-w-0 flex-1 gap-1 overflow-x-auto [scrollbar-width:none]"
>
{children}
</nav>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0 sm:hidden"
disabled={!canScrollRight}
onClick={() => scroll(1)}
aria-label="เลื่อนเมนูไปทางขวา"
>
<ChevronRightIcon />
</Button>
</div>
);
}
+56 -28
View File
@@ -15,11 +15,14 @@ import {
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 { 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 { navigationIconPath } from "@/lib/navigation";
import { normalizeTargetStat, statPriorityColorClass, targetStatIconName } from "@/lib/guides/stats";
import { normalizeTalentPriorities, talentRelationSymbol } from "@/lib/guides/talents";
import { cn } from "@/lib/utils";
@@ -87,7 +90,10 @@ export function StructuredGuideShell({
) : null}
</div>
</div>
<nav className="mx-auto flex min-h-11 w-full max-w-7xl gap-1 overflow-x-auto px-4 sm:px-6">
<ScrollableNav
ariaLabel={`เมนูคู่มือ ${guide.name}`}
className="mx-auto min-h-11 w-full max-w-7xl px-4 sm:px-6"
>
<Link
href={`/${guide.slug}`}
className={cn(
@@ -102,14 +108,15 @@ export function StructuredGuideShell({
key={item.id}
href={`/${guide.slug}/${item.slug}`}
className={cn(
"flex shrink-0 items-center whitespace-nowrap rounded-md px-3 text-sm text-muted-foreground",
"flex min-h-8 shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm text-muted-foreground",
section === item.slug && "bg-muted text-foreground",
)}
>
{navigationIconPath(item.slug) ? <Image src={navigationIconPath(item.slug)!} alt="" width={20} height={20} className="size-5 object-contain" /> : null}
{item.title}
</Link>
))}
</nav>
</ScrollableNav>
</div>
<main className="mx-auto w-full max-w-7xl p-4 sm:p-6 lg:p-10">
{section ? (
@@ -364,7 +371,12 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) {
{profile.substats.length ? (
<Card>
<CardContent className="py-6 text-center text-lg">
<strong>Stat Priority:</strong> {profile.substats.map(statLabel).join(" > ")}
<strong>Stat Priority:</strong>{" "}
{profile.substats.map((stat, index) => (
<span key={`${stat}-${index}`} className={statPriorityColorClass(index)}>
{index ? " > " : ""}{statLabel(stat)}
</span>
))}
</CardContent>
</Card>
) : null}
@@ -374,15 +386,23 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) {
{profile.targets.length ? (
<div>
<p className="mb-2 text-sm font-medium">Target Stats</p>
{profile.targets.map((target) => (
<div className="flex flex-col gap-1">
{profile.targets.map((target, index) => (
<div
key={`${target.stat}-${target.value}`}
className="flex justify-between py-1 text-sm"
className={cn(
"flex items-center justify-between gap-4 rounded-md px-3 py-2 text-sm",
index % 2 === 0 ? "bg-muted/60" : "bg-muted/20",
)}
>
<span>{target.stat}</span>
<strong>{target.value}</strong>
<span className="flex min-w-0 items-center gap-2">
{targetStatIconName(target.stat) ? <Image src={`/icon/stats/${targetStatIconName(target.stat)}.webp`} alt="" width={28} height={28} className="size-7 shrink-0 object-contain" /> : null}
<span>{normalizeTargetStat(target.stat) ?? target.stat}</span>
</span>
<strong className="shrink-0">{target.value}</strong>
</div>
))}
</div>
</div>
) : null}
{profile.talents.length ? (
@@ -440,7 +460,15 @@ function ArtifactStat({ iconSrc, label, values }: { iconSrc: string; label: stri
<Image src={iconSrc} alt="" width={48} height={48} className="size-12 object-contain" />
<p className="font-semibold">{label}</p>
<Separator />
<p className="text-sm leading-6 sm:text-base">{values.length ? values.map(statLabel).join(" > ") : "—"}</p>
<div className="flex max-w-full flex-col items-center gap-1 text-sm leading-6 sm:text-base">
{values.length
? values.map((value, index) => (
<span key={`${value}-${index}`} className={cn("max-w-full overflow-x-auto whitespace-nowrap", statPriorityColorClass(index))}>
{statLabel(value)}
</span>
))
: <span>—</span>}
</div>
</div>
);
}
@@ -455,24 +483,24 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
<div className="grid gap-4 md:grid-cols-2">
{guide.constellations.map((row) => (
<Card key={row.level}>
<CardHeader>
<CardTitle className={cn("text-3xl text-white", row.isGreat && "text-warning")}>
{metric(row.cumulativePercent)}
</CardTitle>
<CardDescription className="text-base font-medium text-white">
C{row.level} · {row.name}
</CardDescription>
<CardHeader className="flex flex-row items-center gap-4">
{row.iconUrl ? (
<CardAction>
<Image
src={row.iconUrl}
alt=""
width={48}
height={48}
className="rounded-lg bg-muted"
/>
</CardAction>
<Image
src={row.iconUrl}
alt=""
width={64}
height={64}
className="size-16 shrink-0 rounded-lg bg-muted object-contain"
/>
) : null}
<div className="min-w-0">
<CardTitle className={cn("text-3xl text-white", row.isGreat && "text-warning")}>
{metric(row.cumulativePercent)}
</CardTitle>
<CardDescription className="text-base font-medium text-white">
C{row.level} · {row.name}
</CardDescription>
</div>
</CardHeader>
<CardContent>
<p className="whitespace-pre-line leading-7">{row.detail}</p>
@@ -542,9 +570,9 @@ function TeamSection({ guide }: { guide: PublicGuide }) {
src={member.weapon.imageUrl}
alt={member.weapon.name}
fill
className="object-contain"
className="object-contain p-1"
/>
<Kbd className="absolute top-0 right-0">
<Kbd className="absolute top-0 left-0">
R{member.refinement}
</Kbd>
</div>
@@ -562,7 +590,7 @@ function TeamSection({ guide }: { guide: PublicGuide }) {
src={member.artifact.imageUrl}
alt={member.artifact.name}
fill
className="object-contain"
className="object-contain p-1"
/>
</div>
) : (