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