feat(guides) : add team transfer and visibility controls
This commit is contained in:
@@ -8,7 +8,7 @@ import { GuideSectionNav } from "@/components/admin/guide-section-nav";
|
|||||||
import { AdminHeader } from "@/components/admin/admin-header";
|
import { AdminHeader } from "@/components/admin/admin-header";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { getAdminSession } from "@/lib/auth/server";
|
import { getAdminSession } from "@/lib/auth/server";
|
||||||
import { getActiveCatalog, getGuideByCharacterKey, getPublicGuide } from "@/lib/guides/queries";
|
import { getActiveCatalog, getAdminGuideContent, getGuideByCharacterKey } from "@/lib/guides/queries";
|
||||||
|
|
||||||
const sections = ["weapon", "artifact", "constellations", "team", "extra"] as const;
|
const sections = ["weapon", "artifact", "constellations", "team", "extra"] as const;
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export default async function StructuredEditorPage({ params }: { params: Promise
|
|||||||
if (!sections.includes(page as (typeof sections)[number])) notFound();
|
if (!sections.includes(page as (typeof sections)[number])) notFound();
|
||||||
const guide = await getGuideByCharacterKey(character);
|
const guide = await getGuideByCharacterKey(character);
|
||||||
if (!guide) notFound();
|
if (!guide) notFound();
|
||||||
const [catalog, content] = await Promise.all([getActiveCatalog(), getPublicGuide(guide.slug)]);
|
const [catalog, content] = await Promise.all([getActiveCatalog(), getAdminGuideContent(guide.slug)]);
|
||||||
if (!catalog || !content) notFound();
|
if (!catalog || !content) notFound();
|
||||||
const guideCharacter = catalog.characters.find((item) => item.key === guide.characterKey);
|
const guideCharacter = catalog.characters.find((item) => item.key === guide.characterKey);
|
||||||
return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6 lg:p-8"><header className="flex items-center gap-4"><Button variant="outline" size="icon" render={<Link href={`/admin/${character}`} />} nativeButton={false} aria-label="กลับหน้า Guide"><ArrowLeftIcon /></Button><div className="min-w-0"><p className="text-sm text-muted-foreground">{guide.name}</p><h1 className="whitespace-nowrap font-heading text-3xl font-semibold">{guide.sections.find((section) => section.slug === page)?.title ?? page}</h1></div></header><GuideSectionNav characterKey={guide.characterKey} current={page as (typeof sections)[number]} /><GuideSectionEditor section={page as (typeof sections)[number]} guide={guide} weapons={catalog.weapons} artifacts={catalog.artifacts} characters={catalog.characters} catalogConstellations={guideCharacter?.constellations ?? []} catalogTalents={guideCharacter?.talents ?? []} initial={{ sectionNote: guide.sections.find((section) => section.slug === page)?.note ?? null, weaponGroups: content.weaponGroups, artifactGroups: content.artifactGroups.map((group) => ({ id: group.id, name: group.name, rows: group.rows.map((row) => ({ firstArtifactKey: row.firstArtifactKey, secondArtifactKey: row.secondArtifactKey, percent: row.percent, note: row.note })) })), artifactProfile: content.artifactProfile, constellationGroups: content.constellationGroups.map((group) => ({ id: group.id, note: group.note, rows: group.rows })), teams: content.teams.map((team) => ({ id: team.id, title: team.title, rotationSeconds: team.rotationSeconds, combo: team.combo, members: team.members.map((member) => ({ characterKey: member.characterKey, weaponKey: member.weaponKey, artifactKey: member.artifactKey, secondArtifactKey: member.secondArtifactKey, constellation: member.constellation, refinement: member.refinement, damage: member.damage })) })), extras: content.extras.map((section) => ({ title: section.title, rows: section.rows.map((row) => ({ mediaId: row.mediaId, ratio: row.ratio === "1:1" ? "1:1" : "16:9", heading: row.heading, body: row.body, percent: row.percent, imageUrl: row.imageUrl })) })) }} /></main></div>;
|
return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6 lg:p-8"><header className="flex items-center gap-4"><Button variant="outline" size="icon" render={<Link href={`/admin/${character}`} />} nativeButton={false} aria-label="กลับหน้า Guide"><ArrowLeftIcon /></Button><div className="min-w-0"><p className="text-sm text-muted-foreground">{guide.name}</p><h1 className="whitespace-nowrap font-heading text-3xl font-semibold">{guide.sections.find((section) => section.slug === page)?.title ?? page}</h1></div></header><GuideSectionNav characterKey={guide.characterKey} current={page as (typeof sections)[number]} /><GuideSectionEditor section={page as (typeof sections)[number]} guide={guide} weapons={catalog.weapons} artifacts={catalog.artifacts} characters={catalog.characters} catalogConstellations={guideCharacter?.constellations ?? []} catalogTalents={guideCharacter?.talents ?? []} initial={{ sectionNote: guide.sections.find((section) => section.slug === page)?.note ?? null, weaponGroups: content.weaponGroups, artifactGroups: content.artifactGroups.map((group) => ({ id: group.id, name: group.name, rows: group.rows.map((row) => ({ firstArtifactKey: row.firstArtifactKey, secondArtifactKey: row.secondArtifactKey, percent: row.percent, note: row.note })) })), artifactProfile: content.artifactProfile, constellationGroups: content.constellationGroups.map((group) => ({ id: group.id, note: group.note, rows: group.rows })), teams: content.teams.map((team) => ({ id: team.id, title: team.title, rotationSeconds: team.rotationSeconds, combo: team.combo, members: team.members.map((member) => ({ characterKey: member.characterKey, weaponKey: member.weaponKey, artifactKey: member.artifactKey, secondArtifactKey: member.secondArtifactKey, constellation: member.constellation, refinement: member.refinement, damage: member.damage })) })), extras: content.extras.map((section) => ({ title: section.title, rows: section.rows.map((row) => ({ mediaId: row.mediaId, ratio: row.ratio === "1:1" ? "1:1" : "16:9", heading: row.heading, body: row.body, percent: row.percent, imageUrl: row.imageUrl })) })) }} /></main></div>;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardAction, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardAction, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { AdminHeader } from "@/components/admin/admin-header";
|
import { AdminHeader } from "@/components/admin/admin-header";
|
||||||
import { GuideOverviewForm } from "@/components/admin/guide-overview-form";
|
import { GuideOverviewForm } from "@/components/admin/guide-overview-form";
|
||||||
|
import { GuideVisibilityControl } from "@/components/admin/guide-visibility-control";
|
||||||
import { GuideSectionNav } from "@/components/admin/guide-section-nav";
|
import { GuideSectionNav } from "@/components/admin/guide-section-nav";
|
||||||
import { getAdminSession } from "@/lib/auth/server";
|
import { getAdminSession } from "@/lib/auth/server";
|
||||||
import { getGuideByCharacterKey } from "@/lib/guides/queries";
|
import { getGuideByCharacterKey } from "@/lib/guides/queries";
|
||||||
@@ -26,5 +27,5 @@ export default async function GuideOverviewPage({ params }: { params: Promise<{
|
|||||||
const { character } = await params;
|
const { character } = await params;
|
||||||
const guide = await getGuideByCharacterKey(character);
|
const guide = await getGuideByCharacterKey(character);
|
||||||
if (!guide) notFound();
|
if (!guide) notFound();
|
||||||
return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-6xl flex-col gap-8 p-4 sm:p-6 lg:p-8"><header className="flex flex-wrap items-center gap-4"><Button variant="outline" size="icon" render={<Link href="/admin" />} nativeButton={false} aria-label="กลับ Dashboard"><ArrowLeftIcon /></Button><div className="relative size-16 overflow-hidden rounded-lg border"><Image src={guide.coverUrl} alt={guide.name} fill className="object-cover" /></div><div><p className="text-sm text-muted-foreground">เลือกส่วนที่ต้องการแก้ไข</p><h1 className="font-heading text-3xl font-semibold">{guide.name}</h1></div></header><GuideSectionNav characterKey={guide.characterKey} current="overview" /><GuideOverviewForm guide={guide} /><div className="grid gap-4 md:grid-cols-2">{sections.map(({ slug, title, description }) => <Link key={slug} href={`/admin/${guide.characterKey}/${slug}`} prefetch className="rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"><Card className="h-full transition-colors hover:bg-muted/50"><CardHeader>{navigationIconPath(slug) ? <Image src={navigationIconPath(slug)!} alt="" width={40} height={40} className="size-10 object-contain" /> : null}<CardTitle>{title}</CardTitle><CardDescription>{description}</CardDescription><CardAction><ArrowUpRightIcon aria-hidden="true" className="size-5 text-primary" /></CardAction></CardHeader></Card></Link>)}<Link href={`/admin/${guide.characterKey}/extra`} prefetch className="rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"><Card className="h-full transition-colors hover:bg-muted/50"><CardHeader><Image src={navigationIconPath("extra")!} alt="" width={40} height={40} className="size-10 object-contain" /><CardTitle>Extra sections</CardTitle><CardDescription>ส่วนเสริมแบบภาพ หัวข้อ ข้อความสั้น และเปอร์เซ็นต์</CardDescription><CardAction><ArrowUpRightIcon aria-hidden="true" className="size-5 text-primary" /></CardAction></CardHeader></Card></Link></div></main></div>;
|
return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-6xl flex-col gap-8 p-4 sm:p-6 lg:p-8"><header className="flex flex-wrap items-center gap-4"><Button variant="outline" size="icon" render={<Link href="/admin" />} nativeButton={false} aria-label="กลับ Dashboard"><ArrowLeftIcon /></Button><div className="relative size-16 overflow-hidden rounded-lg border"><Image src={guide.coverUrl} alt={guide.name} fill className="object-cover" /></div><div className="min-w-0 flex-1"><p className="text-sm text-muted-foreground">เลือกส่วนที่ต้องการแก้ไข</p><h1 className="font-heading text-3xl font-semibold">{guide.name}</h1></div><GuideVisibilityControl guideId={guide.id} guideName={guide.name} isPublic={guide.isPublic} /></header><GuideSectionNav characterKey={guide.characterKey} current="overview" /><GuideOverviewForm guide={guide} /><div className="grid gap-4 md:grid-cols-2">{sections.map(({ slug, title, description }) => <Link key={slug} href={`/admin/${guide.characterKey}/${slug}`} prefetch className="rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"><Card className="h-full transition-colors hover:bg-muted/50"><CardHeader>{navigationIconPath(slug) ? <Image src={navigationIconPath(slug)!} alt="" width={40} height={40} className="size-10 object-contain" /> : null}<CardTitle>{title}</CardTitle><CardDescription>{description}</CardDescription><CardAction><ArrowUpRightIcon aria-hidden="true" className="size-5 text-primary" /></CardAction></CardHeader></Card></Link>)}<Link href={`/admin/${guide.characterKey}/extra`} prefetch className="rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"><Card className="h-full transition-colors hover:bg-muted/50"><CardHeader><Image src={navigationIconPath("extra")!} alt="" width={40} height={40} className="size-10 object-contain" /><CardTitle>Extra sections</CardTitle><CardDescription>ส่วนเสริมแบบภาพ หัวข้อ ข้อความสั้น และเปอร์เซ็นต์</CardDescription><CardAction><ArrowUpRightIcon aria-hidden="true" className="size-5 text-primary" /></CardAction></CardHeader></Card></Link></div></main></div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
saveConstellations,
|
saveConstellations,
|
||||||
saveExtraSections,
|
saveExtraSections,
|
||||||
saveTeams,
|
saveTeams,
|
||||||
|
setGuideVisibility,
|
||||||
saveWeapons,
|
saveWeapons,
|
||||||
trashGuide,
|
trashGuide,
|
||||||
updateGuideBasics,
|
updateGuideBasics,
|
||||||
@@ -275,6 +276,14 @@ export async function restoreStructuredGuide(guideId: string) {
|
|||||||
return runGuideAction(() => restoreGuide(guideId));
|
return runGuideAction(() => restoreGuide(guideId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setStructuredGuideVisibility(
|
||||||
|
guideId: string,
|
||||||
|
isPublic: boolean,
|
||||||
|
) {
|
||||||
|
await requireAdmin();
|
||||||
|
return runGuideAction(() => setGuideVisibility(guideId, isPublic));
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteStructuredGuide(guideId: string) {
|
export async function deleteStructuredGuide(guideId: string) {
|
||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
return runGuideAction(() => permanentlyDeleteGuide(guideId));
|
return runGuideAction(() => permanentlyDeleteGuide(guideId));
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import {
|
import {
|
||||||
ChartNoAxesCombinedIcon,
|
ChartNoAxesCombinedIcon,
|
||||||
|
ClipboardCopyIcon,
|
||||||
|
ClipboardPasteIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
GemIcon,
|
GemIcon,
|
||||||
GripVerticalIcon,
|
GripVerticalIcon,
|
||||||
@@ -129,9 +131,12 @@ import { rarityGradientClass } from "@/lib/catalog/rarity";
|
|||||||
import {
|
import {
|
||||||
applyPresetFields,
|
applyPresetFields,
|
||||||
applyTeamFields,
|
applyTeamFields,
|
||||||
|
findInvalidTeamCatalogReference,
|
||||||
findUnsupportedTeamWeapon,
|
findUnsupportedTeamWeapon,
|
||||||
limitPresets,
|
limitPresets,
|
||||||
parseTeamPresets,
|
parseTeamPresets,
|
||||||
|
parseTeamTransfer,
|
||||||
|
serializeTeamTransfer,
|
||||||
teamPresetCookieKey,
|
teamPresetCookieKey,
|
||||||
teamPresetStorageKey,
|
teamPresetStorageKey,
|
||||||
type TeamApplySelection,
|
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) {
|
export function GuideSectionEditor(props: GuideSectionEditorProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -2124,6 +2167,8 @@ function TeamEditor({
|
|||||||
const [sourceTeamId, setSourceTeamId] = useState(teams[0]?.clientId ?? "");
|
const [sourceTeamId, setSourceTeamId] = useState(teams[0]?.clientId ?? "");
|
||||||
const [applyOpen, setApplyOpen] = useState(false);
|
const [applyOpen, setApplyOpen] = useState(false);
|
||||||
const [presetOpen, setPresetOpen] = useState(false);
|
const [presetOpen, setPresetOpen] = useState(false);
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
|
const [importValue, setImportValue] = useState("");
|
||||||
const [presetName, setPresetName] = useState("");
|
const [presetName, setPresetName] = useState("");
|
||||||
const [presets, setPresets] = useState<TeamPreset[]>([]);
|
const [presets, setPresets] = useState<TeamPreset[]>([]);
|
||||||
const [presetsHydrated, setPresetsHydrated] = useState(false);
|
const [presetsHydrated, setPresetsHydrated] = useState(false);
|
||||||
@@ -2224,6 +2269,25 @@ function TeamEditor({
|
|||||||
return from === -1 || to === -1 ? current : arrayMove(current, from, to);
|
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) => {
|
const toggleTeamSelection = (teamId: string, checked: boolean) => {
|
||||||
setSelectedTeamIds((current) => {
|
setSelectedTeamIds((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
@@ -2306,6 +2370,46 @@ function TeamEditor({
|
|||||||
setTeams(nextTeams);
|
setTeams(nextTeams);
|
||||||
setSelectedTeamIds(new Set());
|
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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -2423,6 +2527,15 @@ function TeamEditor({
|
|||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||||
{dragHandle}
|
{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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -2506,8 +2619,22 @@ function TeamEditor({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<DndContext
|
||||||
{team.members.map((member, memberIndex) => {
|
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(
|
const character = characters.find(
|
||||||
(item) => item.key === member.characterKey,
|
(item) => item.key === member.characterKey,
|
||||||
);
|
);
|
||||||
@@ -2519,7 +2646,12 @@ function TeamEditor({
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
return (
|
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>
|
<CardHeader>
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
ตัวละคร {memberIndex + 1}
|
ตัวละคร {memberIndex + 1}
|
||||||
@@ -2529,8 +2661,8 @@ function TeamEditor({
|
|||||||
? `${(((Number(member.damage) || 0) / total) * 100).toFixed(2)}% ของ Team`
|
? `${(((Number(member.damage) || 0) / total) * 100).toFixed(2)}% ของ Team`
|
||||||
: "0% ของ Team"}
|
: "0% ของ Team"}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
{character ? (
|
<CardAction className="flex items-center gap-1">
|
||||||
<CardAction>
|
{character ? (
|
||||||
<Avatar>
|
<Avatar>
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src={character.imageUrl}
|
src={character.imageUrl}
|
||||||
@@ -2542,8 +2674,9 @@ function TeamEditor({
|
|||||||
.toUpperCase()}
|
.toUpperCase()}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</CardAction>
|
) : null}
|
||||||
) : null}
|
{memberDragHandle}
|
||||||
|
</CardAction>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
@@ -2873,9 +3006,13 @@ function TeamEditor({
|
|||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
</SortableTeamMember>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
<Field data-invalid={teamFieldInvalid(teamIndex, "rotationSeconds")}>
|
<Field data-invalid={teamFieldInvalid(teamIndex, "rotationSeconds")}>
|
||||||
<FieldLabel className="flex items-center gap-2">
|
<FieldLabel className="flex items-center gap-2">
|
||||||
<TimerIcon />
|
<TimerIcon />
|
||||||
@@ -2956,6 +3093,15 @@ function TeamEditor({
|
|||||||
<PlusIcon data-icon="inline-start" />
|
<PlusIcon data-icon="inline-start" />
|
||||||
เพิ่ม Team
|
เพิ่ม Team
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={teams.length >= 30}
|
||||||
|
onClick={() => setImportOpen(true)}
|
||||||
|
>
|
||||||
|
<ClipboardPasteIcon data-icon="inline-start" />
|
||||||
|
Import Team
|
||||||
|
</Button>
|
||||||
<AutosaveStatus
|
<AutosaveStatus
|
||||||
state={autosave.state}
|
state={autosave.state}
|
||||||
issues={
|
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>
|
<DialogFooter><Button type="button" variant="outline" onClick={() => setApplyOpen(false)}>ยกเลิก</Button><Button type="button" disabled={!applyFields.length} onClick={applyChanges}>ยืนยันการคัดลอก</Button></DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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}>
|
<Dialog open={presetOpen} onOpenChange={setPresetOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader><DialogTitle>Save team preset</DialogTitle><DialogDescription>บันทึก Source Team นี้ไว้ใช้กับ Guide นี้ในเบราว์เซอร์นี้</DialogDescription></DialogHeader>
|
<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,
|
startCatalogSync,
|
||||||
} from "@/app/admin/actions";
|
} from "@/app/admin/actions";
|
||||||
import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog";
|
import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog";
|
||||||
|
import { GuideVisibilityControl } from "@/components/admin/guide-visibility-control";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -48,6 +49,7 @@ type GuideCard = {
|
|||||||
version: number;
|
version: number;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
trashedAt: Date | null;
|
trashedAt: Date | null;
|
||||||
|
isPublic: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function StructuredDashboard({
|
export function StructuredDashboard({
|
||||||
@@ -160,7 +162,7 @@ export function StructuredDashboard({
|
|||||||
{new Date(guide.updatedAt).toLocaleString("th-TH")}
|
{new Date(guide.updatedAt).toLocaleString("th-TH")}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="justify-between pb-5">
|
<CardFooter className="flex-wrap justify-between gap-2 pb-5">
|
||||||
<Button
|
<Button
|
||||||
className="relative z-20"
|
className="relative z-20"
|
||||||
render={<Link href={`/admin/${guide.characterKey}`} />}
|
render={<Link href={`/admin/${guide.characterKey}`} />}
|
||||||
@@ -168,6 +170,12 @@ export function StructuredDashboard({
|
|||||||
>
|
>
|
||||||
แก้ไข Guide
|
แก้ไข Guide
|
||||||
</Button>
|
</Button>
|
||||||
|
<GuideVisibilityControl
|
||||||
|
className="relative z-20"
|
||||||
|
guideId={guide.id}
|
||||||
|
guideName={guide.name}
|
||||||
|
isPublic={guide.isPublic}
|
||||||
|
/>
|
||||||
<DeleteConfirmationDialog
|
<DeleteConfirmationDialog
|
||||||
showSuccessToast={false}
|
showSuccessToast={false}
|
||||||
trigger={
|
trigger={
|
||||||
|
|||||||
@@ -499,6 +499,7 @@ export const guides = guidesSchema.table(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => media.id, { onDelete: "restrict" }),
|
.references(() => media.id, { onDelete: "restrict" }),
|
||||||
version: integer("version").default(1).notNull(),
|
version: integer("version").default(1).notNull(),
|
||||||
|
isPublic: boolean("is_public").default(false).notNull(),
|
||||||
sortOrder: integer("sort_order").default(0).notNull(),
|
sortOrder: integer("sort_order").default(0).notNull(),
|
||||||
trashedAt: timestamp("trashed_at", { withTimezone: true }),
|
trashedAt: timestamp("trashed_at", { withTimezone: true }),
|
||||||
...timestamps,
|
...timestamps,
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "guides"."guide" ADD COLUMN "is_public" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
UPDATE "guides"."guide" SET "is_public" = true;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -169,6 +169,13 @@
|
|||||||
"when": 1788493890217,
|
"when": 1788493890217,
|
||||||
"tag": "0023_silly_earthquake",
|
"tag": "0023_silly_earthquake",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 24,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788524071533,
|
||||||
|
"tag": "0024_spooky_shadowcat",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Vendored
+2
-2
@@ -3,7 +3,7 @@ import "server-only";
|
|||||||
import { cacheLife, cacheTag } from "next/cache";
|
import { cacheLife, cacheTag } from "next/cache";
|
||||||
|
|
||||||
import { getPublicPage, listPublicCharacters } from "@/lib/content/queries";
|
import { getPublicPage, listPublicCharacters } from "@/lib/content/queries";
|
||||||
import { getPublicGuide, listGuideCards } from "@/lib/guides/queries";
|
import { getPublicGuide, listPublicGuideCards } from "@/lib/guides/queries";
|
||||||
import {
|
import {
|
||||||
characterCacheTag,
|
characterCacheTag,
|
||||||
dataSourceCacheTag,
|
dataSourceCacheTag,
|
||||||
@@ -46,7 +46,7 @@ export async function getCachedPublicGuideCards() {
|
|||||||
"use cache: remote";
|
"use cache: remote";
|
||||||
cacheLife({ stale: 30, revalidate: 60, expire: 60 * 60 });
|
cacheLife({ stale: 30, revalidate: 60, expire: 60 * 60 });
|
||||||
cacheTag(DIRECTORY_CACHE_TAG);
|
cacheTag(DIRECTORY_CACHE_TAG);
|
||||||
return listGuideCards();
|
return listPublicGuideCards();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCachedPublicGuide(slug: string) {
|
export async function getCachedPublicGuide(slug: string) {
|
||||||
|
|||||||
@@ -232,6 +232,19 @@ export async function restoreGuide(guideId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setGuideVisibility(guideId: string, isPublic: boolean) {
|
||||||
|
return getDb().transaction(async (tx) => {
|
||||||
|
const [guide] = await tx
|
||||||
|
.update(guides)
|
||||||
|
.set({ isPublic, version: sql`${guides.version} + 1` })
|
||||||
|
.where(and(eq(guides.id, guideId), sql`${guides.trashedAt} is null`))
|
||||||
|
.returning();
|
||||||
|
if (!guide) throw new Error("ไม่พบ Guide");
|
||||||
|
await emitGuideEvents(tx, guide.id, guide.version);
|
||||||
|
return guide;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function permanentlyDeleteGuide(guideId: string) {
|
export async function permanentlyDeleteGuide(guideId: string) {
|
||||||
return getDb().transaction(async (tx) => {
|
return getDb().transaction(async (tx) => {
|
||||||
const [guide] = await tx.select().from(guides).where(eq(guides.id, guideId)).limit(1);
|
const [guide] = await tx.select().from(guides).where(eq(guides.id, guideId)).limit(1);
|
||||||
|
|||||||
+24
-2
@@ -74,6 +74,16 @@ export async function listGuideCards(includeTrash = false) {
|
|||||||
return rows.map(({ guide, cover }) => ({ ...guide, coverUrl: publicMediaUrl(cover.objectKey) }));
|
return rows.map(({ guide, cover }) => ({ ...guide, coverUrl: publicMediaUrl(cover.objectKey) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listPublicGuideCards() {
|
||||||
|
const rows = await getDb()
|
||||||
|
.select({ guide: guides, cover: media })
|
||||||
|
.from(guides)
|
||||||
|
.innerJoin(media, eq(media.id, guides.coverMediaId))
|
||||||
|
.where(and(eq(guides.isPublic, true), isNull(guides.trashedAt)))
|
||||||
|
.orderBy(asc(guides.sortOrder), asc(guides.name));
|
||||||
|
return rows.map(({ guide, cover }) => ({ ...guide, coverUrl: publicMediaUrl(cover.objectKey) }));
|
||||||
|
}
|
||||||
|
|
||||||
export async function getGuideByCharacterKey(characterKey: string) {
|
export async function getGuideByCharacterKey(characterKey: string) {
|
||||||
const [row] = await getDb()
|
const [row] = await getDb()
|
||||||
.select({ guide: guides, cover: media })
|
.select({ guide: guides, cover: media })
|
||||||
@@ -86,13 +96,17 @@ export async function getGuideByCharacterKey(characterKey: string) {
|
|||||||
return { ...row.guide, coverUrl: publicMediaUrl(row.cover.objectKey), sections };
|
return { ...row.guide, coverUrl: publicMediaUrl(row.cover.objectKey), sections };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPublicGuide(slug: string) {
|
async function getGuideContent(slug: string, publicOnly: boolean) {
|
||||||
const [row] = await getDb()
|
const [row] = await getDb()
|
||||||
.select({ guide: guides, cover: media, character: catalogCharacters })
|
.select({ guide: guides, cover: media, character: catalogCharacters })
|
||||||
.from(guides)
|
.from(guides)
|
||||||
.innerJoin(media, eq(media.id, guides.coverMediaId))
|
.innerJoin(media, eq(media.id, guides.coverMediaId))
|
||||||
.innerJoin(catalogCharacters, eq(catalogCharacters.key, guides.characterKey))
|
.innerJoin(catalogCharacters, eq(catalogCharacters.key, guides.characterKey))
|
||||||
.where(and(eq(guides.slug, slug), isNull(guides.trashedAt)))
|
.where(and(
|
||||||
|
eq(guides.slug, slug),
|
||||||
|
isNull(guides.trashedAt),
|
||||||
|
...(publicOnly ? [eq(guides.isPublic, true)] : []),
|
||||||
|
))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
const [sections, weaponConditionsRows, groups, profile, constellationGroups, teams] = await Promise.all([
|
const [sections, weaponConditionsRows, groups, profile, constellationGroups, teams] = await Promise.all([
|
||||||
@@ -162,4 +176,12 @@ export async function getPublicGuide(slug: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPublicGuide(slug: string) {
|
||||||
|
return getGuideContent(slug, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminGuideContent(slug: string) {
|
||||||
|
return getGuideContent(slug, false);
|
||||||
|
}
|
||||||
|
|
||||||
export type PublicGuide = NonNullable<Awaited<ReturnType<typeof getPublicGuide>>>;
|
export type PublicGuide = NonNullable<Awaited<ReturnType<typeof getPublicGuide>>>;
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
applyTeamFields,
|
applyTeamFields,
|
||||||
|
findInvalidTeamCatalogReference,
|
||||||
findUnsupportedTeamWeapon,
|
findUnsupportedTeamWeapon,
|
||||||
limitPresets,
|
limitPresets,
|
||||||
|
parseTeamTransfer,
|
||||||
parseTeamPresets,
|
parseTeamPresets,
|
||||||
|
serializeTeamTransfer,
|
||||||
type TeamToolValue,
|
type TeamToolValue,
|
||||||
} from "./team-tools";
|
} from "./team-tools";
|
||||||
|
|
||||||
@@ -47,6 +50,50 @@ describe("team bulk tools", () => {
|
|||||||
expect(result[1].members.slice(1)).toEqual(target.members.slice(1));
|
expect(result[1].members.slice(1)).toEqual(target.members.slice(1));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies fields to the matching character in a different slot", () => {
|
||||||
|
const source = team("source");
|
||||||
|
const target = team("target");
|
||||||
|
source.members[1] = {
|
||||||
|
...source.members[1],
|
||||||
|
characterKey: "nicole",
|
||||||
|
weaponKey: "source-weapon",
|
||||||
|
};
|
||||||
|
target.members[3] = {
|
||||||
|
...target.members[3],
|
||||||
|
characterKey: "nicole",
|
||||||
|
weaponKey: "old-weapon",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = applyTeamFields(
|
||||||
|
[source, target],
|
||||||
|
"source",
|
||||||
|
new Set(["target"]),
|
||||||
|
["member:1:weaponKey"],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result[1].members[3].weaponKey).toBe("source-weapon");
|
||||||
|
expect(result[1].members[1]).toEqual(target.members[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the same slot when the character is not present", () => {
|
||||||
|
const source = team("source");
|
||||||
|
const target = team("target");
|
||||||
|
source.members[1] = {
|
||||||
|
...source.members[1],
|
||||||
|
characterKey: "nicole",
|
||||||
|
refinement: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = applyTeamFields(
|
||||||
|
[source, target],
|
||||||
|
"source",
|
||||||
|
new Set(["target"]),
|
||||||
|
["member:1:refinement"],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result[1].members[1].refinement).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
it("detects an unsupported character and weapon after bulk apply", () => {
|
it("detects an unsupported character and weapon after bulk apply", () => {
|
||||||
const target = team("target");
|
const target = team("target");
|
||||||
target.members[0] = {
|
target.members[0] = {
|
||||||
@@ -74,4 +121,56 @@ describe("team bulk tools", () => {
|
|||||||
expect(parseTeamPresets("not-json")).toEqual([]);
|
expect(parseTeamPresets("not-json")).toEqual([]);
|
||||||
expect(parseTeamPresets(JSON.stringify([{ id: 1 }]))).toEqual([]);
|
expect(parseTeamPresets(JSON.stringify([{ id: 1 }]))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("round-trips every exported team field without the client id", () => {
|
||||||
|
const source = team("source", "Ineffa Exai");
|
||||||
|
source.rotationSeconds = "21.5";
|
||||||
|
source.members[0] = {
|
||||||
|
...source.members[0],
|
||||||
|
weaponKey: "weapon-1",
|
||||||
|
artifactKey: "artifact-1",
|
||||||
|
secondArtifactKey: "artifact-2",
|
||||||
|
constellation: 2,
|
||||||
|
refinement: 4,
|
||||||
|
damage: "123456.78",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(parseTeamTransfer(serializeTeamTransfer(source))).toEqual({
|
||||||
|
title: source.title,
|
||||||
|
rotationSeconds: source.rotationSeconds,
|
||||||
|
combo: source.combo,
|
||||||
|
members: source.members,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed and unsupported team transfers", () => {
|
||||||
|
expect(() => parseTeamTransfer("not-json")).toThrow();
|
||||||
|
expect(() => parseTeamTransfer(JSON.stringify({
|
||||||
|
type: "buzz-sheet.team",
|
||||||
|
version: 2,
|
||||||
|
team: team("source"),
|
||||||
|
}))).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports missing and incompatible imported catalog references", () => {
|
||||||
|
const source = team("source");
|
||||||
|
source.members[0] = {
|
||||||
|
...source.members[0],
|
||||||
|
characterKey: "varka",
|
||||||
|
weaponKey: "wolf-fang",
|
||||||
|
};
|
||||||
|
const imported = { ...source, clientId: undefined as never };
|
||||||
|
const characters = [
|
||||||
|
{ key: "varka", name: "Varka", weaponType: "WEAPON_CLAYMORE" },
|
||||||
|
...source.members.slice(1).map((member) => ({ key: member.characterKey, name: member.characterKey })),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(findInvalidTeamCatalogReference(
|
||||||
|
imported,
|
||||||
|
characters,
|
||||||
|
[{ key: "wolf-fang", name: "Wolf-Fang", weaponType: "WEAPON_SWORD_ONE_HAND" }],
|
||||||
|
[],
|
||||||
|
)).toBe("Varka ไม่รองรับอาวุธ Wolf-Fang");
|
||||||
|
expect(findInvalidTeamCatalogReference(imported, [], [], [])).toContain("ไม่พบตัวละคร");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { supportsWeaponType } from "@/lib/catalog/lunaris";
|
import { supportsWeaponType } from "@/lib/catalog/lunaris";
|
||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
export interface TeamToolMember {
|
export interface TeamToolMember {
|
||||||
characterKey: string;
|
characterKey: string;
|
||||||
@@ -42,6 +43,87 @@ export interface TeamPreset {
|
|||||||
team: Omit<TeamToolValue, "clientId">;
|
team: Omit<TeamToolValue, "clientId">;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const transferMemberSchema = z.strictObject({
|
||||||
|
characterKey: z.string().trim().min(1).max(64),
|
||||||
|
weaponKey: z.string().trim().min(1).max(64).nullable(),
|
||||||
|
artifactKey: z.string().trim().min(1).max(64).nullable(),
|
||||||
|
secondArtifactKey: z.string().trim().min(1).max(64).nullable(),
|
||||||
|
constellation: z.number().int().min(0).max(6),
|
||||||
|
refinement: z.number().int().min(1).max(5),
|
||||||
|
damage: z.string().refine((value) => {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) && number >= 0 && number <= 999_999_999_999;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const transferTeamSchema = z.strictObject({
|
||||||
|
title: z.string().trim().min(1).max(160),
|
||||||
|
rotationSeconds: z.string().refine((value) => {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) && number > 0 && number <= 9999;
|
||||||
|
}),
|
||||||
|
combo: z.string().trim().min(1).max(2_000),
|
||||||
|
members: z.array(transferMemberSchema).length(4)
|
||||||
|
.refine(
|
||||||
|
(members) => new Set(members.map((member) => member.characterKey)).size === 4,
|
||||||
|
)
|
||||||
|
.refine((members) => members.some((member) => Number(member.damage) > 0)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamTransferSchema = z.strictObject({
|
||||||
|
type: z.literal("buzz-sheet.team"),
|
||||||
|
version: z.literal(1),
|
||||||
|
team: transferTeamSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function serializeTeamTransfer(team: TeamToolValue): string {
|
||||||
|
const payload = teamTransferSchema.parse({
|
||||||
|
type: "buzz-sheet.team",
|
||||||
|
version: 1,
|
||||||
|
team: {
|
||||||
|
title: team.title,
|
||||||
|
rotationSeconds: team.rotationSeconds,
|
||||||
|
combo: team.combo,
|
||||||
|
members: team.members,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return JSON.stringify(payload, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTeamTransfer(serialized: string): Omit<TeamToolValue, "clientId"> {
|
||||||
|
const payload = teamTransferSchema.parse(JSON.parse(serialized) as unknown);
|
||||||
|
return {
|
||||||
|
...payload.team,
|
||||||
|
members: payload.team.members.map((member) => ({ ...member })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findInvalidTeamCatalogReference(
|
||||||
|
team: Omit<TeamToolValue, "clientId">,
|
||||||
|
characters: readonly TeamToolCatalogItem[],
|
||||||
|
weapons: readonly TeamToolCatalogItem[],
|
||||||
|
artifacts: readonly TeamToolCatalogItem[],
|
||||||
|
): string | null {
|
||||||
|
const characterByKey = new Map(characters.map((item) => [item.key, item]));
|
||||||
|
const weaponByKey = new Map(weapons.map((item) => [item.key, item]));
|
||||||
|
const artifactByKey = new Map(artifacts.map((item) => [item.key, item]));
|
||||||
|
for (const member of team.members) {
|
||||||
|
const character = characterByKey.get(member.characterKey);
|
||||||
|
if (!character) return `ไม่พบตัวละคร ${member.characterKey} ใน Catalog`;
|
||||||
|
const weapon = member.weaponKey ? weaponByKey.get(member.weaponKey) : null;
|
||||||
|
if (member.weaponKey && !weapon) return `ไม่พบอาวุธ ${member.weaponKey} ใน Catalog`;
|
||||||
|
if (weapon && !supportsWeaponType(character.weaponType, weapon.weaponType)) {
|
||||||
|
return `${character.name} ไม่รองรับอาวุธ ${weapon.name}`;
|
||||||
|
}
|
||||||
|
for (const artifactKey of [member.artifactKey, member.secondArtifactKey]) {
|
||||||
|
if (artifactKey && !artifactByKey.has(artifactKey)) {
|
||||||
|
return `ไม่พบ Artifact ${artifactKey} ใน Catalog`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
interface TeamToolCatalogItem {
|
interface TeamToolCatalogItem {
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -109,9 +191,16 @@ export function applyTeamFields(
|
|||||||
const memberIndex = Number(match[1]);
|
const memberIndex = Number(match[1]);
|
||||||
const memberField = match[2] as TeamMemberApplyField;
|
const memberField = match[2] as TeamMemberApplyField;
|
||||||
const sourceMember = source.members[memberIndex];
|
const sourceMember = source.members[memberIndex];
|
||||||
const targetMember = next.members[memberIndex];
|
const matchingMemberIndex = sourceMember?.characterKey
|
||||||
|
? next.members.findIndex(
|
||||||
|
(member) => member.characterKey === sourceMember.characterKey,
|
||||||
|
)
|
||||||
|
: -1;
|
||||||
|
const targetMemberIndex =
|
||||||
|
matchingMemberIndex === -1 ? memberIndex : matchingMemberIndex;
|
||||||
|
const targetMember = next.members[targetMemberIndex];
|
||||||
if (!sourceMember || !targetMember || !teamMemberApplyFields.includes(memberField)) continue;
|
if (!sourceMember || !targetMember || !teamMemberApplyFields.includes(memberField)) continue;
|
||||||
next.members[memberIndex] = {
|
next.members[targetMemberIndex] = {
|
||||||
...targetMember,
|
...targetMember,
|
||||||
[memberField]: sourceMember[memberField],
|
[memberField]: sourceMember[memberField],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user