Files
buzz-sheet/components/admin/guide-overview-form.tsx
T
gunshiz c3a7a44d02
CI / Verify (push) Successful in 1m21s
CI / Build immutable images and deploy (push) Successful in 3m12s
feat : update
2026-09-06 14:47:11 +07:00

319 lines
12 KiB
TypeScript

"use client";
import Image from "next/image";
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 } from "@/components/public/material-poster";
import { GuideImageExport } from "@/components/public/guide-image-export";
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";
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;
posterTopColors: [string, string];
posterBottomColors: [string, string];
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 [posterTopColors, setPosterTopColors] = useState(guide.posterTopColors);
const [posterBottomColors, setPosterBottomColors] = useState(guide.posterBottomColors);
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,
posterTopColors,
posterBottomColors,
};
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, posterTopColors, posterBottomColors };
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 },
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,
};
},
restoreLocalDraft: (draft) => {
setOverview(draft.overview);
setYoutubeUrl(draft.youtubeUrl);
setCover(draft.cover);
setPosterArt(draft.posterArt);
setPosterArtOffset(draft.posterArtOffset);
setPosterTopColors(draft.posterTopColors);
setPosterBottomColors(draft.posterBottomColors);
},
});
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 });
}
function detectPosterColorsFromFile(file: File) {
const url = URL.createObjectURL(file);
void detectPosterColors(url).finally(() => URL.revokeObjectURL(url));
}
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}
topColors={posterTopColors}
bottomColors={posterBottomColors}
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}
topColors={posterTopColors}
bottomColors={posterBottomColors}
/>
</GuideImageExport>
</div>
<FieldDescription>
อัปโหลดภาพตัวละคร แล้วลากบนตัวอย่างเพื่อจัดตำแหน่ง สีด้านบนและล่างจะตรวจให้อัตโนมัติ
</FieldDescription>
</Field>
</FieldGroup>
</CardContent>
<CardFooter className="justify-end">
<AutosaveStatus
state={autosave.state}
issues={incompleteIssues}
onSave={autosave.saveNow}
/>
</CardFooter>
</Card>
);
}