feat : update
This commit is contained in:
@@ -6,6 +6,8 @@ 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,
|
||||
@@ -26,6 +28,8 @@ 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({
|
||||
@@ -39,6 +43,13 @@ export function GuideOverviewForm({
|
||||
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);
|
||||
@@ -47,9 +58,28 @@ export function GuideOverviewForm({
|
||||
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 };
|
||||
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,
|
||||
@@ -57,7 +87,7 @@ export function GuideOverviewForm({
|
||||
].filter((issue): issue is string => Boolean(issue));
|
||||
const invalidCover = !cover.id;
|
||||
const invalidOverview = !overview.trim();
|
||||
const localDraft = { overview, youtubeUrl, cover };
|
||||
const localDraft = { overview, youtubeUrl, cover, posterArt, posterArtOffset, posterTopColors, posterBottomColors };
|
||||
const autosave = useAutosave({
|
||||
value,
|
||||
valid: Boolean(cover.id && overview.trim() && !invalidYoutubeUrl),
|
||||
@@ -91,14 +121,78 @@ export function GuideOverviewForm({
|
||||
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>
|
||||
@@ -169,6 +263,47 @@ export function GuideOverviewForm({
|
||||
</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">
|
||||
|
||||
Reference in New Issue
Block a user