feat : buzz feedback
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user