feat(guides) : add team transfer and visibility controls
CI / Verify (push) Successful in 1m5s
CI / Build immutable images and deploy (push) Successful in 1m55s

This commit is contained in:
2026-09-04 19:46:46 +07:00 Unverified
parent 50a2a5428d
commit dd87510d68
15 changed files with 3972 additions and 19 deletions
+183 -9
View File
@@ -3,6 +3,8 @@
import Image from "next/image";
import {
ChartNoAxesCombinedIcon,
ClipboardCopyIcon,
ClipboardPasteIcon,
CopyIcon,
GemIcon,
GripVerticalIcon,
@@ -129,9 +131,12 @@ import { rarityGradientClass } from "@/lib/catalog/rarity";
import {
applyPresetFields,
applyTeamFields,
findInvalidTeamCatalogReference,
findUnsupportedTeamWeapon,
limitPresets,
parseTeamPresets,
parseTeamTransfer,
serializeTeamTransfer,
teamPresetCookieKey,
teamPresetStorageKey,
type TeamApplySelection,
@@ -719,6 +724,44 @@ function SortableTeamItem({
);
}
function SortableTeamMember({
id,
children,
}: {
id: string;
children: (dragHandle: ReactNode) => ReactNode;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
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>,
)}
</div>
);
}
export function GuideSectionEditor(props: GuideSectionEditorProps) {
return (
<div className="flex flex-col gap-6">
@@ -2124,6 +2167,8 @@ function TeamEditor({
const [sourceTeamId, setSourceTeamId] = useState(teams[0]?.clientId ?? "");
const [applyOpen, setApplyOpen] = useState(false);
const [presetOpen, setPresetOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importValue, setImportValue] = useState("");
const [presetName, setPresetName] = useState("");
const [presets, setPresets] = useState<TeamPreset[]>([]);
const [presetsHydrated, setPresetsHydrated] = useState(false);
@@ -2224,6 +2269,25 @@ function TeamEditor({
return from === -1 || to === -1 ? current : arrayMove(current, from, to);
});
};
const handleTeamMemberDragEnd = (
teamId: string,
{ active, over }: DragEndEvent,
) => {
if (!over || active.id === over.id) return;
setTeams((current) =>
current.map((team) => {
if (team.clientId !== teamId) return team;
const memberIds = team.members.map(
(_, index) => `${team.clientId}-member-${index}`,
);
const from = memberIds.indexOf(String(active.id));
const to = memberIds.indexOf(String(over.id));
return from === -1 || to === -1
? team
: { ...team, members: arrayMove(team.members, from, to) };
}),
);
};
const toggleTeamSelection = (teamId: string, checked: boolean) => {
setSelectedTeamIds((current) => {
const next = new Set(current);
@@ -2306,6 +2370,46 @@ function TeamEditor({
setTeams(nextTeams);
setSelectedTeamIds(new Set());
};
const exportTeam = async (team: TeamToolValue) => {
try {
await navigator.clipboard.writeText(serializeTeamTransfer(team));
toast.success(`คัดลอก ${team.title} แล้ว`);
} catch {
toast.error("Export Team ไม่สำเร็จ กรุณาตรวจสอบข้อมูลและสิทธิ์ Clipboard");
}
};
const importTeam = () => {
if (teams.length >= 30) {
toast.error("Guide มี Team ครบ 30 Team แล้ว");
return;
}
try {
const imported = parseTeamTransfer(importValue);
const catalogIssue = findInvalidTeamCatalogReference(
imported,
characters,
weapons,
artifacts,
);
if (catalogIssue) {
toast.error(catalogIssue);
return;
}
setTeams((current) => [
...current,
{
clientId: createClientId("team"),
...imported,
members: imported.members.map((member) => ({ ...member })),
},
]);
setImportValue("");
setImportOpen(false);
toast.success(`Import ${imported.title} แล้ว`);
} catch {
toast.error("JSON ของ Team ไม่ถูกต้องหรือเป็นเวอร์ชันที่ไม่รองรับ");
}
};
return (
<Card>
<CardHeader>
@@ -2423,6 +2527,15 @@ 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"
@@ -2506,8 +2619,22 @@ function TeamEditor({
}
/>
</Field>
<div className="grid gap-4 md:grid-cols-2">
{team.members.map((member, memberIndex) => {
<DndContext
sensors={teamSensors}
collisionDetection={closestCenter}
onDragEnd={(event) =>
handleTeamMemberDragEnd(team.clientId, event)
}
>
<SortableContext
items={team.members.map(
(_, index) =>
`${team.clientId}-member-${index}`,
)}
strategy={rectSortingStrategy}
>
<div className="grid gap-4 md:grid-cols-2">
{team.members.map((member, memberIndex) => {
const character = characters.find(
(item) => item.key === member.characterKey,
);
@@ -2519,7 +2646,12 @@ function TeamEditor({
),
);
return (
<Card key={memberIndex} size="sm" data-invalid={memberFieldInvalid(teamIndex, memberIndex, "members")}>
<SortableTeamMember
key={`${team.clientId}-member-${memberIndex}`}
id={`${team.clientId}-member-${memberIndex}`}
>
{(memberDragHandle) => (
<Card size="sm" data-invalid={memberFieldInvalid(teamIndex, memberIndex, "members")}>
<CardHeader>
<CardTitle>
ตัวละคร {memberIndex + 1}
@@ -2529,8 +2661,8 @@ function TeamEditor({
? `${(((Number(member.damage) || 0) / total) * 100).toFixed(2)}% ของ Team`
: "0% ของ Team"}
</CardDescription>
{character ? (
<CardAction>
<CardAction className="flex items-center gap-1">
{character ? (
<Avatar>
<AvatarImage
src={character.imageUrl}
@@ -2542,8 +2674,9 @@ function TeamEditor({
.toUpperCase()}
</AvatarFallback>
</Avatar>
</CardAction>
) : null}
) : null}
{memberDragHandle}
</CardAction>
</CardHeader>
<CardContent>
<FieldGroup>
@@ -2873,9 +3006,13 @@ function TeamEditor({
</FieldGroup>
</CardContent>
</Card>
)}
</SortableTeamMember>
);
})}
</div>
})}
</div>
</SortableContext>
</DndContext>
<Field data-invalid={teamFieldInvalid(teamIndex, "rotationSeconds")}>
<FieldLabel className="flex items-center gap-2">
<TimerIcon />
@@ -2956,6 +3093,15 @@ function TeamEditor({
<PlusIcon data-icon="inline-start" />
เพิ่ม Team
</Button>
<Button
type="button"
variant="outline"
disabled={teams.length >= 30}
onClick={() => setImportOpen(true)}
>
<ClipboardPasteIcon data-icon="inline-start" />
Import Team
</Button>
<AutosaveStatus
state={autosave.state}
issues={
@@ -3013,6 +3159,34 @@ function TeamEditor({
<DialogFooter><Button type="button" variant="outline" onClick={() => setApplyOpen(false)}>ยกเลิก</Button><Button type="button" disabled={!applyFields.length} onClick={applyChanges}>ยืนยันการคัดลอก</Button></DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={importOpen} onOpenChange={setImportOpen}>
<DialogContent>
<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>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setImportOpen(false)}>
ยกเลิก
</Button>
<Button type="button" disabled={!importValue.trim()} onClick={importTeam}>
Import Team
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={presetOpen} onOpenChange={setPresetOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Save team preset</DialogTitle><DialogDescription>บันทึก Source Team นี้ไว้ใช้กับ Guide นี้ในเบราว์เซอร์นี้</DialogDescription></DialogHeader>
@@ -0,0 +1,97 @@
"use client";
import { EyeIcon, EyeOffIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { toast } from "sonner";
import { setStructuredGuideVisibility } from "@/app/admin/actions";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
export function GuideVisibilityControl({
guideId,
guideName,
isPublic,
className,
}: {
guideId: string;
guideName: string;
isPublic: boolean;
className?: string;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const updateVisibility = (nextPublic: boolean) => {
startTransition(async () => {
const result = await setStructuredGuideVisibility(guideId, nextPublic);
if (result.status !== "saved") {
toast.error(result.message ?? "เปลี่ยนสถานะ Guide ไม่สำเร็จ");
return;
}
toast.success(nextPublic ? "เผยแพร่ Guide แล้ว" : "เปลี่ยน Guide เป็น Private แล้ว");
router.refresh();
});
};
return (
<div className={cn("flex flex-wrap items-center gap-2", className)}>
<Badge variant={isPublic ? "default" : "secondary"}>
{isPublic ? "Public" : "Private"}
</Badge>
{isPublic ? (
<AlertDialog>
<AlertDialogTrigger
render={
<Button type="button" variant="outline" size="sm" disabled={pending} />
}
>
{pending ? <Spinner data-icon="inline-start" /> : <EyeOffIcon data-icon="inline-start" />}
Make private
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>เปลี่ยน {guideName} เป็น Private?</AlertDialogTitle>
<AlertDialogDescription>
Guide จะหายจากหน้าเว็บไซต์และลิงก์สาธารณะจะเปิดไม่ได้ แต่ยังแก้ไขได้ใน Admin
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>ยกเลิก</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => updateVisibility(false)}
>
ยืนยัน Private
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : (
<Button
type="button"
size="sm"
disabled={pending}
onClick={() => updateVisibility(true)}
>
{pending ? <Spinner data-icon="inline-start" /> : <EyeIcon data-icon="inline-start" />}
Publish
</Button>
)}
</div>
);
}
+9 -1
View File
@@ -19,6 +19,7 @@ import {
startCatalogSync,
} from "@/app/admin/actions";
import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog";
import { GuideVisibilityControl } from "@/components/admin/guide-visibility-control";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -48,6 +49,7 @@ type GuideCard = {
version: number;
updatedAt: Date;
trashedAt: Date | null;
isPublic: boolean;
};
export function StructuredDashboard({
@@ -160,7 +162,7 @@ export function StructuredDashboard({
{new Date(guide.updatedAt).toLocaleString("th-TH")}
</p>
</CardContent>
<CardFooter className="justify-between pb-5">
<CardFooter className="flex-wrap justify-between gap-2 pb-5">
<Button
className="relative z-20"
render={<Link href={`/admin/${guide.characterKey}`} />}
@@ -168,6 +170,12 @@ export function StructuredDashboard({
>
แก้ไข Guide
</Button>
<GuideVisibilityControl
className="relative z-20"
guideId={guide.id}
guideName={guide.name}
isPublic={guide.isPublic}
/>
<DeleteConfirmationDialog
showSuccessToast={false}
trigger={