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
+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) =>