feat : update
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { SimpleTooltip } from "@/components/tooltip";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -25,6 +26,7 @@ interface DeleteConfirmationDialogProps {
|
||||
open?: ComponentProps<typeof AlertDialog>["open"];
|
||||
onOpenChange?: ComponentProps<typeof AlertDialog>["onOpenChange"];
|
||||
showSuccessToast?: boolean;
|
||||
triggerTooltip?: ReactNode;
|
||||
}
|
||||
|
||||
export function DeleteConfirmationDialog({
|
||||
@@ -37,6 +39,7 @@ export function DeleteConfirmationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
showSuccessToast = true,
|
||||
triggerTooltip,
|
||||
}: DeleteConfirmationDialogProps) {
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm();
|
||||
@@ -46,7 +49,13 @@ export function DeleteConfirmationDialog({
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
{trigger ? (
|
||||
<AlertDialogTrigger render={trigger}>{children}</AlertDialogTrigger>
|
||||
triggerTooltip ? (
|
||||
<SimpleTooltip text={triggerTooltip}>
|
||||
<AlertDialogTrigger render={trigger}>{children}</AlertDialogTrigger>
|
||||
</SimpleTooltip>
|
||||
) : (
|
||||
<AlertDialogTrigger render={trigger}>{children}</AlertDialogTrigger>
|
||||
)
|
||||
) : null}
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
import { AutosaveStatus } from "@/components/admin/autosave-status";
|
||||
import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog";
|
||||
import { MediaUploader } from "@/components/admin/media-uploader";
|
||||
import { SimpleTooltip } from "@/components/tooltip";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
@@ -137,6 +138,7 @@ import {
|
||||
parseTeamPresets,
|
||||
parseTeamTransfer,
|
||||
serializeTeamTransfer,
|
||||
teamTransferFileName,
|
||||
teamPresetCookieKey,
|
||||
teamPresetStorageKey,
|
||||
type TeamApplySelection,
|
||||
@@ -708,17 +710,19 @@ function SortableTeamItem({
|
||||
className={cn("rounded-xl border px-4", isDragging && "opacity-60")}
|
||||
>
|
||||
{children(
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="touch-none"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVerticalIcon />
|
||||
<span className="sr-only">ลากเพื่อจัดลำดับ Team</span>
|
||||
</Button>,
|
||||
<SimpleTooltip text="ลากเพื่อจัดลำดับ Team">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="touch-none"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVerticalIcon />
|
||||
<span className="sr-only">ลากเพื่อจัดลำดับ Team</span>
|
||||
</Button>
|
||||
</SimpleTooltip>,
|
||||
)}
|
||||
</AccordionItem>
|
||||
);
|
||||
@@ -746,17 +750,19 @@ function SortableTeamMember({
|
||||
className={cn(isDragging && "opacity-60")}
|
||||
>
|
||||
{children(
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="touch-none"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVerticalIcon />
|
||||
<span className="sr-only">ลากเพื่อย้ายตำแหน่งตัวละคร</span>
|
||||
</Button>,
|
||||
<SimpleTooltip text="ลากเพื่อย้ายตำแหน่งตัวละคร">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="touch-none"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVerticalIcon />
|
||||
<span className="sr-only">ลากเพื่อย้ายตำแหน่งตัวละคร</span>
|
||||
</Button>
|
||||
</SimpleTooltip>,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -2169,6 +2175,7 @@ function TeamEditor({
|
||||
const [presetOpen, setPresetOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [importValue, setImportValue] = useState("");
|
||||
const [importFiles, setImportFiles] = useState<File[]>([]);
|
||||
const [presetName, setPresetName] = useState("");
|
||||
const [presets, setPresets] = useState<TeamPreset[]>([]);
|
||||
const [presetsHydrated, setPresetsHydrated] = useState(false);
|
||||
@@ -2371,43 +2378,64 @@ function TeamEditor({
|
||||
setSelectedTeamIds(new Set());
|
||||
};
|
||||
const exportTeam = async (team: TeamToolValue) => {
|
||||
let serialized: string;
|
||||
try {
|
||||
await navigator.clipboard.writeText(serializeTeamTransfer(team));
|
||||
toast.success(`คัดลอก ${team.title} แล้ว`);
|
||||
serialized = serializeTeamTransfer(team);
|
||||
} catch {
|
||||
toast.error("Export Team ไม่สำเร็จ กรุณาตรวจสอบข้อมูลและสิทธิ์ Clipboard");
|
||||
}
|
||||
};
|
||||
const importTeam = () => {
|
||||
if (teams.length >= 30) {
|
||||
toast.error("Guide มี Team ครบ 30 Team แล้ว");
|
||||
toast.error("Export Team ไม่สำเร็จ กรุณาตรวจสอบข้อมูล Team");
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(new Blob([serialized], { type: "application/json" }));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = teamTransferFileName(team.title);
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
try {
|
||||
const imported = parseTeamTransfer(importValue);
|
||||
const catalogIssue = findInvalidTeamCatalogReference(
|
||||
imported,
|
||||
characters,
|
||||
weapons,
|
||||
artifacts,
|
||||
);
|
||||
if (catalogIssue) {
|
||||
toast.error(catalogIssue);
|
||||
await navigator.clipboard.writeText(serialized);
|
||||
toast.success(`คัดลอกและดาวน์โหลด ${team.title} แล้ว`);
|
||||
} catch {
|
||||
toast.success(`ดาวน์โหลด ${teamTransferFileName(team.title)} แล้ว`);
|
||||
}
|
||||
};
|
||||
const importTeam = async () => {
|
||||
try {
|
||||
const serializedTeams = [
|
||||
...(importValue.trim() ? [importValue] : []),
|
||||
...(await Promise.all(importFiles.map((file) => file.text()))),
|
||||
];
|
||||
if (!serializedTeams.length) return;
|
||||
if (teams.length + serializedTeams.length > 30) {
|
||||
toast.error("Import ไม่ได้ เพราะ Guide จะมี Team เกิน 30 Team");
|
||||
return;
|
||||
}
|
||||
const importedTeams = serializedTeams.map(parseTeamTransfer);
|
||||
for (const imported of importedTeams) {
|
||||
const catalogIssue = findInvalidTeamCatalogReference(
|
||||
imported,
|
||||
characters,
|
||||
weapons,
|
||||
artifacts,
|
||||
);
|
||||
if (catalogIssue) {
|
||||
toast.error(catalogIssue);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setTeams((current) => [
|
||||
...current,
|
||||
{
|
||||
...importedTeams.map((imported) => ({
|
||||
clientId: createClientId("team"),
|
||||
...imported,
|
||||
members: imported.members.map((member) => ({ ...member })),
|
||||
},
|
||||
})),
|
||||
]);
|
||||
setImportValue("");
|
||||
setImportFiles([]);
|
||||
setImportOpen(false);
|
||||
toast.success(`Import ${imported.title} แล้ว`);
|
||||
toast.success(`Import ${importedTeams.length} Team แล้ว`);
|
||||
} catch {
|
||||
toast.error("JSON ของ Team ไม่ถูกต้องหรือเป็นเวอร์ชันที่ไม่รองรับ");
|
||||
toast.error("มีไฟล์หรือ JSON ของ Team ที่ไม่ถูกต้องหรือเป็นเวอร์ชันที่ไม่รองรับ");
|
||||
}
|
||||
};
|
||||
return (
|
||||
@@ -2420,7 +2448,7 @@ function TeamEditor({
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border bg-muted/30 p-3">
|
||||
<div className="flex min-w-0 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())}
|
||||
@@ -2428,7 +2456,7 @@ function TeamEditor({
|
||||
/>
|
||||
<span className="text-sm font-medium">เลือก Team สำหรับ Multiple Apply</span>
|
||||
<Select value={sourceTeamId} onValueChange={(value) => { if (value) setSourceTeamId(value); }}>
|
||||
<SelectTrigger className="w-48"><span className="truncate">{teams.find((team) => team.clientId === sourceTeamId)?.title || "Source Team"}</span></SelectTrigger>
|
||||
<SelectTrigger className="max-w-full"><span>{teams.find((team) => team.clientId === sourceTeamId)?.title || "Source Team"}</span></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>{teams.map((team) => <SelectItem key={team.clientId} value={team.clientId}>{team.title || "Untitled Team"}</SelectItem>)}</SelectGroup>
|
||||
</SelectContent>
|
||||
@@ -2436,9 +2464,11 @@ function TeamEditor({
|
||||
<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 key={preset.id} className="flex min-w-0 max-w-full items-center gap-1">
|
||||
<Button className="min-w-0 max-w-full truncate" type="button" variant="ghost" size="sm" disabled={!selectedTeamIds.size} onClick={() => applyPreset(preset)}>{preset.name}</Button>
|
||||
<SimpleTooltip text={`ลบ preset ${preset.name}`}>
|
||||
<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>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -2527,22 +2557,25 @@ function TeamEditor({
|
||||
</AccordionTrigger>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{dragHandle}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => void exportTeam(team)}
|
||||
>
|
||||
<ClipboardCopyIcon />
|
||||
<span className="sr-only">Export {team.title || `Team ${teamIndex + 1}`}</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={teams.length >= 30}
|
||||
onClick={() =>
|
||||
setTeams((current) => {
|
||||
<SimpleTooltip text="Export Team">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => void exportTeam(team)}
|
||||
>
|
||||
<ClipboardCopyIcon />
|
||||
<span className="sr-only">Export {team.title || `Team ${teamIndex + 1}`}</span>
|
||||
</Button>
|
||||
</SimpleTooltip>
|
||||
<SimpleTooltip text="ทำสำเนา Team">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={teams.length >= 30}
|
||||
onClick={() =>
|
||||
setTeams((current) => {
|
||||
const sourceIndex = current.findIndex(
|
||||
(item) => item.clientId === team.clientId,
|
||||
);
|
||||
@@ -2568,13 +2601,15 @@ function TeamEditor({
|
||||
0,
|
||||
copy,
|
||||
);
|
||||
})
|
||||
}
|
||||
>
|
||||
<CopyIcon />
|
||||
<span className="sr-only">ทำสำเนา Team</span>
|
||||
</Button>
|
||||
})
|
||||
}
|
||||
>
|
||||
<CopyIcon />
|
||||
<span className="sr-only">ทำสำเนา Team</span>
|
||||
</Button>
|
||||
</SimpleTooltip>
|
||||
<DeleteConfirmationDialog
|
||||
triggerTooltip="ลบ Team"
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
@@ -3115,12 +3150,12 @@ function TeamEditor({
|
||||
/>
|
||||
</CardFooter>
|
||||
<Dialog open={applyOpen} onOpenChange={setApplyOpen}>
|
||||
<DialogContent className="max-h-[90svh] overflow-y-auto sm:max-w-3xl">
|
||||
<DialogContent className="max-h-[90svh] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Multiple Apply</DialogTitle>
|
||||
<DialogDescription>คัดลอกค่าจาก {teams.find((team) => team.clientId === sourceTeamId)?.title || "Source Team"} ไปยัง {selectedTeamIds.size} Team ที่เลือก: {[...selectedTeamIds].map((id) => teams.find((team) => team.clientId === id)?.title || "Untitled Team").join(", ")}</DialogDescription>
|
||||
<DialogDescription className="break-words">คัดลอกค่าจาก {teams.find((team) => team.clientId === sourceTeamId)?.title || "Source Team"} ไปยัง {selectedTeamIds.size} Team ที่เลือก: {[...selectedTeamIds].map((id) => teams.find((team) => team.clientId === id)?.title || "Untitled Team").join(", ")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex min-h-0 flex-col gap-5 overflow-y-auto pr-1">
|
||||
{teams.find((team) => team.clientId === sourceTeamId)?.members.map((member, memberIndex) => {
|
||||
const characterName = characters.find((character) => character.key === member.characterKey)?.name || "No character";
|
||||
return (
|
||||
@@ -3160,28 +3195,43 @@ function TeamEditor({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={importOpen} onOpenChange={setImportOpen}>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-h-[90svh] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Team</DialogTitle>
|
||||
<DialogDescription>
|
||||
วาง JSON ที่ Export จาก Team ใน Guide อื่น ระบบจะเพิ่ม Team ใหม่ต่อท้ายรายการ
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="team-import-json">Team JSON</FieldLabel>
|
||||
<Textarea
|
||||
id="team-import-json"
|
||||
value={importValue}
|
||||
rows={12}
|
||||
placeholder={'{\n "type": "buzz-sheet.team",\n ...\n}'}
|
||||
onChange={(event) => setImportValue(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<FieldGroup className="min-h-0 overflow-y-auto pr-1">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="team-import-json">Team JSON</FieldLabel>
|
||||
<Textarea
|
||||
id="team-import-json"
|
||||
value={importValue}
|
||||
rows={12}
|
||||
placeholder={'{\n "type": "buzz-sheet.team",\n ...\n}'}
|
||||
onChange={(event) => setImportValue(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="team-import-files">เลือกไฟล์ JSON</FieldLabel>
|
||||
<Input
|
||||
id="team-import-files"
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
multiple
|
||||
onChange={(event) => setImportFiles(Array.from(event.target.files ?? []))}
|
||||
/>
|
||||
<FieldDescription>
|
||||
เลือกหลายไฟล์เพื่อ Import หลาย Team พร้อมกันได้
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setImportOpen(false)}>
|
||||
ยกเลิก
|
||||
</Button>
|
||||
<Button type="button" disabled={!importValue.trim()} onClick={importTeam}>
|
||||
<Button type="button" disabled={!importValue.trim() && !importFiles.length} onClick={() => void importTeam()}>
|
||||
Import Team
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
export function SimpleTooltip({
|
||||
text,
|
||||
children,
|
||||
side,
|
||||
...props
|
||||
}: {
|
||||
text?: ReactNode;
|
||||
side?: "top" | "right" | "bottom" | "left" | undefined;
|
||||
children: ReactNode;
|
||||
} & Omit<ComponentProps<typeof Tooltip>, "children">) {
|
||||
return (
|
||||
<Tooltip {...props}>
|
||||
<TooltipTrigger render={<span className="inline-flex" />}>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side}>{text}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
parseTeamTransfer,
|
||||
parseTeamPresets,
|
||||
serializeTeamTransfer,
|
||||
teamTransferFileName,
|
||||
type TeamToolValue,
|
||||
} from "./team-tools";
|
||||
|
||||
@@ -152,6 +153,12 @@ describe("team bulk tools", () => {
|
||||
}))).toThrow();
|
||||
});
|
||||
|
||||
it("uses the team name as a safe JSON download filename", () => {
|
||||
expect(teamTransferFileName("Ineffa Exai")).toBe("Ineffa Exai.json");
|
||||
expect(teamTransferFileName('A/B: Team?')).toBe("A-B- Team-.json");
|
||||
expect(teamTransferFileName("... ")).toBe("team.json");
|
||||
});
|
||||
|
||||
it("reports missing and incompatible imported catalog references", () => {
|
||||
const source = team("source");
|
||||
source.members[0] = {
|
||||
|
||||
@@ -90,6 +90,14 @@ export function serializeTeamTransfer(team: TeamToolValue): string {
|
||||
return JSON.stringify(payload, null, 2);
|
||||
}
|
||||
|
||||
export function teamTransferFileName(teamName: string): string {
|
||||
const safeName = teamName
|
||||
.replace(/[<>:"/\\|?*\u0000-\u001f]/gu, "-")
|
||||
.replace(/[. ]+$/gu, "")
|
||||
.trim();
|
||||
return `${safeName || "team"}.json`;
|
||||
}
|
||||
|
||||
export function parseTeamTransfer(serialized: string): Omit<TeamToolValue, "clientId"> {
|
||||
const payload = teamTransferSchema.parse(JSON.parse(serialized) as unknown);
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user