feat : buzz feedback
This commit is contained in:
@@ -7,6 +7,7 @@ import sharp from "sharp";
|
||||
import { rarityGradient } from "@/lib/catalog/rarity";
|
||||
import { imageContentDisposition } from "@/lib/guides/export-image";
|
||||
import { getPublicArtifactExport } from "@/lib/guides/queries";
|
||||
import { normalizeTargetStat, statPriorityColor, targetStatIconName, targetStatOptions } from "@/lib/guides/stats";
|
||||
import { normalizeTalentPriorities } from "@/lib/guides/talents";
|
||||
|
||||
const anuphanFonts = Promise.all(
|
||||
@@ -55,9 +56,9 @@ async function pngDataUrl(url: string): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
async function localPngDataUrl(fileName: string): Promise<string> {
|
||||
const png = await sharp(join(process.cwd(), "public", "icon", fileName))
|
||||
.resize(96, 96, { fit: "contain" })
|
||||
async function localPngDataUrl(fileName: string, size: number | null = 96): Promise<string> {
|
||||
const image = sharp(join(process.cwd(), "public", "icon", fileName));
|
||||
const png = await (size === null ? image : image.resize(size, size, { fit: "contain" }))
|
||||
.png()
|
||||
.toBuffer();
|
||||
return `data:image/png;base64,${png.toString("base64")}`;
|
||||
@@ -72,7 +73,7 @@ export async function GET(
|
||||
if (!guide) return new Response("Guide not found", { status: 404 });
|
||||
const talents = normalizeTalentPriorities(guide.profile?.talents ?? []);
|
||||
|
||||
const [fonts, groupAssets, statIconAssets, talentAssets] = await Promise.all([
|
||||
const [fonts, groupAssets, statIconAssets, talentAssets, targetStatAssets, watermark] = await Promise.all([
|
||||
anuphanFonts,
|
||||
Promise.all(
|
||||
guide.groups.map((group) =>
|
||||
@@ -93,13 +94,15 @@ export async function GET(
|
||||
const catalogTalent = guide.character.talents.find((item) => item.type === talent.type);
|
||||
return catalogTalent?.iconUrl ? pngDataUrl(catalogTalent.iconUrl) : null;
|
||||
})),
|
||||
Promise.all(targetStatOptions.map((stat) => localPngDataUrl(`stats/${targetStatIconName(stat)}.webp`))),
|
||||
localPngDataUrl("Watermark.png", null),
|
||||
]);
|
||||
|
||||
const profile = guide.profile;
|
||||
const groupRowHeights = guide.groups.map((group) => group.rows.map((row) => Math.max(76, 76 + (row.note ? Math.ceil(row.note.length / 58) * 22 : 0))));
|
||||
const leftHeight = guide.groups.reduce((sum, _group, groupIndex) => sum + 68 + groupRowHeights[groupIndex].reduce((groupSum, rowHeight) => groupSum + rowHeight, 0), 0) + Math.max(0, guide.groups.length - 1) * 16;
|
||||
const detailHeight = profile
|
||||
? 60 + profile.targets.length * 30 + (profile.talents.length ? 82 : 0) + (profile.note ? Math.max(48, Math.ceil(profile.note.length / 58) * 24) : 0)
|
||||
? 60 + profile.targets.length * 40 + (profile.talents.length ? 82 : 0) + (profile.note ? Math.max(48, Math.ceil(profile.note.length / 58) * 24) : 0)
|
||||
: 0;
|
||||
const rightHeight = profile
|
||||
? 242 + (profile.substats.length ? 76 : 0) + (detailHeight > 60 ? detailHeight + 16 : 0) + 32
|
||||
@@ -118,8 +121,11 @@ export async function GET(
|
||||
background: "#05060a",
|
||||
padding: "32px 36px",
|
||||
fontFamily: "Anuphan",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<img src={watermark} alt="" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", opacity: 0.2 }} />
|
||||
<div style={{ position: "relative", zIndex: 1, display: "flex", flex: 1, flexDirection: "column", gap: 20 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 8 }}>
|
||||
<div style={{ display: "flex", color: "#f0b72f", fontSize: 16, fontWeight: 700 }}>
|
||||
อาร์ติแฟกต์และสเตตัสที่แนะนำ
|
||||
@@ -148,7 +154,7 @@ export async function GET(
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", padding: "0 20px 14px" }}>
|
||||
{group.rows.map((row, rowIndex) => (
|
||||
<div key={row.id} style={{ display: "flex", minHeight: groupRowHeights[groupIndex][rowIndex], alignItems: "center", gap: 14 }}>
|
||||
<div key={row.id} style={{ display: "flex", minHeight: groupRowHeights[groupIndex][rowIndex], alignItems: "center", gap: 14 }}>
|
||||
<div style={{ display: "flex", width: row.second ? 94 : 58 }}>
|
||||
{groupAssets[groupIndex][rowIndex].first ? (
|
||||
<div style={{ display: "flex", width: 58, height: 58, overflow: "hidden", border: "1px solid #ffffff2a", borderRadius: 10, background: rarityGradient(row.first.rarity) }}>
|
||||
@@ -193,8 +199,8 @@ export async function GET(
|
||||
<img src={stat.icon} alt="" width={48} height={48} style={{ objectFit: "contain" }} />
|
||||
<div style={{ display: "flex", fontSize: 17, fontWeight: 700 }}>{stat.label}</div>
|
||||
<div style={{ display: "flex", width: "100%", borderTop: "1px solid #34343d" }} />
|
||||
<div style={{ display: "flex", fontSize: 14, lineHeight: 1.45 }}>
|
||||
{stat.values.length ? stat.values.map(statLabel).join(" > ") : "—"}
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 4, fontSize: 14, lineHeight: 1.45 }}>
|
||||
{stat.values.length ? stat.values.map((value, index) => <span key={`${value}-${index}`} style={{ color: statPriorityColor(index), whiteSpace: "nowrap" }}>{statLabel(value)}</span>) : "—"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -202,8 +208,8 @@ export async function GET(
|
||||
</div>
|
||||
|
||||
{profile.substats.length ? (
|
||||
<div style={{ display: "flex", justifyContent: "center", border: "1px solid #34343d", borderRadius: 14, background: "#1d1d25", padding: "20px", fontSize: 17, textAlign: "center" }}>
|
||||
<strong>Stat Priority: </strong>{profile.substats.map(statLabel).join(" > ")}
|
||||
<div style={{ display: "flex", justifyContent: "center", border: "1px solid #34343d", borderRadius: 14, background: "#1d1d25", padding: "20px", fontSize: 17, textAlign: "center" }}>
|
||||
<strong>Stat Priority: </strong>{profile.substats.map((stat, index) => <span key={`${stat}-${index}`} style={{ color: statPriorityColor(index) }}>{index ? " > " : ""}{statLabel(stat)}</span>)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -213,11 +219,14 @@ export async function GET(
|
||||
{profile.targets.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 7 }}>
|
||||
<div style={{ display: "flex", color: "#b9b9c2", fontSize: 14, fontWeight: 700 }}>Target Stats</div>
|
||||
{profile.targets.map((target) => (
|
||||
<div key={`${target.stat}-${target.value}`} style={{ display: "flex", justifyContent: "space-between", fontSize: 15 }}>
|
||||
<span>{target.stat}</span><strong>{target.value}</strong>
|
||||
{profile.targets.map((target, index) => {
|
||||
const iconIndex = targetStatOptions.findIndex((stat) => stat === normalizeTargetStat(target.stat));
|
||||
return (
|
||||
<div key={`${target.stat}-${target.value}`} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, borderRadius: 6, background: index % 2 === 0 ? "#34343d" : "#272730", padding: "5px 8px", fontSize: 15 }}>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>{iconIndex >= 0 ? <img src={targetStatAssets[iconIndex]} alt="" width={28} height={28} style={{ objectFit: "contain" }} /> : null}{normalizeTargetStat(target.stat) ?? target.stat}</span><strong>{target.value}</strong>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{talents.length ? (
|
||||
@@ -245,6 +254,7 @@ export async function GET(
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
{
|
||||
width: 1200,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { join } from "node:path";
|
||||
|
||||
import { ImageResponse } from "next/og";
|
||||
import sharp from "sharp";
|
||||
|
||||
@@ -44,6 +46,14 @@ async function pngDataUrl(url: string): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
async function localPngDataUrl(fileName: string, size: number | null = 96): Promise<string> {
|
||||
const image = sharp(join(process.cwd(), "public", "icon", fileName));
|
||||
const png = await (size === null ? image : image.resize(size, size, { fit: "contain" }))
|
||||
.png()
|
||||
.toBuffer();
|
||||
return `data:image/png;base64,${png.toString("base64")}`;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
context: { params: Promise<{ guideId: string }> },
|
||||
@@ -52,9 +62,10 @@ export async function GET(
|
||||
const guide = await getPublicWeaponExport(guideId);
|
||||
if (!guide) return new Response("Guide not found", { status: 404 });
|
||||
|
||||
const [fonts, weaponImages] = await Promise.all([
|
||||
const [fonts, weaponImages, watermark] = await Promise.all([
|
||||
anuphanFonts,
|
||||
Promise.all(guide.weapons.map((row) => pngDataUrl(row.weapon.imageUrl))),
|
||||
localPngDataUrl("Watermark.png", null),
|
||||
]);
|
||||
const weaponHeights = guide.weapons.map((row) => Math.max(126, 126 + (row.note ? Math.ceil(row.note.length / 52) * 22 : 0)));
|
||||
const height = Math.max(360, 190 + weaponHeights.reduce((sum, rowHeight) => sum + rowHeight, 0) + Math.max(0, guide.weapons.length - 1) * 14);
|
||||
@@ -71,8 +82,11 @@ export async function GET(
|
||||
background: "#05060a",
|
||||
padding: "32px 36px",
|
||||
fontFamily: "Anuphan",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<img src={watermark} alt="" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", opacity: 0.2 }} />
|
||||
<div style={{ position: "relative", zIndex: 1, display: "flex", flex: 1, flexDirection: "column", gap: 14 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 8, marginBottom: 6 }}>
|
||||
<div style={{ display: "flex", color: "#f0b72f", fontSize: 16, fontWeight: 700 }}>อาวุธที่แนะนำ</div>
|
||||
<div style={{ display: "flex", fontSize: 38, fontWeight: 700 }}>Weapons</div>
|
||||
@@ -128,6 +142,7 @@ export async function GET(
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
{
|
||||
width: 1000,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { ImageResponse } from "next/og";
|
||||
import { join } from "node:path";
|
||||
import sharp from "sharp";
|
||||
|
||||
import { rarityGradient } from "@/lib/catalog/rarity";
|
||||
@@ -44,6 +45,14 @@ async function pngDataUrl(url: string): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
async function localPngDataUrl(fileName: string, size: number | null = 96): Promise<string> {
|
||||
const image = sharp(join(process.cwd(), "public", "icon", fileName));
|
||||
const png = await (size === null ? image : image.resize(size, size, { fit: "contain" }))
|
||||
.png()
|
||||
.toBuffer();
|
||||
return `data:image/png;base64,${png.toString("base64")}`;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
context: RouteContext<"/api/teams/[teamId]/image">,
|
||||
@@ -58,8 +67,10 @@ export async function GET(
|
||||
);
|
||||
const seconds = Number(team.rotationSeconds);
|
||||
const dps = seconds > 0 ? total / seconds : 0;
|
||||
const fonts = await anuphanFonts;
|
||||
const memberAssets = await Promise.all(
|
||||
const [fonts, watermark, memberAssets] = await Promise.all([
|
||||
anuphanFonts,
|
||||
localPngDataUrl("Watermark.png", null),
|
||||
Promise.all(
|
||||
team.members.map(async (member) => {
|
||||
const [character, weapon, artifact] = await Promise.all([
|
||||
pngDataUrl(member.character.imageUrl),
|
||||
@@ -68,7 +79,8 @@ export async function GET(
|
||||
]);
|
||||
return { character, weapon, artifact };
|
||||
}),
|
||||
);
|
||||
),
|
||||
]);
|
||||
|
||||
return new ImageResponse(
|
||||
<div
|
||||
@@ -83,8 +95,11 @@ export async function GET(
|
||||
border: "2px solid #3aaaa2",
|
||||
padding: "34px 36px",
|
||||
fontFamily: "Anuphan",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<img src={watermark} alt="" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", opacity: 0.2 }} />
|
||||
<div style={{ position: "relative", zIndex: 1, display: "flex", flex: 1, flexDirection: "column" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", textAlign: "center" }}>
|
||||
<div style={{ display: "flex", justifyContent: "center", fontSize: 42, fontWeight: 700 }}>
|
||||
{team.guideName}
|
||||
@@ -117,7 +132,7 @@ export async function GET(
|
||||
{member.weapon && memberAssets[index].weapon ? (
|
||||
<div style={{ position: "relative", display: "flex", width: 50, height: 50, overflow: "hidden", borderRadius: 10, background: rarityGradient(member.weapon.rarity) }}>
|
||||
<img src={memberAssets[index].weapon} alt="" width={50} height={50} style={{ objectFit: "contain" }} />
|
||||
<div style={{ position: "absolute", top: 2, right: 2, display: "flex", borderRadius: 4, background: "#f4fbfa", color: "#06171b", padding: "1px 4px", fontSize: 11, fontWeight: 700 }}>
|
||||
<div style={{ position: "absolute", top: 2, left: 2, display: "flex", borderRadius: 4, background: "#f4fbfa", color: "#06171b", padding: "1px 4px", fontSize: 11, fontWeight: 700 }}>
|
||||
R{member.refinement}
|
||||
</div>
|
||||
</div>
|
||||
@@ -167,6 +182,7 @@ export async function GET(
|
||||
<div style={{ display: "flex", justifyContent: "center", marginTop: 18, color: "#5c9995", fontSize: 14 }}>
|
||||
By YT เกนชินไม่ใช่เกมมือถือ
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
{
|
||||
width: 550,
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
--color-success: var(--success);
|
||||
--color-warning: var(--warning);
|
||||
--color-info: var(--info);
|
||||
--color-tier-gold: var(--tier-gold);
|
||||
--color-tier-silver: var(--tier-silver);
|
||||
--color-tier-copper: var(--tier-copper);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
@@ -73,6 +76,9 @@
|
||||
--success: oklch(0.72 0.15 155);
|
||||
--warning: oklch(0.8 0.15 82);
|
||||
--info: oklch(0.72 0.12 235);
|
||||
--tier-gold: oklch(0.8 0.15 82);
|
||||
--tier-silver: oklch(0.82 0.02 260);
|
||||
--tier-copper: oklch(0.65 0.12 55);
|
||||
--border: oklch(0.96 0.01 88 / 12%);
|
||||
--input: oklch(0.96 0.01 88 / 16%);
|
||||
--ring: oklch(0.79 0.14 78);
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { GripVerticalIcon, PlusIcon, SparklesIcon, Trash2Icon } from "lucide-react";
|
||||
import {
|
||||
ChartNoAxesCombinedIcon,
|
||||
GemIcon,
|
||||
GripVerticalIcon,
|
||||
ListOrderedIcon,
|
||||
PlusIcon,
|
||||
RefreshCcwIcon,
|
||||
SparklesIcon,
|
||||
SwordIcon,
|
||||
TimerIcon,
|
||||
Trash2Icon,
|
||||
UserRoundIcon,
|
||||
} from "lucide-react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import {
|
||||
closestCenter,
|
||||
@@ -31,6 +43,17 @@ import {
|
||||
import { AutosaveStatus } from "@/components/admin/autosave-status";
|
||||
import { MediaUploader } from "@/components/admin/media-uploader";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -60,6 +83,11 @@ import {
|
||||
teamSectionSchema,
|
||||
weaponSectionSchema,
|
||||
} from "@/lib/guides/schemas";
|
||||
import {
|
||||
normalizeTargetStat,
|
||||
targetStatDefaults,
|
||||
targetStatOptions,
|
||||
} from "@/lib/guides/stats";
|
||||
import { supportsWeaponType } from "@/lib/catalog/lunaris";
|
||||
import { rarityGradientClass } from "@/lib/catalog/rarity";
|
||||
import {
|
||||
@@ -98,7 +126,6 @@ export interface GuideSectionEditorProps {
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_TARGET_STATS = ["ATK", "EM", "ER", "CRIT Rate", "CRIT DMG"] as const;
|
||||
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 }) {
|
||||
@@ -285,9 +312,16 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
|
||||
const [circlet, setCirclet] = useState(profile?.circlet ?? []);
|
||||
const [substats, setSubstats] = useState(profile?.substats ?? []);
|
||||
const [targets, setTargets] = useState(() => {
|
||||
const existing = profile?.targets ?? [];
|
||||
const defaults = DEFAULT_TARGET_STATS.map((stat) => existing.find((target) => target.stat === stat) ?? { stat, value: "" });
|
||||
return [...defaults, ...existing.filter((target) => !DEFAULT_TARGET_STATS.includes(target.stat as (typeof DEFAULT_TARGET_STATS)[number]))];
|
||||
const existing = new Map(
|
||||
(profile?.targets ?? []).flatMap((target) => {
|
||||
const stat = normalizeTargetStat(target.stat);
|
||||
return stat ? [[stat, target.value] as const] : [];
|
||||
}),
|
||||
);
|
||||
return targetStatDefaults.map((target) => ({
|
||||
stat: target.stat,
|
||||
value: existing.get(target.stat) ?? target.value,
|
||||
}));
|
||||
});
|
||||
const [talents, setTalents] = useState<TalentPriority[]>(() => {
|
||||
const source: TalentPriority[] = profile?.talents.length
|
||||
@@ -299,7 +333,7 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
|
||||
})));
|
||||
});
|
||||
const [note, setNote] = useState(profile?.note ?? "");
|
||||
const value = { groups: groups.map((group) => ({ ...group, rows: group.rows.map((row) => ({ ...row, percent: Number(row.percent), note: row.note?.trim() || null })) })), sands, goblet, circlet, substats, targets: targets.filter((target) => target.stat.trim() && target.value.trim()), talents, note: note || null };
|
||||
const value = { groups: groups.map((group) => ({ ...group, rows: group.rows.map((row) => ({ ...row, percent: Number(row.percent), note: row.note?.trim() || null })) })), sands, goblet, circlet, substats, targets: targets.filter((target) => target.value.trim()).map((target) => ({ stat: target.stat, value: target.value.trim() })), talents, note: note || null };
|
||||
const autosave = useAutosave({ value, valid: artifactSectionSchema.safeParse({ ...value, expectedVersion: guide.version }).success, initialVersion: guide.version, persist: (next, expectedVersion) => saveArtifactSection(guide.id, { ...next, expectedVersion }) });
|
||||
const orderedTalents = normalizeTalentPriorities(talents);
|
||||
const handleTalentDragEnd = ({ active, over }: DragEndEvent) => {
|
||||
@@ -313,7 +347,7 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Artifact tier list</CardTitle><CardDescription>จัดชุด Artifact ในกลุ่ม Late Game และ Early Game ระบบจะบันทึกอัตโนมัติ</CardDescription></CardHeader>
|
||||
<CardContent className="flex flex-col gap-5">
|
||||
<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>)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -327,8 +361,18 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
|
||||
<StatSelect label="Substats" options={substatOptions} value={substats} max={substatOptions.length} onChange={setSubstats} />
|
||||
<FieldSet>
|
||||
<FieldLegend>Target Stats</FieldLegend>
|
||||
{targets.map((target, index) => <div key={`${target.stat}-${index}`} className="grid gap-3 sm:grid-cols-[1fr_1fr_auto]"><Input value={target.stat} placeholder="ATK" aria-label={`Target stat ${index + 1}`} onChange={(event) => setTargets(targets.map((item, itemIndex) => itemIndex === index ? { ...item, stat: event.target.value } : item))} /><Input value={target.value} placeholder={target.stat === "ATK" ? "2200+" : "Recommended value"} aria-label={`${target.stat || "Target"} value`} onChange={(event) => setTargets(targets.map((item, itemIndex) => itemIndex === index ? { ...item, value: event.target.value } : item))} /><Button variant="ghost" size="icon" onClick={() => setTargets(targets.filter((_, itemIndex) => itemIndex !== index))}><Trash2Icon /><span className="sr-only">ลบเป้าหมาย</span></Button></div>)}
|
||||
<Button variant="outline" onClick={() => setTargets([...targets, { stat: "", value: "" }])}><PlusIcon data-icon="inline-start" />เพิ่ม Target</Button>
|
||||
<div className="flex flex-col gap-3">
|
||||
{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>
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
<FieldDescription>เลือกสเตตัสจากรายการ และกรอกค่าเป็นตัวเลข เปอร์เซ็นต์ หรือข้อความ เช่น -%</FieldDescription>
|
||||
</FieldSet>
|
||||
<FieldSet>
|
||||
<FieldLegend>Talent Priority</FieldLegend>
|
||||
@@ -438,14 +482,52 @@ function TeamEditor({ guide, characters, weapons, artifacts, initial }: EditorBa
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
{dragHandle}
|
||||
<Button type="button" variant="ghost" size="icon-sm" onClick={() => setTeams(teams.filter((item) => item.clientId !== team.clientId))}><Trash2Icon /><span className="sr-only">ลบ Team</span></Button>
|
||||
<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>
|
||||
<AccordionContent className="pb-4">
|
||||
<FieldGroup>
|
||||
<Field><FieldLabel>ชื่อ Team</FieldLabel><Input value={team.title} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, title: event.target.value } : item))} /></Field>
|
||||
<div className="grid gap-4 md:grid-cols-2">{team.members.map((member, memberIndex) => { const character = characters.find((item) => item.key === member.characterKey); const compatibleWeapons = weapons.filter((weapon) => supportsWeaponType(character?.weaponType, weapon.weaponType)); return <Card key={memberIndex} size="sm"><CardHeader><CardTitle>ตัวละคร {memberIndex + 1}</CardTitle><CardDescription>{total ? `${((Number(member.damage) || 0) / total * 100).toFixed(2)}% ของ Team` : "0% ของ Team"}</CardDescription>{character ? <CardAction><Avatar size="lg"><AvatarImage src={character.imageUrl} alt={character.name} /><AvatarFallback>{character.name.slice(0, 2).toUpperCase()}</AvatarFallback></Avatar></CardAction> : null}</CardHeader><CardContent><FieldGroup><Field><FieldLabel>ตัวละคร</FieldLabel><CatalogPicker items={characters} value={member.characterKey} placeholder="ค้นหาชื่อตัวละคร…" onChange={(characterKey) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, characterKey, weaponKey: null, artifactKey: null, constellation: 0, refinement: 1 } : candidate) } : item))} /></Field><Field><FieldLabel>Damage</FieldLabel><Input type="number" min="0" step="0.01" value={member.damage} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, damage: event.target.value } : candidate) } : item))} /></Field><Field><FieldLabel>Weapon</FieldLabel><CatalogPicker items={compatibleWeapons} value={member.weaponKey ?? ""} placeholder="ค้นหาอาวุธที่รองรับ…" rarityBackground onChange={(weaponKey) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, weaponKey: weaponKey || null } : candidate) } : item))} /></Field><Field><FieldLabel>Refinement</FieldLabel><ToggleGroup className="flex-wrap justify-start" variant="outline" value={[`R${member.refinement}`]} onValueChange={(selected) => { const refinement = selected[0]; if (refinement) setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, refinement: Number(refinement.slice(1)) } : candidate) } : item)); }}>{[1, 2, 3, 4, 5].map((refinement) => <ToggleGroupItem className={SELECTED_TOGGLE_CLASS} key={refinement} value={`R${refinement}`}>R{refinement}</ToggleGroupItem>)}</ToggleGroup></Field><Field><FieldLabel>Artifact set</FieldLabel><CatalogPicker items={artifacts} value={member.artifactKey ?? ""} placeholder="ค้นหาชุด Artifact…" rarityBackground onChange={(artifactKey) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, artifactKey: artifactKey || null } : candidate) } : item))} /></Field><Field><FieldLabel>Constellation</FieldLabel><ToggleGroup className="flex-wrap justify-start" variant="outline" value={[`C${member.constellation}`]} onValueChange={(selected) => { const constellation = selected[0]; if (constellation) setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, constellation: Number(constellation.slice(1)) } : candidate) } : item)); }}>{[0, 1, 2, 3, 4, 5, 6].map((constellation) => <ToggleGroupItem className={SELECTED_TOGGLE_CLASS} key={constellation} value={`C${constellation}`}>C{constellation}</ToggleGroupItem>)}</ToggleGroup></Field></FieldGroup></CardContent></Card>; })}</div>
|
||||
<Field><FieldLabel>Rotation (seconds)</FieldLabel><Input type="number" min="0.01" step="0.01" value={team.rotationSeconds} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, rotationSeconds: event.target.value } : item))} /></Field>
|
||||
<Field><FieldLabel>Combo</FieldLabel><Textarea value={team.combo} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, combo: event.target.value } : item))} /></Field>
|
||||
<Field><FieldLabel className="flex items-center gap-2"><UserRoundIcon />ชื่อ Team</FieldLabel><Input value={team.title} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, title: event.target.value } : item))} /></Field>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{team.members.map((member, memberIndex) => {
|
||||
const character = characters.find((item) => item.key === member.characterKey);
|
||||
const compatibleWeapons = weapons.filter((weapon) => supportsWeaponType(character?.weaponType, weapon.weaponType));
|
||||
return (
|
||||
<Card key={memberIndex} size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle>ตัวละคร {memberIndex + 1}</CardTitle>
|
||||
<CardDescription>{total ? `${((Number(member.damage) || 0) / total * 100).toFixed(2)}% ของ Team` : "0% ของ Team"}</CardDescription>
|
||||
{character ? <CardAction><Avatar size="lg"><AvatarImage src={character.imageUrl} alt={character.name} /><AvatarFallback>{character.name.slice(0, 2).toUpperCase()}</AvatarFallback></Avatar></CardAction> : null}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<Field><FieldLabel className="flex items-center gap-2"><UserRoundIcon />ตัวละคร</FieldLabel><CatalogPicker items={characters} value={member.characterKey} placeholder="ค้นหาชื่อตัวละคร…" onChange={(characterKey) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, characterKey, weaponKey: null, artifactKey: null, constellation: 0, refinement: 1 } : candidate) } : item))} /></Field>
|
||||
<Field><FieldLabel className="flex items-center gap-2"><ChartNoAxesCombinedIcon />Damage</FieldLabel><Input type="number" min="0" step="0.01" value={member.damage} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, damage: event.target.value } : candidate) } : item))} /></Field>
|
||||
<Field><FieldLabel className="flex items-center gap-2"><SwordIcon />Weapon</FieldLabel><CatalogPicker items={compatibleWeapons} value={member.weaponKey ?? ""} placeholder="ค้นหาอาวุธที่รองรับ…" rarityBackground onChange={(weaponKey) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, weaponKey: weaponKey || null } : candidate) } : item))} /></Field>
|
||||
<Field><FieldLabel className="flex items-center gap-2"><RefreshCcwIcon />Refinement</FieldLabel><ToggleGroup className="flex-wrap justify-start" variant="outline" value={[`R${member.refinement}`]} onValueChange={(selected) => { const refinement = selected[0]; if (refinement) setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, refinement: Number(refinement.slice(1)) } : candidate) } : item)); }}>{[1, 2, 3, 4, 5].map((refinement) => <ToggleGroupItem className={SELECTED_TOGGLE_CLASS} key={refinement} value={`R${refinement}`}>R{refinement}</ToggleGroupItem>)}</ToggleGroup></Field>
|
||||
<Field><FieldLabel className="flex items-center gap-2"><GemIcon />Artifact set</FieldLabel><CatalogPicker items={artifacts} value={member.artifactKey ?? ""} placeholder="ค้นหาชุด Artifact…" rarityBackground onChange={(artifactKey) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, artifactKey: artifactKey || null } : candidate) } : item))} /></Field>
|
||||
<Field><FieldLabel className="flex items-center gap-2"><SparklesIcon />Constellation</FieldLabel><ToggleGroup className="flex-wrap justify-start" variant="outline" value={[`C${member.constellation}`]} onValueChange={(selected) => { const constellation = selected[0]; if (constellation) setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, constellation: Number(constellation.slice(1)) } : candidate) } : item)); }}>{[0, 1, 2, 3, 4, 5, 6].map((constellation) => <ToggleGroupItem className={SELECTED_TOGGLE_CLASS} key={constellation} value={`C${constellation}`}>C{constellation}</ToggleGroupItem>)}</ToggleGroup></Field>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Field><FieldLabel className="flex items-center gap-2"><TimerIcon />Rotation (seconds)</FieldLabel><Input type="number" min="0.01" step="0.01" value={team.rotationSeconds} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, rotationSeconds: event.target.value } : item))} /></Field>
|
||||
<Field><FieldLabel className="flex items-center gap-2"><ListOrderedIcon />Combo</FieldLabel><Textarea value={team.combo} onChange={(event) => setTeams(teams.map((item, index) => index === teamIndex ? { ...item, combo: event.target.value } : item))} /></Field>
|
||||
</FieldGroup>
|
||||
</AccordionContent>
|
||||
</>}</SortableTeamItem>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
} from "@/components/ui/pagination";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
import { ScrollableNav } from "@/components/public/scrollable-nav";
|
||||
import { navigationIconPath } from "@/lib/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const sections = [
|
||||
{ slug: "weapon", label: "Weapons" },
|
||||
@@ -21,29 +21,35 @@ export function GuideSectionNav({
|
||||
current: "overview" | (typeof sections)[number]["slug"];
|
||||
}) {
|
||||
return (
|
||||
<Pagination className="justify-start overflow-x-auto rounded-xl border bg-card p-2">
|
||||
<PaginationContent className="whitespace-nowrap">
|
||||
<PaginationItem>
|
||||
<PaginationLink
|
||||
href={`/admin/${characterKey}`}
|
||||
size="default"
|
||||
isActive={current === "overview"}
|
||||
<ScrollableNav
|
||||
ariaLabel="เมนูแก้ไข Guide"
|
||||
className="w-full rounded-xl border bg-card p-2"
|
||||
>
|
||||
<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",
|
||||
current === "overview" ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
Overview
|
||||
</Link>
|
||||
{sections.map((section) => {
|
||||
const iconPath = navigationIconPath(section.slug);
|
||||
return (
|
||||
<Link
|
||||
key={section.slug}
|
||||
href={`/admin/${characterKey}/${section.slug}`}
|
||||
className={cn(
|
||||
"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 === section.slug ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
Overview
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
{sections.map((section) => (
|
||||
<PaginationItem key={section.slug}>
|
||||
<PaginationLink
|
||||
href={`/admin/${characterKey}/${section.slug}`}
|
||||
size="default"
|
||||
isActive={current === section.slug}
|
||||
>
|
||||
{section.label}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
{iconPath ? <Image src={iconPath} alt="" width={20} height={20} className="size-5 object-contain" /> : null}
|
||||
{section.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</ScrollableNav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -35,6 +35,19 @@ describe("structured guide validation", () => {
|
||||
expect(artifactSectionSchema.safeParse({ ...base, groups: [{ name: "Late Game", rows: [{ ...row, note: "x".repeat(2_001) }] }, artifactGroups[1]] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts fixed target stats with text values", () => {
|
||||
const base = { expectedVersion: 1, groups: artifactGroups, sands: [], goblet: [], circlet: [], substats: [], talents: [], note: null };
|
||||
expect(artifactSectionSchema.safeParse({
|
||||
...base,
|
||||
targets: [
|
||||
{ stat: "ER", value: "100%" },
|
||||
{ stat: "CRIT RATE", value: "5%" },
|
||||
{ stat: "CRIT DAMAGE", value: "-%" },
|
||||
],
|
||||
}).success).toBe(true);
|
||||
expect(artifactSectionSchema.safeParse({ ...base, targets: [{ stat: "Energy Recharge", value: "100%" }] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("requires the fixed Late Game and Early Game artifact groups", () => {
|
||||
const base = { expectedVersion: 1, sands: [], goblet: [], circlet: [], substats: [], targets: [], talents: [], note: null };
|
||||
expect(artifactSectionSchema.safeParse({ ...base, groups: artifactGroups }).success).toBe(true);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as z from "zod";
|
||||
|
||||
import { targetStatOptions } from "@/lib/guides/stats";
|
||||
|
||||
export const guideSectionKinds = ["weapon", "artifact", "constellations", "team"] as const;
|
||||
export type GuideSectionKind = (typeof guideSectionKinds)[number];
|
||||
|
||||
@@ -62,7 +64,7 @@ export const artifactSectionSchema = z.strictObject({
|
||||
circlet: z.array(z.enum(mainStatOptions.circlet)).max(2),
|
||||
substats: z.array(z.enum(substatOptions)).max(substatOptions.length),
|
||||
targets: z.array(z.strictObject({
|
||||
stat: z.string().trim().min(1).max(80),
|
||||
stat: z.enum(targetStatOptions),
|
||||
value: z.string().trim().min(1).max(80),
|
||||
})).max(20),
|
||||
talents: z.array(z.strictObject({
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
export const targetStatOptions = [
|
||||
"HP",
|
||||
"ATK",
|
||||
"DEF",
|
||||
"EM",
|
||||
"ER",
|
||||
"CRIT RATE",
|
||||
"CRIT DAMAGE",
|
||||
] as const;
|
||||
|
||||
export type TargetStat = (typeof targetStatOptions)[number];
|
||||
|
||||
export const targetStatDefaults: Array<{ stat: TargetStat; value: string }> = [
|
||||
{ stat: "HP", value: "" },
|
||||
{ stat: "ATK", value: "" },
|
||||
{ stat: "DEF", value: "" },
|
||||
{ stat: "EM", value: "" },
|
||||
{ stat: "ER", value: "100%" },
|
||||
{ stat: "CRIT RATE", value: "5%" },
|
||||
{ stat: "CRIT DAMAGE", value: "50%" },
|
||||
];
|
||||
|
||||
const legacyTargetStatNames: Record<string, TargetStat> = {
|
||||
"CRIT Rate": "CRIT RATE",
|
||||
"CRIT DMG": "CRIT DAMAGE",
|
||||
};
|
||||
|
||||
export function normalizeTargetStat(value: string): TargetStat | null {
|
||||
const normalized = legacyTargetStatNames[value] ?? value;
|
||||
return targetStatOptions.includes(normalized as TargetStat)
|
||||
? (normalized as TargetStat)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function targetStatIconName(value: string): string | null {
|
||||
const stat = normalizeTargetStat(value);
|
||||
if (!stat) return null;
|
||||
return stat === "CRIT RATE"
|
||||
? "critrate"
|
||||
: stat === "CRIT DAMAGE"
|
||||
? "critdmg"
|
||||
: stat.toLowerCase();
|
||||
}
|
||||
|
||||
export function statPriorityColorClass(index: number): string {
|
||||
return index === 0
|
||||
? "text-tier-gold"
|
||||
: index === 1
|
||||
? "text-tier-silver"
|
||||
: "text-tier-copper";
|
||||
}
|
||||
|
||||
export function statPriorityColor(index: number): string {
|
||||
return index === 0 ? "#e0b35f" : index === 1 ? "#c7cbd3" : "#bd8055";
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function navigationIconPath(slug: string): string | null {
|
||||
if (slug === "weapon") return "/icon/nav/Weapon.png";
|
||||
if (slug === "artifact") return "/icon/nav/Artifact.webp";
|
||||
if (slug === "constellations") return "/icon/nav/Constellations.webp";
|
||||
if (slug === "team") return "/icon/nav/Team-calc.webp";
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user