From dd87510d68bf2c002cabeb6812309dc79e86ec03 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Fri, 4 Sep 2026 19:46:46 +0700 Subject: [PATCH] feat(guides) : add team transfer and visibility controls --- app/admin/[character]/[page]/page.tsx | 4 +- app/admin/[character]/page.tsx | 3 +- app/admin/actions.ts | 9 + components/admin/guide-section-editor.tsx | 192 +- components/admin/guide-visibility-control.tsx | 97 + components/admin/structured-dashboard.tsx | 10 +- db/schema.ts | 1 + drizzle/0024_spooky_shadowcat.sql | 2 + drizzle/meta/0024_snapshot.json | 3431 +++++++++++++++++ drizzle/meta/_journal.json | 7 + lib/cache/public.ts | 4 +- lib/guides/mutations.ts | 13 + lib/guides/queries.ts | 26 +- lib/guides/team-tools.test.ts | 99 + lib/guides/team-tools.ts | 93 +- 15 files changed, 3972 insertions(+), 19 deletions(-) create mode 100644 components/admin/guide-visibility-control.tsx create mode 100644 drizzle/0024_spooky_shadowcat.sql create mode 100644 drizzle/meta/0024_snapshot.json diff --git a/app/admin/[character]/[page]/page.tsx b/app/admin/[character]/[page]/page.tsx index 99b0f59..dc418ac 100644 --- a/app/admin/[character]/[page]/page.tsx +++ b/app/admin/[character]/[page]/page.tsx @@ -8,7 +8,7 @@ import { GuideSectionNav } from "@/components/admin/guide-section-nav"; import { AdminHeader } from "@/components/admin/admin-header"; import { Button } from "@/components/ui/button"; 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; @@ -19,7 +19,7 @@ export default async function StructuredEditorPage({ params }: { params: Promise if (!sections.includes(page as (typeof sections)[number])) notFound(); const guide = await getGuideByCharacterKey(character); 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(); const guideCharacter = catalog.characters.find((item) => item.key === guide.characterKey); return

{guide.name}

{guide.sections.find((section) => section.slug === page)?.title ?? page}

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 })) })) }} />
; diff --git a/app/admin/[character]/page.tsx b/app/admin/[character]/page.tsx index 6a1a814..b7f53c6 100644 --- a/app/admin/[character]/page.tsx +++ b/app/admin/[character]/page.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardAction, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { AdminHeader } from "@/components/admin/admin-header"; 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 { getAdminSession } from "@/lib/auth/server"; import { getGuideByCharacterKey } from "@/lib/guides/queries"; @@ -26,5 +27,5 @@ export default async function GuideOverviewPage({ params }: { params: Promise<{ const { character } = await params; const guide = await getGuideByCharacterKey(character); if (!guide) notFound(); - return
{guide.name}

เลือกส่วนที่ต้องการแก้ไข

{guide.name}

{sections.map(({ slug, title, description }) => {navigationIconPath(slug) ? : null}{title}{description})}Extra sectionsส่วนเสริมแบบภาพ หัวข้อ ข้อความสั้น และเปอร์เซ็นต์
; + return
{guide.name}

เลือกส่วนที่ต้องการแก้ไข

{guide.name}

{sections.map(({ slug, title, description }) => {navigationIconPath(slug) ? : null}{title}{description})}Extra sectionsส่วนเสริมแบบภาพ หัวข้อ ข้อความสั้น และเปอร์เซ็นต์
; } diff --git a/app/admin/actions.ts b/app/admin/actions.ts index 2f985e6..d107aa1 100644 --- a/app/admin/actions.ts +++ b/app/admin/actions.ts @@ -24,6 +24,7 @@ import { saveConstellations, saveExtraSections, saveTeams, + setGuideVisibility, saveWeapons, trashGuide, updateGuideBasics, @@ -275,6 +276,14 @@ export async function restoreStructuredGuide(guideId: string) { 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) { await requireAdmin(); return runGuideAction(() => permanentlyDeleteGuide(guideId)); diff --git a/components/admin/guide-section-editor.tsx b/components/admin/guide-section-editor.tsx index d7f1ab0..f7bc79e 100644 --- a/components/admin/guide-section-editor.tsx +++ b/components/admin/guide-section-editor.tsx @@ -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 ( +
+ {children( + , + )} +
+ ); +} + export function GuideSectionEditor(props: GuideSectionEditorProps) { return (
@@ -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([]); 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 ( @@ -2423,6 +2527,15 @@ function TeamEditor({
{dragHandle} + + + + + + Import Team + + วาง JSON ที่ Export จาก Team ใน Guide อื่น ระบบจะเพิ่ม Team ใหม่ต่อท้ายรายการ + + + + Team JSON +