420 lines
20 KiB
TypeScript
420 lines
20 KiB
TypeScript
"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, 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 { GuideImageExport } from "@/components/public/guide-image-export";
|
|
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 { 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;
|
|
scale: number;
|
|
blendMode: PosterBlendMode;
|
|
};
|
|
|
|
const blendModes: PosterBlendMode[] = ["normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", "hard-light", "soft-light", "difference", "exclusion", "hue", "saturation", "color", "luminosity"];
|
|
|
|
function SortableBleedingLayer({ layer, onChange, onRemove }: { layer: BleedingLayer; onChange: (patch: Partial<Pick<BleedingLayer, "amount" | "scale" | "blendMode">>) => void; onRemove: () => void }) {
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: layer.id });
|
|
return (
|
|
<div ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition }} className={cn("flex items-center gap-2 rounded-lg border bg-card p-2", isDragging && "z-10 opacity-70 shadow-lg")}>
|
|
<button type="button" className="touch-none text-muted-foreground" aria-label="จัดลำดับ Bleeding" {...attributes} {...listeners}><GripVerticalIcon className="size-5" /></button>
|
|
{layer.imageUrl ? <Image src={layer.imageUrl} alt="" width={48} height={48} className="size-12 rounded object-contain bg-muted" /> : <div className="size-12 rounded bg-muted" />}
|
|
<div className="min-w-0 flex-1 space-y-1">
|
|
<div className="flex justify-between text-xs"><span>Amount</span><span>{layer.amount}%</span></div>
|
|
<Input type="range" min={0} max={100} value={layer.amount} onChange={(event) => onChange({ amount: Number(event.target.value) })} aria-label="Bleeding amount" />
|
|
<div className="flex justify-between text-xs"><span>Scale</span><span>{layer.scale}%</span></div>
|
|
<Input type="range" min={25} max={300} value={layer.scale} onChange={(event) => onChange({ scale: Number(event.target.value) })} aria-label="Bleeding scale" />
|
|
<select className="h-8 w-full rounded-lg border border-input bg-background px-2 text-xs" value={layer.blendMode} onChange={(event) => onChange({ blendMode: event.target.value as PosterBlendMode })} aria-label="Bleeding blend mode">
|
|
{blendModes.map((mode) => <option key={mode} value={mode}>{mode}</option>)}
|
|
</select>
|
|
</div>
|
|
<Button type="button" variant="ghost" size="icon" onClick={onRemove} aria-label="ลบ Bleeding"><Trash2Icon className="size-4" /></Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function GuideOverviewForm({
|
|
guide,
|
|
}: {
|
|
guide: {
|
|
id: string;
|
|
version: number;
|
|
overview: string;
|
|
youtubeUrl: string | null;
|
|
coverMediaId: string;
|
|
coverUrl: string;
|
|
name: string;
|
|
posterArtMediaId: string | null;
|
|
posterArtUrl: string | null;
|
|
posterArtOffsetX: number;
|
|
posterArtOffsetY: number;
|
|
posterArtScale: 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<PublicGuide["character"], "aliases">;
|
|
};
|
|
}) {
|
|
const [overview, setOverview] = useState(guide.overview);
|
|
const [youtubeUrl, setYoutubeUrl] = useState(guide.youtubeUrl ?? "");
|
|
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 [posterTopColors, setPosterTopColors] = useState(guide.posterTopColors);
|
|
const [posterBottomColors, setPosterBottomColors] = useState(guide.posterBottomColors);
|
|
const [bleeding, setBleeding] = useState<BleedingLayer[]>(guide.posterBleeding.map(({ id, mediaId, imageUrl, amount, scale, blendMode }) => ({ id, mediaId, imageUrl, amount, scale, blendMode })));
|
|
const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
|
|
const youtubeVideo = parseYouTubeUrl(youtubeUrl.trim());
|
|
const invalidYoutubeUrl = Boolean(youtubeUrl.trim() && !youtubeVideo);
|
|
const value = {
|
|
coverMediaId: cover.id,
|
|
overview,
|
|
youtubeUrl,
|
|
posterArtMediaId: posterArt.id,
|
|
posterArtOffsetX: posterArtOffset.x,
|
|
posterArtOffsetY: posterArtOffset.y,
|
|
posterArtScale,
|
|
posterTopColors,
|
|
posterBottomColors,
|
|
posterBleeding: bleeding.map(({ mediaId, amount, scale, blendMode }) => ({ mediaId, amount, scale, blendMode })),
|
|
};
|
|
const incompleteIssues = [
|
|
!cover.id ? "กรุณาอัปโหลด Guide Image" : null,
|
|
!overview.trim() ? "กรุณากรอก Overview" : null,
|
|
invalidYoutubeUrl ? "กรุณาใส่ลิงก์วิดีโอ YouTube ที่ถูกต้อง" : null,
|
|
].filter((issue): issue is string => Boolean(issue));
|
|
const invalidCover = !cover.id;
|
|
const invalidOverview = !overview.trim();
|
|
const localDraft = { overview, youtubeUrl, cover, posterArt, posterArtOffset, posterArtScale, posterTopColors, posterBottomColors, bleeding };
|
|
const autosave = useAutosave({
|
|
value,
|
|
valid: Boolean(cover.id && overview.trim() && !invalidYoutubeUrl),
|
|
initialVersion: guide.version,
|
|
persist: (next, expectedVersion) =>
|
|
saveGuideOverview(guide.id, { ...next, expectedVersion }),
|
|
storageKey: adminDraftKey(guide.id, "overview"),
|
|
localDraft,
|
|
parseLocalDraft: (candidate) => {
|
|
if (!candidate || typeof candidate !== "object") return null;
|
|
const draft = candidate as Record<string, unknown>;
|
|
if (
|
|
typeof draft.overview !== "string" ||
|
|
!draft.cover ||
|
|
typeof draft.cover !== "object"
|
|
)
|
|
return null;
|
|
const draftCover = draft.cover as Record<string, unknown>;
|
|
if (
|
|
typeof draftCover.id !== "string" ||
|
|
typeof draftCover.url !== "string"
|
|
)
|
|
return null;
|
|
if (
|
|
draft.youtubeUrl !== undefined &&
|
|
typeof draft.youtubeUrl !== "string"
|
|
)
|
|
return null;
|
|
return {
|
|
overview: draft.overview,
|
|
youtubeUrl:
|
|
typeof draft.youtubeUrl === "string" ? draft.youtubeUrl : "",
|
|
cover: { id: draftCover.id, url: draftCover.url },
|
|
posterArt: draft.posterArt && typeof draft.posterArt === "object"
|
|
? {
|
|
id: typeof (draft.posterArt as Record<string, unknown>).id === "string"
|
|
? (draft.posterArt as Record<string, unknown>).id as string
|
|
: null,
|
|
url: typeof (draft.posterArt as Record<string, unknown>).url === "string"
|
|
? (draft.posterArt as Record<string, unknown>).url as string
|
|
: null,
|
|
}
|
|
: { id: guide.posterArtMediaId, url: guide.posterArtUrl },
|
|
posterArtOffset: draft.posterArtOffset && typeof draft.posterArtOffset === "object"
|
|
? {
|
|
x: typeof (draft.posterArtOffset as Record<string, unknown>).x === "number" ? (draft.posterArtOffset as Record<string, unknown>).x as number : 0,
|
|
y: typeof (draft.posterArtOffset as Record<string, unknown>).y === "number" ? (draft.posterArtOffset as Record<string, unknown>).y as number : 0,
|
|
}
|
|
: { x: guide.posterArtOffsetX, y: guide.posterArtOffsetY },
|
|
posterArtScale: typeof draft.posterArtScale === "number" ? Math.max(50, Math.min(200, draft.posterArtScale)) : guide.posterArtScale,
|
|
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<string, unknown>;
|
|
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, scale: typeof layer.scale === "number" ? Math.max(25, Math.min(300, layer.scale)) : 100, blendMode: typeof layer.blendMode === "string" && blendModes.includes(layer.blendMode as PosterBlendMode) ? layer.blendMode as PosterBlendMode : "multiply" }]
|
|
: [];
|
|
})
|
|
: guide.posterBleeding.map(({ id, mediaId, imageUrl, amount, scale, blendMode }) => ({ id, mediaId, imageUrl, amount, scale, blendMode })),
|
|
};
|
|
},
|
|
restoreLocalDraft: (draft) => {
|
|
setOverview(draft.overview);
|
|
setYoutubeUrl(draft.youtubeUrl);
|
|
setCover(draft.cover);
|
|
setPosterArt(draft.posterArt);
|
|
setPosterArtOffset(draft.posterArtOffset);
|
|
setPosterArtScale(draft.posterArtScale);
|
|
setPosterTopColors(draft.posterTopColors);
|
|
setPosterBottomColors(draft.posterBottomColors);
|
|
setBleeding(draft.bleeding);
|
|
},
|
|
});
|
|
|
|
async function detectPosterColors(url: string) {
|
|
try {
|
|
const image = await new Promise<HTMLImageElement>((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 });
|
|
setBleeding((current) => current.length
|
|
? current.map((layer) => ({ ...layer, mediaId: asset.id, imageUrl: asset.url }))
|
|
: [{ id: crypto.randomUUID(), mediaId: asset.id, imageUrl: asset.url, amount: 65, scale: 100, blendMode: "multiply" }]);
|
|
}
|
|
|
|
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: crypto.randomUUID(), mediaId, imageUrl, amount: 65, scale: 100, 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);
|
|
});
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Overview</CardTitle>
|
|
<CardDescription>
|
|
ภาพ ข้อความ และวิดีโอนี้แสดงบนหน้าแรกของ Guide
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<FieldGroup>
|
|
<Field data-invalid={invalidCover}>
|
|
<FieldLabel>Guide Image</FieldLabel>
|
|
<div className={cn(
|
|
"relative mx-auto aspect-square w-full max-w-md overflow-hidden rounded-lg border",
|
|
invalidCover && "border-destructive",
|
|
)}>
|
|
<Image
|
|
src={cover.url}
|
|
alt={`Guide ${guide.name}`}
|
|
fill
|
|
className="object-cover"
|
|
/>
|
|
</div>
|
|
<MediaUploader onReady={(asset) => setCover(asset)} />
|
|
</Field>
|
|
<Field data-invalid={invalidOverview}>
|
|
<FieldLabel htmlFor="guide-overview">Overview</FieldLabel>
|
|
<Textarea
|
|
id="guide-overview"
|
|
value={overview}
|
|
aria-invalid={invalidOverview}
|
|
maxLength={500}
|
|
onChange={(event) => setOverview(event.target.value)}
|
|
/>
|
|
<FieldDescription>{overview.length}/500 ตัวอักษร</FieldDescription>
|
|
</Field>
|
|
<Field data-invalid={invalidYoutubeUrl}>
|
|
<FieldLabel htmlFor="guide-youtube-url">
|
|
YouTube video link
|
|
</FieldLabel>
|
|
<Input
|
|
id="guide-youtube-url"
|
|
type="url"
|
|
inputMode="url"
|
|
value={youtubeUrl}
|
|
maxLength={2_048}
|
|
placeholder="https://www.youtube.com/watch?v=..."
|
|
aria-invalid={invalidYoutubeUrl}
|
|
onChange={(event) => setYoutubeUrl(event.target.value)}
|
|
/>
|
|
<FieldDescription>
|
|
ไม่บังคับ รองรับลิงก์ YouTube, youtu.be, Shorts และ Live
|
|
</FieldDescription>
|
|
{invalidYoutubeUrl ? (
|
|
<FieldError>กรุณาใส่ลิงก์วิดีโอ YouTube ที่ถูกต้อง</FieldError>
|
|
) : null}
|
|
{youtubeVideo ? (
|
|
<div className="aspect-video w-full overflow-hidden rounded-xl border bg-muted">
|
|
<iframe
|
|
src={youtubeVideo.embedUrl}
|
|
title={`ตัวอย่างวิดีโอไกด์ ${guide.name}`}
|
|
loading="lazy"
|
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
|
referrerPolicy="strict-origin-when-cross-origin"
|
|
allowFullScreen
|
|
className="size-full"
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel>Materials Overview</FieldLabel>
|
|
<div className="mx-auto w-full max-w-md overflow-hidden rounded-lg border">
|
|
<MaterialPoster
|
|
character={guide.character}
|
|
characterArtUrl={posterArt.url}
|
|
artworkOffsetX={posterArtOffset.x}
|
|
artworkOffsetY={posterArtOffset.y}
|
|
artworkScale={posterArtScale}
|
|
topColors={posterTopColors}
|
|
bottomColors={posterBottomColors}
|
|
bleeding={bleeding}
|
|
interactive
|
|
onArtworkMove={setPosterArtOffset}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
<MediaUploader
|
|
onReady={setUploadedPoster}
|
|
onFileSelected={detectPosterColorsFromFile}
|
|
/>
|
|
<GuideImageExport
|
|
width={1600}
|
|
height={2000}
|
|
format="webp"
|
|
disabled={!posterArt.url}
|
|
fileName={`${guide.name}-materials.webp`}
|
|
captureClassName="p-0"
|
|
>
|
|
<MaterialPoster
|
|
character={guide.character}
|
|
characterArtUrl={posterArt.url}
|
|
artworkOffsetX={posterArtOffset.x}
|
|
artworkOffsetY={posterArtOffset.y}
|
|
artworkScale={posterArtScale}
|
|
topColors={posterTopColors}
|
|
bottomColors={posterBottomColors}
|
|
bleeding={bleeding}
|
|
/>
|
|
</GuideImageExport>
|
|
</div>
|
|
<div className="space-y-2 rounded-lg border p-3">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div>
|
|
<FieldLabel>Bleeding layers</FieldLabel>
|
|
<p className="text-xs text-muted-foreground">ใช้ภาพตัวละครเดียวกันด้วย mix-blend-mode · รายการบนสุดจะแสดงก่อน</p>
|
|
</div>
|
|
<Button type="button" variant="outline" size="sm" disabled={!posterArt.id || !posterArt.url} onClick={addBleedingEffect}>เพิ่ม effect</Button>
|
|
</div>
|
|
{bleeding.length ? (
|
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleBleedingDragEnd}>
|
|
<SortableContext items={bleeding.map((layer) => layer.id)} strategy={verticalListSortingStrategy}>
|
|
<div className="space-y-2">
|
|
{bleeding.map((layer) => <SortableBleedingLayer key={layer.id} layer={layer} onChange={(patch) => setBleeding((current) => current.map((item) => item.id === layer.id ? { ...item, ...patch } : item))} onRemove={() => setBleeding((current) => current.filter((item) => item.id !== layer.id))} />)}
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
) : <p className="text-sm text-muted-foreground">อัปโหลดภาพเพื่อเพิ่ม Bleeding layer</p>}
|
|
</div>
|
|
<div className="rounded-lg border p-3">
|
|
<div className="flex justify-between text-sm"><span>Character image zoom</span><span>{posterArtScale}%</span></div>
|
|
<Input type="range" min={50} max={200} value={posterArtScale} onChange={(event) => setPosterArtScale(Number(event.target.value))} aria-label="Character image zoom" />
|
|
</div>
|
|
<FieldDescription>
|
|
อัปโหลดภาพตัวละคร แล้วลากบนตัวอย่างเพื่อจัดตำแหน่ง สีด้านบนและล่างจะตรวจให้อัตโนมัติ
|
|
</FieldDescription>
|
|
</Field>
|
|
</FieldGroup>
|
|
</CardContent>
|
|
<CardFooter className="justify-end">
|
|
<AutosaveStatus
|
|
state={autosave.state}
|
|
issues={incompleteIssues}
|
|
onSave={autosave.saveNow}
|
|
/>
|
|
</CardFooter>
|
|
</Card>
|
|
);
|
|
}
|