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