"use client"; import Image from "next/image"; import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core"; import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { GripVerticalIcon, PlusIcon, Trash2Icon } from "lucide-react"; import { useState } from "react"; import { saveGuideOverview } from "@/app/admin/actions"; import { AutosaveStatus } from "@/components/admin/autosave-status"; import { MediaUploader } from "@/components/admin/media-uploader"; import { MaterialPoster, type PosterBlendMode } from "@/components/public/material-poster"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "@/components/ui/card"; import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList, } from "@/components/ui/combobox"; import { Slider } from "@/components/ui/slider"; import { Textarea } from "@/components/ui/textarea"; import { useAutosave } from "@/hooks/use-autosave"; import { adminDraftKey } from "@/lib/editor/local-draft"; import { parseYouTubeUrl } from "@/lib/guides/youtube"; import { detectThemeColorsFromPixels } from "@/lib/overview-image"; import type { PublicGuide } from "@/lib/guides/queries"; import { cn } from "@/lib/utils"; type BleedingLayer = { id: string; mediaId: string; imageUrl: string | null; amount: number; blendMode: PosterBlendMode; }; type VideoLink = { id: string; url: string; }; const blendModes: PosterBlendMode[] = ["normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", "hard-light", "soft-light", "difference", "exclusion", "hue", "saturation", "color", "luminosity"]; const blendModeItems = blendModes.map((value) => ({ value, label: value.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" "), })); let bleedingLayerIdCounter = 0; function createBleedingLayerId(): string { const browserCrypto = globalThis.crypto; if (typeof browserCrypto?.randomUUID === "function") return `bleeding-${browserCrypto.randomUUID()}`; bleedingLayerIdCounter += 1; return `bleeding-${Date.now()}-${bleedingLayerIdCounter}`; } function createVideoLinkId(): string { const browserCrypto = globalThis.crypto; if (typeof browserCrypto?.randomUUID === "function") return `video-${browserCrypto.randomUUID()}`; bleedingLayerIdCounter += 1; return `video-${Date.now()}-${bleedingLayerIdCounter}`; } function SortableVideoLink({ link, index, guideName, onChange, onRemove }: { link: VideoLink; index: number; guideName: string; onChange: (url: string) => void; onRemove: () => void }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: link.id }); const video = parseYouTubeUrl(link.url.trim()); const invalid = Boolean(link.url.trim() && !video); return ( YouTube video {index + 1} onChange(event.target.value)} /> {invalid ? กรุณาใส่ลิงก์วิดีโอ YouTube ที่ถูกต้อง : null} {video ? ( ) : null} ); } function SortableBleedingLayer({ layer, onChange, onRemove }: { layer: BleedingLayer; onChange: (patch: Partial>) => void; onRemove: () => void }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: layer.id }); const selectedMode = blendModeItems.find((mode) => mode.value === layer.blendMode); return ( {layer.imageUrl ? : } value && onChange({ blendMode: value.value })} itemToStringLabel={(item) => item.label} itemToStringValue={(item) => item.value} filter={(item, query) => `${item.label} ${item.value}`.toLowerCase().includes(query.trim().toLowerCase())} > ไม่พบ Blend mode {(item) => {item.label}} Amount {layer.amount}% onChange({ amount: typeof value === "number" ? value : value[0] ?? 0 })} /> ); } export function GuideOverviewForm({ guide, }: { guide: { id: string; version: number; overview: string; youtubeUrls: string[]; coverMediaId: string; coverUrl: string; name: string; posterArtMediaId: string | null; posterArtUrl: string | null; posterArtOffsetX: number; posterArtOffsetY: number; posterArtScale: number; posterArtRotation: number; posterTopColors: [string, string]; posterBottomColors: [string, string]; posterBleeding: Array<{ id: string; mediaId: string; imageUrl: string | null; amount: number; scale: number; blendMode: PosterBlendMode; sortOrder: number }>; character: Omit; }; }) { const [overview, setOverview] = useState(guide.overview); const [youtubeLinks, setYoutubeLinks] = useState(() => guide.youtubeUrls.map((url) => ({ id: createVideoLinkId(), url }))); const [cover, setCover] = useState({ id: guide.coverMediaId, url: guide.coverUrl, }); const [posterArt, setPosterArt] = useState({ id: guide.posterArtMediaId, url: guide.posterArtUrl, }); const [posterArtOffset, setPosterArtOffset] = useState({ x: guide.posterArtOffsetX, y: guide.posterArtOffsetY, }); const [posterArtScale, setPosterArtScale] = useState(guide.posterArtScale); const [posterArtRotation, setPosterArtRotation] = useState(guide.posterArtRotation); const [posterTopColors, setPosterTopColors] = useState(guide.posterTopColors); const [posterBottomColors, setPosterBottomColors] = useState(guide.posterBottomColors); const [bleeding, setBleeding] = useState(guide.posterBleeding.map(({ id, mediaId, imageUrl, amount, blendMode }) => ({ id, mediaId, imageUrl, amount, blendMode }))); const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })); const invalidYoutubeLinks = youtubeLinks.some((link) => !parseYouTubeUrl(link.url.trim())); const value = { coverMediaId: cover.id, overview, youtubeUrls: youtubeLinks.map((link) => link.url), posterArtMediaId: posterArt.id, posterArtOffsetX: posterArtOffset.x, posterArtOffsetY: posterArtOffset.y, posterArtScale, posterArtRotation, posterTopColors, posterBottomColors, posterBleeding: bleeding.map(({ mediaId, amount, blendMode }) => ({ mediaId, amount, scale: 100, blendMode })), }; const incompleteIssues = [ !cover.id ? "กรุณาอัปโหลด Guide Image" : null, !overview.trim() ? "กรุณากรอก Overview" : null, invalidYoutubeLinks ? "กรุณาใส่ลิงก์วิดีโอ YouTube ที่ถูกต้อง" : null, ].filter((issue): issue is string => Boolean(issue)); const invalidCover = !cover.id; const invalidOverview = !overview.trim(); const localDraft = { overview, youtubeLinks, cover, posterArt, posterArtOffset, posterArtScale, posterArtRotation, posterTopColors, posterBottomColors, bleeding }; const autosave = useAutosave({ guideId: guide.id, scope: "overview", value, valid: Boolean(cover.id && overview.trim() && !invalidYoutubeLinks), initialVersion: guide.version, persist: (next, expectedVersion, intent) => saveGuideOverview(guide.id, { ...next, expectedVersion }, intent), storageKey: adminDraftKey(guide.id, "overview"), localDraft, parseLocalDraft: (candidate) => { if (!candidate || typeof candidate !== "object") return null; const draft = candidate as Record; if ( typeof draft.overview !== "string" || !draft.cover || typeof draft.cover !== "object" ) return null; const draftCover = draft.cover as Record; if ( typeof draftCover.id !== "string" || typeof draftCover.url !== "string" ) return null; const youtubeLinks = Array.isArray(draft.youtubeLinks) ? draft.youtubeLinks.flatMap((item) => { if (!item || typeof item !== "object") return []; const link = item as Record; return typeof link.id === "string" && typeof link.url === "string" ? [{ id: link.id, url: link.url }] : []; }) : typeof draft.youtubeUrl === "string" && draft.youtubeUrl ? [{ id: createVideoLinkId(), url: draft.youtubeUrl }] : guide.youtubeUrls.map((url) => ({ id: createVideoLinkId(), url })); return { overview: draft.overview, youtubeLinks, cover: { id: draftCover.id, url: draftCover.url }, posterArt: draft.posterArt && typeof draft.posterArt === "object" ? { id: typeof (draft.posterArt as Record).id === "string" ? (draft.posterArt as Record).id as string : null, url: typeof (draft.posterArt as Record).url === "string" ? (draft.posterArt as Record).url as string : null, } : { id: guide.posterArtMediaId, url: guide.posterArtUrl }, posterArtOffset: draft.posterArtOffset && typeof draft.posterArtOffset === "object" ? { x: typeof (draft.posterArtOffset as Record).x === "number" ? (draft.posterArtOffset as Record).x as number : 0, y: typeof (draft.posterArtOffset as Record).y === "number" ? (draft.posterArtOffset as Record).y as number : 0, } : { x: guide.posterArtOffsetX, y: guide.posterArtOffsetY }, posterArtScale: typeof draft.posterArtScale === "number" ? Math.max(50, Math.min(500, draft.posterArtScale)) : guide.posterArtScale, posterArtRotation: typeof draft.posterArtRotation === "number" ? Math.max(-180, Math.min(180, draft.posterArtRotation)) : guide.posterArtRotation, posterTopColors: Array.isArray(draft.posterTopColors) && draft.posterTopColors.length === 2 ? draft.posterTopColors as [string, string] : guide.posterTopColors, posterBottomColors: Array.isArray(draft.posterBottomColors) && draft.posterBottomColors.length === 2 ? draft.posterBottomColors as [string, string] : guide.posterBottomColors, bleeding: Array.isArray(draft.bleeding) ? draft.bleeding.flatMap((item) => { if (!item || typeof item !== "object") return []; const layer = item as Record; return typeof layer.id === "string" && typeof layer.mediaId === "string" && typeof layer.imageUrl === "string" ? [{ id: layer.id, mediaId: layer.mediaId, imageUrl: layer.imageUrl, amount: typeof layer.amount === "number" ? Math.max(0, Math.min(100, layer.amount)) : 100, blendMode: typeof layer.blendMode === "string" && blendModes.includes(layer.blendMode as PosterBlendMode) ? layer.blendMode as PosterBlendMode : "multiply" }] : []; }) : guide.posterBleeding.map(({ id, mediaId, imageUrl, amount, blendMode }) => ({ id, mediaId, imageUrl, amount, blendMode })), }; }, restoreLocalDraft: (draft) => { setOverview(draft.overview); setYoutubeLinks(draft.youtubeLinks); setCover(draft.cover); setPosterArt(draft.posterArt); setPosterArtOffset(draft.posterArtOffset); setPosterArtScale(draft.posterArtScale); setPosterArtRotation(draft.posterArtRotation); setPosterTopColors(draft.posterTopColors); setPosterBottomColors(draft.posterBottomColors); setBleeding(draft.bleeding); }, }); async function detectPosterColors(url: string) { try { const image = await new Promise((resolve, reject) => { const candidate = new window.Image(); candidate.crossOrigin = "anonymous"; candidate.onload = () => resolve(candidate); candidate.onerror = reject; candidate.src = url; }); const scale = Math.min(1, 512 / Math.max(image.naturalWidth, image.naturalHeight)); const canvas = document.createElement("canvas"); canvas.width = Math.max(1, Math.round(image.naturalWidth * scale)); canvas.height = Math.max(1, Math.round(image.naturalHeight * scale)); const context = canvas.getContext("2d"); if (!context) return; context.drawImage(image, 0, 0, canvas.width, canvas.height); const colors = detectThemeColorsFromPixels( context.getImageData(0, 0, canvas.width, canvas.height).data, canvas.width, canvas.height, ); setPosterTopColors(colors.top); setPosterBottomColors(colors.bottom); } catch { // Keep the current colors when the uploaded media host disallows pixel reads. } } function setUploadedPoster(asset: { id: string; url: string }) { setPosterArt(asset); setPosterArtOffset({ x: 0, y: 0 }); setPosterArtRotation(0); setBleeding((current) => current.length ? current.map((layer) => ({ ...layer, mediaId: asset.id, imageUrl: asset.url })) : [{ id: createBleedingLayerId(), mediaId: asset.id, imageUrl: asset.url, amount: 65, blendMode: "multiply" }]); } function removeUploadedPoster() { setPosterArt({ id: null, url: null }); setPosterArtOffset({ x: 0, y: 0 }); setPosterArtScale(100); setPosterArtRotation(0); setPosterTopColors(["#000000", "#000000"]); setPosterBottomColors(["#000000", "#000000"]); setBleeding([]); } function detectPosterColorsFromFile(file: File) { const url = URL.createObjectURL(file); void detectPosterColors(url).finally(() => URL.revokeObjectURL(url)); } function addBleedingEffect() { if (!posterArt.id || !posterArt.url) return; const mediaId = posterArt.id; const imageUrl = posterArt.url; setBleeding((current) => [...current, { id: createBleedingLayerId(), mediaId, imageUrl, amount: 65, blendMode: "multiply" }]); } function handleBleedingDragEnd(event: DragEndEvent) { if (!event.over || event.active.id === event.over.id) return; setBleeding((current) => { const oldIndex = current.findIndex((layer) => layer.id === event.active.id); const newIndex = current.findIndex((layer) => layer.id === event.over?.id); return oldIndex < 0 || newIndex < 0 ? current : arrayMove(current, oldIndex, newIndex); }); } function handleVideoDragEnd(event: DragEndEvent) { if (!event.over || event.active.id === event.over.id) return; setYoutubeLinks((current) => { const oldIndex = current.findIndex((link) => link.id === event.active.id); const newIndex = current.findIndex((link) => link.id === event.over?.id); return oldIndex < 0 || newIndex < 0 ? current : arrayMove(current, oldIndex, newIndex); }); } return ( Overview ภาพ ข้อความ และวิดีโอนี้แสดงบนหน้าแรกของ Guide Guide Image Upload Guide Image อัปโหลดภาพสี่เหลี่ยมสำหรับหน้าแรกของ Guide setCover(asset)} /> Overview setOverview(event.target.value)} /> {overview.length}/500 ตัวอักษร YouTube videos ไม่บังคับ รองรับ YouTube, youtu.be, Shorts และ Live · ลากเพื่อจัดลำดับ = 20} onClick={() => setYoutubeLinks((current) => [...current, { id: createVideoLinkId(), url: "" }])}> เพิ่มลิงก์ {youtubeLinks.length ? ( link.id)} strategy={verticalListSortingStrategy}> {youtubeLinks.map((link, index) => ( setYoutubeLinks((current) => current.map((item) => item.id === link.id ? { ...item, url } : item))} onRemove={() => setYoutubeLinks((current) => current.filter((item) => item.id !== link.id))} /> ))} ) : ยังไม่มีวิดีโอใน Overview} Materials Overview {posterArt.id ? ( ลบภาพตัวละคร ) : null} Bleeding layers ใช้ภาพตัวละครเดียวกันด้วย mix-blend-mode · รายการบนสุดจะแสดงก่อน เพิ่ม effect {bleeding.length ? ( layer.id)} strategy={verticalListSortingStrategy}> {bleeding.map((layer) => setBleeding((current) => current.map((item) => item.id === layer.id ? { ...item, ...patch } : item))} onRemove={() => setBleeding((current) => current.filter((item) => item.id !== layer.id))} />)} ) : อัปโหลดภาพเพื่อเพิ่ม Bleeding layer} Character image zoom{posterArtScale}% setPosterArtScale(typeof value === "number" ? value : value[0] ?? 100)} /> Character image rotation{posterArtRotation}° setPosterArtRotation(typeof value === "number" ? value : value[0] ?? 0)} /> อัปโหลดภาพตัวละคร แล้วลากบนตัวอย่างเพื่อจัดตำแหน่ง สีด้านบนและล่างจะตรวจให้อัตโนมัติ ); }
ยังไม่มีวิดีโอใน Overview
ใช้ภาพตัวละครเดียวกันด้วย mix-blend-mode · รายการบนสุดจะแสดงก่อน
อัปโหลดภาพเพื่อเพิ่ม Bleeding layer