|
|
|
@@ -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,6 +710,7 @@ function SortableTeamItem({
|
|
|
|
|
className={cn("rounded-xl border px-4", isDragging && "opacity-60")}
|
|
|
|
|
>
|
|
|
|
|
{children(
|
|
|
|
|
<SimpleTooltip text="ลากเพื่อจัดลำดับ Team">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
@@ -718,7 +721,8 @@ function SortableTeamItem({
|
|
|
|
|
>
|
|
|
|
|
<GripVerticalIcon />
|
|
|
|
|
<span className="sr-only">ลากเพื่อจัดลำดับ Team</span>
|
|
|
|
|
</Button>,
|
|
|
|
|
</Button>
|
|
|
|
|
</SimpleTooltip>,
|
|
|
|
|
)}
|
|
|
|
|
</AccordionItem>
|
|
|
|
|
);
|
|
|
|
@@ -746,6 +750,7 @@ function SortableTeamMember({
|
|
|
|
|
className={cn(isDragging && "opacity-60")}
|
|
|
|
|
>
|
|
|
|
|
{children(
|
|
|
|
|
<SimpleTooltip text="ลากเพื่อย้ายตำแหน่งตัวละคร">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
@@ -756,7 +761,8 @@ function SortableTeamMember({
|
|
|
|
|
>
|
|
|
|
|
<GripVerticalIcon />
|
|
|
|
|
<span className="sr-only">ลากเพื่อย้ายตำแหน่งตัวละคร</span>
|
|
|
|
|
</Button>,
|
|
|
|
|
</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,20 +2378,39 @@ 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);
|
|
|
|
|
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,
|
|
|
|
@@ -2395,19 +2421,21 @@ function TeamEditor({
|
|
|
|
|
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>
|
|
|
|
|
<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,6 +2557,7 @@ function TeamEditor({
|
|
|
|
|
</AccordionTrigger>
|
|
|
|
|
<div className="ml-auto flex shrink-0 items-center gap-1">
|
|
|
|
|
{dragHandle}
|
|
|
|
|
<SimpleTooltip text="Export Team">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
@@ -2536,6 +2567,8 @@ function TeamEditor({
|
|
|
|
|
<ClipboardCopyIcon />
|
|
|
|
|
<span className="sr-only">Export {team.title || `Team ${teamIndex + 1}`}</span>
|
|
|
|
|
</Button>
|
|
|
|
|
</SimpleTooltip>
|
|
|
|
|
<SimpleTooltip text="ทำสำเนา Team">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
@@ -2574,7 +2607,9 @@ function TeamEditor({
|
|
|
|
|
<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,13 +3195,14 @@ 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>
|
|
|
|
|
<FieldGroup className="min-h-0 overflow-y-auto pr-1">
|
|
|
|
|
<Field>
|
|
|
|
|
<FieldLabel htmlFor="team-import-json">Team JSON</FieldLabel>
|
|
|
|
|
<Textarea
|
|
|
|
@@ -3177,11 +3213,25 @@ function TeamEditor({
|
|
|
|
|
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>
|
|
|
|
|