feat(guides) : add team transfer and visibility controls
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
Reference in New Issue
Block a user