diff --git a/app/admin/[character]/[page]/page.tsx b/app/admin/[character]/[page]/page.tsx index f42f0e5..ed77ccf 100644 --- a/app/admin/[character]/[page]/page.tsx +++ b/app/admin/[character]/[page]/page.tsx @@ -22,5 +22,5 @@ export default async function StructuredEditorPage({ params }: { params: Promise const [catalog, content] = await Promise.all([getActiveCatalog(), getPublicGuide(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}

({ id: group.id, name: group.name, rows: group.rows.map((row) => ({ firstArtifactKey: row.firstArtifactKey, secondArtifactKey: row.secondArtifactKey, percent: row.percent })) })), artifactProfile: content.artifactProfile, constellations: content.constellations, teams: content.teams.map((team) => ({ title: team.title, rotationSeconds: team.rotationSeconds, combo: team.combo, members: team.members.map((member) => ({ characterKey: member.characterKey, weaponKey: member.weaponKey, artifactKey: member.artifactKey, 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, heading: row.heading, body: row.body, percent: row.percent, imageUrl: row.imageUrl })) })) }} />
; + return

{guide.name}

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

({ 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, constellations: content.constellations, 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, 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, heading: row.heading, body: row.body, percent: row.percent, imageUrl: row.imageUrl })) })) }} />
; } diff --git a/app/api/guides/[guideId]/artifacts/image/route.tsx b/app/api/guides/[guideId]/artifacts/image/route.tsx index bea4e4e..caa7968 100644 --- a/app/api/guides/[guideId]/artifacts/image/route.tsx +++ b/app/api/guides/[guideId]/artifacts/image/route.tsx @@ -70,8 +70,9 @@ export async function GET( const { guideId } = await context.params; const guide = await getPublicArtifactExport(guideId); if (!guide) return new Response("Guide not found", { status: 404 }); + const talents = normalizeTalentPriorities(guide.profile?.talents ?? []); - const [fonts, groupAssets, statIconAssets] = await Promise.all([ + const [fonts, groupAssets, statIconAssets, talentAssets] = await Promise.all([ anuphanFonts, Promise.all( guide.groups.map((group) => @@ -88,18 +89,22 @@ export async function GET( localPngDataUrl("Icon_Goblet_of_Eonothem.webp"), localPngDataUrl("Icon_Circlet_of_Logos.webp"), ]), + Promise.all(talents.map((talent) => { + const catalogTalent = guide.character.talents.find((item) => item.type === talent.type); + return catalogTalent?.iconUrl ? pngDataUrl(catalogTalent.iconUrl) : null; + })), ]); const profile = guide.profile; - const leftHeight = guide.groups.reduce((sum, group) => sum + 68 + group.rows.length * 76, 0) + Math.max(0, guide.groups.length - 1) * 16; + const groupRowHeights = guide.groups.map((group) => group.rows.map((row) => Math.max(76, 76 + (row.note ? Math.ceil(row.note.length / 58) * 22 : 0)))); + const leftHeight = guide.groups.reduce((sum, _group, groupIndex) => sum + 68 + groupRowHeights[groupIndex].reduce((groupSum, rowHeight) => groupSum + rowHeight, 0), 0) + Math.max(0, guide.groups.length - 1) * 16; const detailHeight = profile - ? 60 + profile.targets.length * 30 + (profile.talents.length ? 58 : 0) + (profile.note ? Math.max(48, Math.ceil(profile.note.length / 58) * 24) : 0) + ? 60 + profile.targets.length * 30 + (profile.talents.length ? 82 : 0) + (profile.note ? Math.max(48, Math.ceil(profile.note.length / 58) * 24) : 0) : 0; const rightHeight = profile ? 242 + (profile.substats.length ? 76 : 0) + (detailHeight > 60 ? detailHeight + 16 : 0) + 32 : 0; const height = Math.max(520, 190 + Math.max(leftHeight, rightHeight)); - const talents = normalizeTalentPriorities(profile?.talents ?? []); return new ImageResponse(
{group.rows.map((row, rowIndex) => ( -
+
{groupAssets[groupIndex][rowIndex].first ? (
@@ -156,8 +161,11 @@ export async function GET(
) : null}
-
- {rowIndex + 1}. {row.first.name}{row.second ? ` + ${row.second.name}` : ""} +
+
+ {rowIndex + 1}. {row.first.name}{row.second ? ` + ${row.second.name}` : ""} +
+ {row.note ?
{row.note}
: null}
{metric(row.percent)} @@ -215,9 +223,10 @@ export async function GET( {talents.length ? (
ลำดับการอัพพรสวรรค์
-
+
{talents.map((talent, index) => ( -
+
+ {talentAssets[index] ? : null} {talent.type}{talent.level ? ` Lv.${talent.level}+` : ""} {index < talents.length - 1 ? {talent.relationToNext === "gte" ? ">=" : ">"} : null}
diff --git a/app/api/guides/[guideId]/weapons/image/route.tsx b/app/api/guides/[guideId]/weapons/image/route.tsx index 4473f0f..d92f937 100644 --- a/app/api/guides/[guideId]/weapons/image/route.tsx +++ b/app/api/guides/[guideId]/weapons/image/route.tsx @@ -56,7 +56,8 @@ export async function GET( anuphanFonts, Promise.all(guide.weapons.map((row) => pngDataUrl(row.weapon.imageUrl))), ]); - const height = Math.max(360, 190 + guide.weapons.length * 126 + Math.max(0, guide.weapons.length - 1) * 14); + const weaponHeights = guide.weapons.map((row) => Math.max(126, 126 + (row.note ? Math.ceil(row.note.length / 52) * 22 : 0))); + const height = Math.max(360, 190 + weaponHeights.reduce((sum, rowHeight) => sum + rowHeight, 0) + Math.max(0, guide.weapons.length - 1) * 14); return new ImageResponse(
{index + 1}
)} -
- {index + 1}. {row.weapon.name} R{row.refinement} +
+
+ {index + 1}. {row.weapon.name} R{row.refinement} +
+ {row.note ?
{row.note}
: null}
diff --git a/components/admin/guide-section-editor.tsx b/components/admin/guide-section-editor.tsx index 9fac8fa..5d935f0 100644 --- a/components/admin/guide-section-editor.tsx +++ b/components/admin/guide-section-editor.tsx @@ -1,8 +1,8 @@ "use client"; import Image from "next/image"; -import { ArrowDownIcon, ArrowUpIcon, GripVerticalIcon, PlusIcon, SparklesIcon, Trash2Icon } from "lucide-react"; -import { useState } from "react"; +import { GripVerticalIcon, PlusIcon, SparklesIcon, Trash2Icon } from "lucide-react"; +import { useState, type ReactNode } from "react"; import { closestCenter, DndContext, @@ -13,6 +13,7 @@ import { type DragEndEvent, } from "@dnd-kit/core"; import { + arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, @@ -71,13 +72,13 @@ import { cn } from "@/lib/utils"; type CatalogItem = { key: string; name: string; imageUrl: string; rarity?: number | null; weaponType?: string | null }; type Guide = { id: string; characterKey: string; version: number; name: string }; -type WeaponValue = { weaponKey: string; refinement: number; overallPercent: string | null; personalPercent: string | null; weapon: CatalogItem }; -type ArtifactValue = { id: string; name: string; rows: Array<{ firstArtifactKey: string; secondArtifactKey: string | null; percent: string }> }; +type WeaponValue = { id: string; weaponKey: string; refinement: number; overallPercent: string | null; personalPercent: string | null; note: string | null; weapon: CatalogItem }; +type ArtifactValue = { id: string; name: string; rows: Array<{ firstArtifactKey: string; secondArtifactKey: string | null; percent: string; note: string | null }> }; type ArtifactProfile = { sands: string[]; goblet: string[]; circlet: string[]; substats: string[]; targets: Array<{ stat: string; value: string }>; talents: TalentPriority[]; note: string | null } | null; type ConstellationValue = { level: number; detail: string; cumulativePercent: string; isGreat: boolean }; type CatalogConstellation = { level: number; name: string; description: string; iconUrl: string | null }; type CatalogTalent = { type: "NA" | "E" | "Q"; name: string; iconUrl: string | null }; -type TeamValue = { title: string; rotationSeconds: string; combo: string; members: Array<{ characterKey: string; weaponKey: string | null; artifactKey: string | null; constellation: number; refinement: number; damage: string }> }; +type TeamValue = { id: string; title: string; rotationSeconds: string; combo: string; members: Array<{ characterKey: string; weaponKey: string | null; artifactKey: string | null; constellation: number; refinement: number; damage: string }> }; export interface GuideSectionEditorProps { section: "weapon" | "artifact" | "constellations" | "team" | "extra"; @@ -183,15 +184,41 @@ function SortableTalentCard({ ); } -function RowActions({ index, length, move, remove }: { index: number; length: number; move: (from: number, to: number) => void; remove?: () => void }) { - return
{remove ? : null}
; +function SortableWeaponCard({ id, children }: { id: string; children: (dragHandle: ReactNode) => ReactNode }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); + return ( +
+ {children( + , + )} +
+ ); } -function moveItem(items: T[], from: number, to: number): T[] { - const next = [...items]; - const [item] = next.splice(from, 1); - next.splice(to, 0, item); - return next; +function SortableTeamItem({ id, children }: { id: string; children: (dragHandle: ReactNode) => ReactNode }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); + return ( + + {children( + , + )} + + ); } export function GuideSectionEditor(props: GuideSectionEditorProps) { @@ -211,16 +238,34 @@ type EditorBase = GuideSectionEditorProps; function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) { const characterWeaponType = characters.find((character) => character.key === guide.characterKey)?.weaponType; const compatibleWeapons = weapons.filter((weapon) => supportsWeaponType(characterWeaponType, weapon.weaponType)); - const [rows, setRows] = useState(initial.weapons.map((row) => ({ weaponKey: row.weaponKey, refinement: row.refinement, overallPercent: row.overallPercent ?? "", personalPercent: row.personalPercent ?? "" }))); - const value = { rows: rows.map((row) => ({ ...row, overallPercent: row.overallPercent === "" ? null : Number(row.overallPercent), personalPercent: row.personalPercent === "" ? null : Number(row.personalPercent) })) }; + const weaponSensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + const [rows, setRows] = useState(initial.weapons.map((row) => ({ clientId: row.id, weaponKey: row.weaponKey, refinement: row.refinement, overallPercent: row.overallPercent ?? "", personalPercent: row.personalPercent ?? "", note: row.note ?? "" }))); + const value = { rows: rows.map((row) => ({ weaponKey: row.weaponKey, refinement: row.refinement, overallPercent: row.overallPercent === "" ? null : Number(row.overallPercent), personalPercent: row.personalPercent === "" ? null : Number(row.personalPercent), note: row.note.trim() || null })) }; const autosave = useAutosave({ value, valid: weaponSectionSchema.safeParse({ ...value, expectedVersion: guide.version }).success, initialVersion: guide.version, persist: (next, expectedVersion) => saveWeaponSection(guide.id, { ...next, expectedVersion }) }); + const handleWeaponDragEnd = ({ active, over }: DragEndEvent) => { + if (!over || active.id === over.id) return; + setRows((current) => { + const from = current.findIndex((row) => row.clientId === active.id); + const to = current.findIndex((row) => row.clientId === over.id); + return from === -1 || to === -1 ? current : arrayMove(current, from, to); + }); + }; return ( - Weapon tier listเรียงอาวุธจากดีที่สุดลงมา และกรอกเปอร์เซ็นต์อย่างน้อยหนึ่งช่อง ระบบจะบันทึกอัตโนมัติ + Weapon tier listลากเพื่อเรียงอาวุธจากดีที่สุดลงมา และกรอกเปอร์เซ็นต์อย่างน้อยหนึ่งช่อง ระบบจะบันทึกอัตโนมัติ - {rows.map((row, rowIndex) => { const selectedWeapon = compatibleWeapons.find((weapon) => weapon.key === row.weaponKey); return {selectedWeapon ?
: null}อันดับ {rowIndex + 1}
setRows(moveItem(rows, from, to))} remove={() => setRows(rows.filter((_, index) => index !== rowIndex))} />
อาวุธ setRows(rows.map((item, index) => index === rowIndex ? { ...item, weaponKey } : item))} />Refinement { const next = selected[0]; if (next) setRows(rows.map((item, index) => index === rowIndex ? { ...item, refinement: Number(next.slice(1)) } : item)); }}>{[1, 2, 3, 4, 5].map((refinement) => R{refinement})}
Overall % setRows(rows.map((item, index) => index === rowIndex ? { ...item, overallPercent: event.target.value } : item))} />Personal % setRows(rows.map((item, index) => index === rowIndex ? { ...item, personalPercent: event.target.value } : item))} />
; })} + + row.clientId)} strategy={verticalListSortingStrategy}> +
+ {rows.map((row, rowIndex) => { const selectedWeapon = compatibleWeapons.find((weapon) => weapon.key === row.weaponKey); return {(dragHandle) => {selectedWeapon ?
: null}อันดับ {rowIndex + 1}
{dragHandle}
อาวุธ setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, weaponKey } : item))} />Refinement { const next = selected[0]; if (next) setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, refinement: Number(next.slice(1)) } : item)); }}>{[1, 2, 3, 4, 5].map((refinement) => R{refinement})}
Overall % setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, overallPercent: event.target.value } : item))} />Personal % setRows(rows.map((item) => item.clientId === row.clientId ? { ...item, personalPercent: event.target.value } : item))} />
Note (optional)