feat : update
CI / Verify (push) Successful in 1m16s
CI / Build immutable images and deploy (push) Successful in 2m5s

This commit is contained in:
2026-09-04 20:04:06 +07:00 Unverified
parent dd87510d68
commit 06d0287433
5 changed files with 185 additions and 86 deletions
@@ -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 ? (
triggerTooltip ? (
<SimpleTooltip text={triggerTooltip}>
<AlertDialogTrigger render={trigger}>{children}</AlertDialogTrigger>
</SimpleTooltip>
) : (
<AlertDialogTrigger render={trigger}>{children}</AlertDialogTrigger>
)
) : null}
<AlertDialogContent>
<AlertDialogHeader>
+74 -24
View File
@@ -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>
+25
View File
@@ -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>
);
}
+7
View File
@@ -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] = {
+8
View File
@@ -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 {