feat : update

This commit is contained in:
2026-09-03 23:24:51 +07:00 Unverified
parent 0b16f5aa84
commit e72897e55b
10 changed files with 481 additions and 30 deletions
-2
View File
@@ -17,7 +17,6 @@
"discord.js": "^14.27.0",
"drizzle-orm": "^0.45.2",
"embla-carousel-react": "^8.6.0",
"fflate": "^0.8.3",
"ioredis": "^6.0.0",
"lucide-react": "^1.35.0",
"modern-screenshot": "^4.7.0",
@@ -954,7 +953,6 @@
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fflate": ["[email protected]", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
"figures": ["[email protected]", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
+10 -3
View File
@@ -26,6 +26,7 @@ import { Textarea } from "@/components/ui/textarea";
import { useAutosave } from "@/hooks/use-autosave";
import { adminDraftKey } from "@/lib/editor/local-draft";
import { parseYouTubeUrl } from "@/lib/guides/youtube";
import { cn } from "@/lib/utils";
export function GuideOverviewForm({
guide,
@@ -55,6 +56,8 @@ export function GuideOverviewForm({
!overview.trim() ? "กรุณากรอก Overview" : null,
invalidYoutubeUrl ? "กรุณาใส่ลิงก์วิดีโอ YouTube ที่ถูกต้อง" : null,
].filter((issue): issue is string => Boolean(issue));
const invalidCover = !cover.id;
const invalidOverview = !overview.trim();
const localDraft = { overview, youtubeUrl, cover };
const autosave = useAutosave({
value,
@@ -107,9 +110,12 @@ export function GuideOverviewForm({
</CardHeader>
<CardContent>
<FieldGroup>
<Field>
<Field data-invalid={invalidCover}>
<FieldLabel>Guide Image</FieldLabel>
<div className="relative mx-auto aspect-square w-full max-w-md overflow-hidden rounded-lg border">
<div className={cn(
"relative mx-auto aspect-square w-full max-w-md overflow-hidden rounded-lg border",
invalidCover && "border-destructive",
)}>
<Image
src={cover.url}
alt={`Guide ${guide.name}`}
@@ -119,11 +125,12 @@ export function GuideOverviewForm({
</div>
<MediaUploader onReady={(asset) => setCover(asset)} />
</Field>
<Field>
<Field data-invalid={invalidOverview}>
<FieldLabel htmlFor="guide-overview">Overview</FieldLabel>
<Textarea
id="guide-overview"
value={overview}
aria-invalid={invalidOverview}
maxLength={500}
onChange={(event) => setOverview(event.target.value)}
/>
+236 -18
View File
@@ -17,7 +17,7 @@ import {
UserRoundIcon,
XIcon,
} from "lucide-react";
import { useState, type ReactNode } from "react";
import { useEffect, useState, type ReactNode } from "react";
import {
closestCenter,
DndContext,
@@ -55,6 +55,7 @@ import {
} from "@/components/ui/accordion";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Card,
CardAction,
@@ -92,6 +93,14 @@ import {
import { Textarea } from "@/components/ui/textarea";
import { Toggle } from "@/components/ui/toggle";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useAutosave } from "@/hooks/use-autosave";
import { adminDraftKey } from "@/lib/editor/local-draft";
import {
@@ -113,6 +122,18 @@ import {
} from "@/lib/guides/stats";
import { supportsWeaponType } from "@/lib/catalog/lunaris";
import { rarityGradientClass } from "@/lib/catalog/rarity";
import {
applyPresetFields,
applyTeamFields,
limitPresets,
parseTeamPresets,
teamApplyFields,
teamPresetCookieKey,
teamPresetStorageKey,
type TeamApplyField,
type TeamPreset,
type TeamToolValue,
} from "@/lib/guides/team-tools";
import {
normalizeTalentPriorities,
reorderTalentPriorities,
@@ -227,6 +248,13 @@ function parseEditorDraft<Draft>(
return value as Draft;
}
function hasIssuePrefix(
issues: readonly { path: readonly PropertyKey[] }[],
...path: (string | number)[]
): boolean {
return issues.some((issue) => path.every((part, index) => issue.path[index] === part));
}
export interface GuideSectionEditorProps {
section: "weapon" | "artifact" | "constellations" | "team" | "extra";
guide: Guide;
@@ -266,6 +294,7 @@ function CatalogPicker({
placeholder,
rarityBackground = false,
showSelectedImage = false,
invalid = false,
}: {
items: CatalogItem[];
value: string;
@@ -273,6 +302,7 @@ function CatalogPicker({
placeholder: string;
rarityBackground?: boolean;
showSelectedImage?: boolean;
invalid?: boolean;
}) {
const selected = items.find((item) => item.key === value) ?? null;
return (
@@ -305,6 +335,7 @@ function CatalogPicker({
className="w-full"
placeholder={placeholder}
showClear
aria-invalid={invalid}
/>
<ComboboxContent className="min-w-[24rem]">
<ComboboxEmpty>ไม่พบรายการ</ComboboxEmpty>
@@ -691,6 +722,8 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
...value,
expectedVersion: guide.version,
});
const weaponIssue = (...path: (string | number)[]) =>
!validation.success && hasIssuePrefix(validation.error.issues, ...path);
const localDraft = { note, rows };
const autosave = useAutosave({
value,
@@ -802,11 +835,12 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
<CardContent>
<FieldGroup>
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-end gap-4">
<Field className="min-w-0">
<Field className="min-w-0" data-invalid={weaponIssue("rows", rowIndex, "weaponKey")}>
<FieldLabel>อาวุธ</FieldLabel>
<CatalogPicker
items={compatibleWeapons}
value={row.weaponKey}
invalid={weaponIssue("rows", rowIndex, "weaponKey")}
placeholder="ค้นหาชื่ออาวุธ…"
rarityBackground
onChange={(weaponKey) =>
@@ -820,7 +854,7 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
}
/>
</Field>
<Field className="shrink-0">
<Field className="shrink-0" data-invalid={weaponIssue("rows", rowIndex)}>
<FieldLabel>Refinement</FieldLabel>
<ToggleGroup
variant="outline"
@@ -855,12 +889,13 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
</Field>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field>
<Field data-invalid={weaponIssue("rows", rowIndex)}>
<FieldLabel>Overall %</FieldLabel>
<Input
type="text"
inputMode="decimal"
value={row.overallPercent}
aria-invalid={weaponIssue("rows", rowIndex)}
onChange={(event) =>
setRows(
rows.map((item) =>
@@ -894,12 +929,13 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
}
/>
</Field>
<Field>
<Field data-invalid={weaponIssue("rows", rowIndex)}>
<FieldLabel>Personal %</FieldLabel>
<Input
type="text"
inputMode="decimal"
value={row.personalPercent}
aria-invalid={weaponIssue("rows", rowIndex)}
onChange={(event) =>
setRows(
rows.map((item) =>
@@ -1090,6 +1126,8 @@ function ArtifactEditor({
...value,
expectedVersion: guide.version,
});
const artifactIssue = (...path: (string | number)[]) =>
!validation.success && hasIssuePrefix(validation.error.issues, ...path);
const localDraft = {
groups,
sands,
@@ -1213,6 +1251,7 @@ function ArtifactEditor({
rowIndex % 2 === 0
? "bg-muted/60"
: "bg-muted/20",
artifactIssue("groups", groupIndex, "rows", rowIndex) && "border-destructive",
)}
>
{dragHandle}
@@ -1221,6 +1260,7 @@ function ArtifactEditor({
<CatalogPicker
items={artifacts}
value={row.firstArtifactKey}
invalid={artifactIssue("groups", groupIndex, "rows", rowIndex, "firstArtifactKey")}
placeholder="ค้นหาชุด Artifact แรก…"
rarityBackground
showSelectedImage
@@ -1279,6 +1319,7 @@ function ArtifactEditor({
inputMode="decimal"
aria-label="Artifact percent"
value={row.percent}
aria-invalid={artifactIssue("groups", groupIndex, "rows", rowIndex)}
onChange={(event) =>
setGroups(
groups.map((item, index) =>
@@ -1676,6 +1717,8 @@ function ConstellationEditor({
...value,
expectedVersion: guide.version,
});
const constellationIssue = (...path: (string | number)[]) =>
!validation.success && hasIssuePrefix(validation.error.issues, ...path);
const localDraft = { note, groups };
const autosave = useAutosave({
value,
@@ -1766,7 +1809,13 @@ function ConstellationEditor({
</Field>
<div className="grid gap-4 lg:grid-cols-2">
{group.rows.map((row, rowIndex) => (
<Card key={row.level} size="sm">
<Card
key={row.level}
size="sm"
className={cn(
constellationIssue("groups", groupIndex, "rows", rowIndex) && "border-destructive",
)}
>
<CardHeader>
{row.iconUrl ? (
<Image
@@ -1789,6 +1838,7 @@ function ConstellationEditor({
type="text"
inputMode="decimal"
value={row.cumulativePercent}
aria-invalid={constellationIssue("groups", groupIndex, "rows", rowIndex, "cumulativePercent")}
onChange={(event) =>
setGroups(
groups.map((item) =>
@@ -1930,9 +1980,20 @@ function TeamEditor({
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const [teams, setTeams] = useState(
const [teams, setTeams] = useState<TeamToolValue[]>(
initial.teams.map(({ id, ...team }) => ({ clientId: id, ...team })),
);
const [selectedTeamIds, setSelectedTeamIds] = useState<Set<string>>(
() => new Set(),
);
const [sourceTeamId, setSourceTeamId] = useState(teams[0]?.clientId ?? "");
const [applyOpen, setApplyOpen] = useState(false);
const [presetOpen, setPresetOpen] = useState(false);
const [presetName, setPresetName] = useState("");
const [presets, setPresets] = useState<TeamPreset[]>([]);
const [presetsHydrated, setPresetsHydrated] = useState(false);
const [presetsGuideId, setPresetsGuideId] = useState<string | null>(null);
const [applyFields, setApplyFields] = useState<TeamApplyField[]>(["loadout"]);
const value = {
teams: teams.map((team) => ({
title: team.title,
@@ -1948,7 +2009,37 @@ function TeamEditor({
...value,
expectedVersion: guide.version,
});
const teamFieldInvalid = (teamIndex: number, field: string) =>
!validation.success && validation.error.issues.some((issue) =>
issue.path[0] === "teams" && issue.path[1] === teamIndex && issue.path[2] === field,
);
const memberFieldInvalid = (teamIndex: number, memberIndex: number, field: string) =>
!validation.success && validation.error.issues.some((issue) =>
issue.path[0] === "teams" && issue.path[1] === teamIndex && issue.path[2] === "members" &&
(issue.path[3] === memberIndex && issue.path[4] === field || issue.path.length === 3),
);
const localDraft = { teams };
useEffect(() => {
const timer = window.setTimeout(() => {
try {
setPresets(parseTeamPresets(window.localStorage.getItem(teamPresetStorageKey(guide.id))));
} catch {
setPresets([]);
}
setPresetsHydrated(true);
setPresetsGuideId(guide.id);
}, 0);
return () => window.clearTimeout(timer);
}, [guide.id]);
useEffect(() => {
if (!presetsHydrated || presetsGuideId !== guide.id) return;
try {
window.localStorage.setItem(teamPresetStorageKey(guide.id), JSON.stringify(presets));
document.cookie = `${teamPresetCookieKey(guide.id)}=${encodeURIComponent(JSON.stringify(presets.map(({ id, name, createdAt }) => ({ id, name, createdAt })) ))}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
} catch {
// Browser storage is optional; the editor remains usable without it.
}
}, [guide.id, presets, presetsGuideId, presetsHydrated]);
const autosave = useAutosave({
value,
valid: validation.success,
@@ -1969,6 +2060,52 @@ function TeamEditor({
return from === -1 || to === -1 ? current : arrayMove(current, from, to);
});
};
const toggleTeamSelection = (teamId: string, checked: boolean) => {
setSelectedTeamIds((current) => {
const next = new Set(current);
if (checked) next.add(teamId);
else next.delete(teamId);
return next;
});
};
const toggleApplyField = (field: TeamApplyField, checked: boolean) => {
setApplyFields((current) => {
const next = new Set(current);
if (checked) next.add(field);
else next.delete(field);
return teamApplyFields.filter((candidate) => next.has(candidate));
});
};
const applyChanges = () => {
if (!sourceTeamId || !selectedTeamIds.size || !applyFields.length) return;
setTeams((current) => applyTeamFields(current, sourceTeamId, selectedTeamIds, applyFields));
setSelectedTeamIds(new Set());
setApplyOpen(false);
};
const savePreset = () => {
const source = teams.find((team) => team.clientId === sourceTeamId);
const name = presetName.trim();
if (!source || !name) return;
const preset: TeamPreset = {
id: createClientId("preset"),
name,
createdAt: new Date().toISOString(),
team: {
title: source.title,
rotationSeconds: source.rotationSeconds,
combo: source.combo,
members: source.members.map((member) => ({ ...member })),
},
};
setPresets((current) => limitPresets([preset, ...current]));
setPresetName("");
setPresetOpen(false);
};
const applyPreset = (preset: TeamPreset) => {
if (!selectedTeamIds.size || !applyFields.length) return;
setTeams((current) => applyPresetFields(current, preset, selectedTeamIds, applyFields));
setSelectedTeamIds(new Set());
};
return (
<Card>
<CardHeader>
@@ -1979,6 +2116,28 @@ function TeamEditor({
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-center gap-2 rounded-lg border bg-muted/30 p-3">
<Checkbox
checked={selectedTeamIds.size === teams.length && teams.length > 0}
onCheckedChange={(checked) => setSelectedTeamIds(checked ? new Set(teams.map((team) => team.clientId)) : new Set())}
aria-label="เลือกทุก Team"
/>
<span className="text-sm font-medium">เลือก Team สำหรับ Multiple Apply</span>
<Select value={sourceTeamId} onValueChange={(value) => { if (value) setSourceTeamId(value); }}>
<SelectTrigger className="w-48"><SelectValue placeholder="Source Team" /></SelectTrigger>
<SelectContent>
<SelectGroup>{teams.map((team) => <SelectItem key={team.clientId} value={team.clientId}>{team.title || "Untitled Team"}</SelectItem>)}</SelectGroup>
</SelectContent>
</Select>
<Button type="button" size="sm" disabled={!selectedTeamIds.size} onClick={() => setApplyOpen(true)}>Multiple Apply</Button>
<Button type="button" variant="outline" size="sm" disabled={!sourceTeamId} onClick={() => setPresetOpen(true)}>Save preset</Button>
{presets.map((preset) => (
<div key={preset.id} className="flex items-center gap-1">
<Button type="button" variant="ghost" size="sm" disabled={!selectedTeamIds.size} onClick={() => applyPreset(preset)}>{preset.name}</Button>
<Button type="button" variant="ghost" size="icon-sm" onClick={() => setPresets((current) => current.filter((item) => item.id !== preset.id))} aria-label={`ลบ preset ${preset.name}`}><XIcon /></Button>
</div>
))}
</div>
<DndContext
sensors={teamSensors}
collisionDetection={closestCenter}
@@ -2000,10 +2159,16 @@ function TeamEditor({
);
const dps = total / (Number(team.rotationSeconds) || 1);
return (
<SortableTeamItem key={team.clientId} id={team.clientId}>
<SortableTeamItem key={team.clientId} id={team.clientId}>
{(dragHandle) => (
<>
<div className="flex w-full items-center gap-2">
<Checkbox
checked={selectedTeamIds.has(team.clientId)}
onCheckedChange={(checked) => toggleTeamSelection(team.clientId, checked === true)}
onClick={(event) => event.stopPropagation()}
aria-label={`เลือก ${team.title || `Team ${teamIndex + 1}`}`}
/>
<AccordionTrigger
headerClassName="min-w-0 flex-1"
className="min-w-0 flex-1 py-4 hover:no-underline"
@@ -2082,15 +2247,16 @@ function TeamEditor({
</DeleteConfirmationDialog>
</div>
</div>
<AccordionContent className="pb-4">
<AccordionContent className="pb-4" data-invalid={teamFieldInvalid(teamIndex, "members")}>
<FieldGroup>
<Field>
<Field data-invalid={teamFieldInvalid(teamIndex, "title")}>
<FieldLabel className="flex items-center gap-2">
<UserRoundIcon />
ชื่อ Team
</FieldLabel>
<Input
value={team.title}
aria-invalid={teamFieldInvalid(teamIndex, "title")}
onChange={(event) =>
setTeams(
teams.map((item, index) =>
@@ -2115,7 +2281,7 @@ function TeamEditor({
),
);
return (
<Card key={memberIndex} size="sm">
<Card key={memberIndex} size="sm" data-invalid={memberFieldInvalid(teamIndex, memberIndex, "members")}>
<CardHeader>
<CardTitle>
ตัวละคร {memberIndex + 1}
@@ -2143,7 +2309,7 @@ function TeamEditor({
</CardHeader>
<CardContent>
<FieldGroup>
<Field>
<Field data-invalid={memberFieldInvalid(teamIndex, memberIndex, "characterKey")}>
<FieldLabel className="flex items-center gap-2">
<UserRoundIcon />
ตัวละคร
@@ -2151,6 +2317,7 @@ function TeamEditor({
<CatalogPicker
items={characters}
value={member.characterKey}
invalid={memberFieldInvalid(teamIndex, memberIndex, "characterKey")}
placeholder="ค้นหาชื่อตัวละคร…"
onChange={(characterKey) =>
setTeams(
@@ -2187,7 +2354,7 @@ function TeamEditor({
}
/>
</Field>
<Field>
<Field data-invalid={memberFieldInvalid(teamIndex, memberIndex, "damage")}>
<FieldLabel className="flex items-center gap-2">
<ChartNoAxesCombinedIcon />
Damage
@@ -2197,6 +2364,7 @@ function TeamEditor({
min="0"
step="0.01"
value={member.damage}
aria-invalid={memberFieldInvalid(teamIndex, memberIndex, "damage")}
onChange={(event) =>
setTeams(
teams.map((item, index) =>
@@ -2470,7 +2638,7 @@ function TeamEditor({
);
})}
</div>
<Field>
<Field data-invalid={teamFieldInvalid(teamIndex, "rotationSeconds")}>
<FieldLabel className="flex items-center gap-2">
<TimerIcon />
Rotation (seconds)
@@ -2480,6 +2648,7 @@ function TeamEditor({
min="0.01"
step="0.01"
value={team.rotationSeconds}
aria-invalid={teamFieldInvalid(teamIndex, "rotationSeconds")}
onChange={(event) =>
setTeams(
teams.map((item, index) =>
@@ -2494,13 +2663,14 @@ function TeamEditor({
}
/>
</Field>
<Field>
<Field data-invalid={teamFieldInvalid(teamIndex, "combo")}>
<FieldLabel className="flex items-center gap-2">
<ListOrderedIcon />
Combo
</FieldLabel>
<Textarea
value={team.combo}
aria-invalid={teamFieldInvalid(teamIndex, "combo")}
onChange={(event) =>
setTeams(
teams.map((item, index) =>
@@ -2548,11 +2718,36 @@ function TeamEditor({
issues={
validation.success
? []
: formatGuideValidationIssues("team", validation.error.issues)
: formatGuideValidationIssues("team", validation.error.issues, {
teamNames: teams.map((team) => team.title),
})
}
onSave={autosave.saveNow}
/>
</CardFooter>
<Dialog open={applyOpen} onOpenChange={setApplyOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Multiple Apply</DialogTitle>
<DialogDescription>คัดลอกค่าจาก {teams.find((team) => team.clientId === sourceTeamId)?.title || "Source Team"} ไปยัง {selectedTeamIds.size} Team ที่เลือก</DialogDescription>
</DialogHeader>
<FieldSet>
<FieldLegend>ข้อมูลที่ต้องการคัดลอก</FieldLegend>
<Field orientation="horizontal"><Checkbox checked={applyFields.includes("loadout")} onCheckedChange={(checked) => toggleApplyField("loadout", checked === true)} /><FieldLabel>Loadout: Character, Weapon, Artifact, C, R</FieldLabel></Field>
<Field orientation="horizontal"><Checkbox checked={applyFields.includes("damage")} onCheckedChange={(checked) => toggleApplyField("damage", checked === true)} /><FieldLabel>Damage</FieldLabel></Field>
<Field orientation="horizontal"><Checkbox checked={applyFields.includes("rotation")} onCheckedChange={(checked) => toggleApplyField("rotation", checked === true)} /><FieldLabel>Rotation</FieldLabel></Field>
<Field orientation="horizontal"><Checkbox checked={applyFields.includes("combo")} onCheckedChange={(checked) => toggleApplyField("combo", checked === true)} /><FieldLabel>Combo</FieldLabel></Field>
</FieldSet>
<DialogFooter><Button type="button" variant="outline" onClick={() => setApplyOpen(false)}>ยกเลิก</Button><Button type="button" disabled={!applyFields.length} onClick={applyChanges}>ยืนยันการคัดลอก</Button></DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={presetOpen} onOpenChange={setPresetOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Save team preset</DialogTitle><DialogDescription>บันทึก Source Team นี้ไว้ใช้กับ Guide นี้ในเบราว์เซอร์นี้</DialogDescription></DialogHeader>
<Field><FieldLabel htmlFor="team-preset-name">ชื่อ Preset</FieldLabel><Input id="team-preset-name" value={presetName} onChange={(event) => setPresetName(event.target.value)} /></Field>
<DialogFooter><Button type="button" variant="outline" onClick={() => setPresetOpen(false)}>ยกเลิก</Button><Button type="button" disabled={!presetName.trim()} onClick={savePreset}>บันทึก</Button></DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}
@@ -2578,6 +2773,8 @@ function ExtraEditor({ guide, initial }: EditorBase) {
...value,
expectedVersion: guide.version,
});
const extraIssue = (...path: (string | number)[]) =>
!validation.success && hasIssuePrefix(validation.error.issues, ...path);
const localDraft = { sections };
const autosave = useAutosave({
value,
@@ -2602,12 +2799,19 @@ function ExtraEditor({ guide, initial }: EditorBase) {
</CardHeader>
<CardContent className="flex flex-col gap-5">
{sections.map((section, sectionIndex) => (
<Card key={sectionIndex} size="sm">
<Card
key={sectionIndex}
size="sm"
className={cn(
extraIssue("sections", sectionIndex, "title") && "border-destructive",
)}
>
<CardHeader>
<CardTitle>
<Input
value={section.title}
aria-label="ชื่อ Extra section"
aria-invalid={extraIssue("sections", sectionIndex, "title")}
onChange={(event) =>
setSections(
sections.map((item, index) =>
@@ -2639,7 +2843,14 @@ function ExtraEditor({ guide, initial }: EditorBase) {
</CardHeader>
<CardContent className="flex flex-col gap-4">
{section.rows.map((row, rowIndex) => (
<Card key={rowIndex} size="sm">
<Card
key={rowIndex}
size="sm"
className={cn(
extraIssue("sections", sectionIndex, "rows", rowIndex) &&
"border-destructive",
)}
>
<CardHeader>
<CardTitle>
{row.heading || `แถว ${rowIndex + 1}`}
@@ -2797,6 +3008,13 @@ function ExtraEditor({ guide, initial }: EditorBase) {
<FieldLabel>หัวข้อ</FieldLabel>
<Input
value={row.heading}
aria-invalid={extraIssue(
"sections",
sectionIndex,
"rows",
rowIndex,
"heading",
)}
onChange={(event) =>
setSections(
sections.map((item, index) =>
+39 -2
View File
@@ -18,6 +18,43 @@ import { cn } from "@/lib/utils";
type Team = PublicGuide["teams"][number];
function TeamExportImage({ team }: { team: Team }) {
const total = team.members.reduce((sum, member) => sum + Number(member.damage), 0);
const seconds = Number(team.rotationSeconds);
return (
<div className="flex h-[1350px] w-[1080px] flex-col bg-[#05060a] p-[48px_52px] font-heading text-[white]">
<header className="flex flex-col items-center text-center">
<h2 className="text-[64px] leading-tight font-bold">{team.title}</h2>
<p className="text-[32px] text-[#b9b9c2]">ระยะเวลาโรเทชั่น {seconds.toLocaleString(undefined, { maximumFractionDigits: 2 })} วิ</p>
</header>
<div className="mt-6 flex flex-col gap-6">
{team.members.map((member) => {
const damage = Number(member.damage);
const percent = total > 0 ? damage / total * 100 : 0;
return (
<div key={member.position} className="flex h-[190px] items-center gap-6 rounded-3xl p-3.5" style={{ backgroundColor: elementBackgroundColor(member.character.element) }}>
<div className={cn("relative size-[150px] shrink-0 overflow-hidden rounded-3xl border-[3px] border-white/20", rarityGradientClass(isCustomTravelerKey(member.character.key) ? 5 : member.character.rarity))}>
<Image src={member.character.imageUrl} alt="" fill loading="eager" sizes="150px" className="object-contain" />
<span className="absolute top-1.5 left-1.5 text-xl font-bold">C{member.constellation}</span>
</div>
<div className="flex gap-3">
{[member.weapon, member.artifact, member.secondArtifact].map((item, index) => item ? <div key={`${item.key}-${index}`} className={cn("relative size-24 overflow-hidden rounded-2xl", rarityGradientClass(item.rarity))}><Image src={item.imageUrl} alt="" fill loading="eager" sizes="96px" className="object-contain p-1" />{index === 0 ? <span className="absolute top-1 left-1 text-base font-bold">R{member.refinement}</span> : null}</div> : null)}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-3.5">
<div className="flex items-center justify-between gap-4"><span className="truncate text-[36px] font-semibold">{member.character.name}</span><span className="text-[36px]">{damage.toLocaleString()}</span></div>
<div className="flex items-center gap-5"><div className="h-11 flex-1 overflow-hidden rounded-xl bg-[#272730]"><div className="h-full rounded-xl bg-gradient-to-r from-[#b7791f] to-[#f0b72f]" style={{ width: `${Math.max(2, Math.min(100, percent))}%` }} /></div><span className="w-28 text-right text-[34px] font-bold">{percent.toFixed(1)}%</span></div>
</div>
</div>
);
})}
</div>
<div className="my-5 flex flex-1 items-center justify-center rounded-2xl bg-[#272730] p-6 text-center text-[28px] leading-[1.45]">{team.combo}</div>
<div className="flex justify-center border-t-2 border-[#34343d] pt-6 text-[64px] font-bold text-[#f0c95f]">DPS: {seconds > 0 ? (total / seconds).toLocaleString(undefined, { maximumFractionDigits: 0 }) : "0"}</div>
<div className="mt-5 flex justify-center text-[40px] text-[#74747f]">By YT เกนชินไม่ใช่เกมมือถือ</div>
</div>
);
}
function TeamCard({
team,
action,
@@ -262,8 +299,8 @@ export function TeamSearch({ teams }: { teams: PublicGuide["teams"] }) {
key={team.id}
team={team}
action={
<GuideImageExport fileName={`${team.title}.png`}>
<TeamCard team={team} exporting />
<GuideImageExport fileName={`${team.title}.png`} width={1080} height={1350} captureClassName="p-0">
<TeamExportImage team={team} />
</GuideImageExport>
}
/>
+29
View File
@@ -0,0 +1,29 @@
"use client"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { applyTeamFields, limitPresets, parseTeamPresets } from "./team-tools";
const team = (id: string, title = id) => ({
clientId: id, title, rotationSeconds: "20", combo: "E > Q",
members: [1, 2, 3, 4].map((n) => ({ characterKey: `${id}-${n}`, weaponKey: null, artifactKey: null, secondArtifactKey: null, constellation: 0, refinement: 1, damage: String(n) })),
});
describe("team bulk tools", () => {
it("copies selected fields without changing titles or mutating source", () => {
const source = team("source", "Source");
const target = team("target", "Keep title");
const result = applyTeamFields([source, target], "source", new Set(["target"]), ["loadout"]);
expect(result[1].title).toBe("Keep title");
expect(result[1].members.map((member) => member.characterKey)).toEqual(["source-1", "source-2", "source-3", "source-4"]);
expect(result[1].members.map((member) => member.damage)).toEqual(["1", "2", "3", "4"]);
expect(source.members[0].characterKey).toBe("source-1");
});
it("does not apply to the source team", () => {
const source = team("source");
expect(applyTeamFields([source], "source", new Set(["source"]), ["combo"])).toEqual([source]);
});
it("deduplicates and limits presets", () => {
const presets = Array.from({ length: 7 }, (_, index) => ({ id: String(index), name: String(index), createdAt: String(index), team: { ...team(String(index)), clientId: undefined as never } }));
expect(limitPresets([presets[0], presets[0], ...presets.slice(1)])).toHaveLength(5);
});
it("rejects malformed preset storage", () => {
expect(parseTeamPresets("not-json")).toEqual([]);
expect(parseTeamPresets(JSON.stringify([{ id: 1 }]))).toEqual([]);
});
});
+115
View File
@@ -0,0 +1,115 @@
export interface TeamToolMember {
characterKey: string;
weaponKey: string | null;
artifactKey: string | null;
secondArtifactKey: string | null;
constellation: number;
refinement: number;
damage: string;
}
export interface TeamToolValue {
clientId: string;
title: string;
rotationSeconds: string;
combo: string;
members: TeamToolMember[];
}
export const teamApplyFields = ["loadout", "damage", "rotation", "combo"] as const;
export type TeamApplyField = (typeof teamApplyFields)[number];
export interface TeamPreset {
id: string;
name: string;
createdAt: string;
team: Omit<TeamToolValue, "clientId">;
}
function cloneMember(member: TeamToolMember): TeamToolMember {
return { ...member };
}
function cloneTeam(team: TeamToolValue): TeamToolValue {
return { ...team, members: team.members.map(cloneMember) };
}
export function applyTeamFields(
teams: readonly TeamToolValue[],
sourceId: string,
targetIds: ReadonlySet<string>,
fields: readonly TeamApplyField[],
): TeamToolValue[] {
const source = teams.find((team) => team.clientId === sourceId);
if (!source) return teams.map(cloneTeam);
const selected = new Set(fields);
return teams.map((team) => {
if (!targetIds.has(team.clientId) || team.clientId === sourceId) return cloneTeam(team);
const next = cloneTeam(team);
if (selected.has("loadout")) {
next.members = source.members.map((member, index) => ({
...cloneMember(member),
damage: next.members[index]?.damage ?? "0",
}));
}
if (selected.has("damage")) next.members = source.members.map(cloneMember);
if (selected.has("rotation")) next.rotationSeconds = source.rotationSeconds;
if (selected.has("combo")) next.combo = source.combo;
return next;
});
}
export function applyPresetFields(
teams: readonly TeamToolValue[],
preset: TeamPreset,
targetIds: ReadonlySet<string>,
fields: readonly TeamApplyField[],
): TeamToolValue[] {
return applyTeamFields(
[
...teams,
{ clientId: preset.id, ...preset.team, members: preset.team.members.map(cloneMember) },
],
preset.id,
targetIds,
fields,
).slice(0, teams.length);
}
export function presetFingerprint(team: Omit<TeamToolValue, "clientId">): string {
return JSON.stringify(team);
}
export function limitPresets(presets: readonly TeamPreset[], limit = 5): TeamPreset[] {
const seen = new Set<string>();
return presets.filter((preset) => {
const fingerprint = presetFingerprint(preset.team);
if (seen.has(fingerprint)) return false;
seen.add(fingerprint);
return true;
}).slice(0, limit);
}
export function parseTeamPresets(serialized: string | null): TeamPreset[] {
if (!serialized) return [];
try {
const value: unknown = JSON.parse(serialized);
if (!Array.isArray(value)) return [];
return limitPresets(value.filter((item): item is TeamPreset => {
if (!item || typeof item !== "object") return false;
const candidate = item as Record<string, unknown>;
return typeof candidate.id === "string" && typeof candidate.name === "string" &&
typeof candidate.createdAt === "string" && Boolean(candidate.team);
}));
} catch {
return [];
}
}
export function teamPresetStorageKey(guideId: string): string {
return `buzz-sheet:team-presets:${guideId}`;
}
export function teamPresetCookieKey(guideId: string): string {
return `buzz-sheet-team-presets-${guideId}`;
}
+8
View File
@@ -14,4 +14,12 @@ describe("guide validation messages", () => {
"Team 2: Team ต้องมีตัวละครไม่ซ้ำกัน 4 ตัว",
]);
});
it("uses the authored team name when one is available", () => {
expect(formatGuideValidationIssues(
"team",
[{ path: ["teams", 1, "combo"], message: "Invalid input" }],
{ teamNames: ["First team", "Hypercarry"] },
)).toEqual(["Hypercarry: กรุณากรอก Combo"]);
});
});
+9 -4
View File
@@ -34,7 +34,7 @@ function indexAfter(path: readonly PropertyKey[], key: string): number | null {
return position >= 0 && typeof value === "number" ? value : null;
}
function issueContext(section: GuideValidationSection, path: readonly PropertyKey[]): string {
function issueContext(section: GuideValidationSection, path: readonly PropertyKey[], teamNames?: readonly string[]): string {
const rowIndex = indexAfter(path, "rows");
const groupIndex = indexAfter(path, "groups");
const teamIndex = indexAfter(path, "teams");
@@ -50,7 +50,8 @@ function issueContext(section: GuideValidationSection, path: readonly PropertyKe
return rowIndex === null ? `Condition ${groupIndex + 1}` : `Condition ${groupIndex + 1} · C${rowIndex + 1}`;
}
if (section === "team" && teamIndex !== null) {
return memberIndex === null ? `Team ${teamIndex + 1}` : `Team ${teamIndex + 1} · ตัวละคร ${memberIndex + 1}`;
const teamName = teamNames?.[teamIndex]?.trim() || `Team ${teamIndex + 1}`;
return memberIndex === null ? teamName : `${teamName} · ตัวละคร ${memberIndex + 1}`;
}
if (section === "extra" && sectionIndex !== null) {
return rowIndex === null ? `Extra section ${sectionIndex + 1}` : `Extra section ${sectionIndex + 1} · แถว ${rowIndex + 1}`;
@@ -67,6 +68,10 @@ function issueMessage(issue: ValidationIssue): string {
return "กรุณาตรวจสอบข้อมูลให้ถูกต้อง";
}
export function formatGuideValidationIssues(section: GuideValidationSection, issues: readonly ValidationIssue[]): string[] {
return issues.map((issue) => `${issueContext(section, issue.path)}: ${issueMessage(issue)}`);
export function formatGuideValidationIssues(
section: GuideValidationSection,
issues: readonly ValidationIssue[],
options: { teamNames?: readonly string[] } = {},
): string[] {
return issues.map((issue) => `${issueContext(section, issue.path, options.teamNames)}: ${issueMessage(issue)}`);
}
-1
View File
@@ -33,7 +33,6 @@
"discord.js": "^14.27.0",
"drizzle-orm": "^0.45.2",
"embla-carousel-react": "^8.6.0",
"fflate": "^0.8.3",
"ioredis": "^6.0.0",
"lucide-react": "^1.35.0",
"modern-screenshot": "^4.7.0",