From c3a7a44d02002f518c6d6d4b1e4d8c4dbe08bfdd Mon Sep 17 00:00:00 2001 From: gunshiz Date: Sun, 6 Sep 2026 14:47:11 +0700 Subject: [PATCH] feat : update --- app/globals.css | 8 + components/admin/guide-overview-form.tsx | 139 +- components/admin/media-uploader.tsx | 7 +- components/public/guide-image-export.tsx | 9 +- components/public/material-poster.tsx | 179 ++ components/public/structured-guide.tsx | 73 +- db/schema.ts | 15 + drizzle/0032_grey_excalibur.sql | 7 + drizzle/meta/0032_snapshot.json | 3698 ++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + lib/catalog/lunaris.test.ts | 17 + lib/catalog/lunaris.ts | 18 + lib/catalog/sync.ts | 7 + lib/content/queries.ts | 11 +- lib/guides/mutations.ts | 27 + lib/guides/queries.ts | 29 +- lib/guides/schemas.ts | 5 + public/Kanit-ExtraBoldItalic.ttf | Bin 0 -> 191664 bytes public/img/mat/fade.png | Bin 0 -> 208233 bytes 19 files changed, 4218 insertions(+), 38 deletions(-) create mode 100644 components/public/material-poster.tsx create mode 100644 drizzle/0032_grey_excalibur.sql create mode 100644 drizzle/meta/0032_snapshot.json create mode 100644 public/Kanit-ExtraBoldItalic.ttf create mode 100644 public/img/mat/fade.png diff --git a/app/globals.css b/app/globals.css index 0fa8350..8fe1671 100644 --- a/app/globals.css +++ b/app/globals.css @@ -17,6 +17,14 @@ font-display: swap; } +@font-face { + font-family: "Kanit Poster"; + src: url("/Kanit-ExtraBoldItalic.ttf") format("truetype"); + font-style: italic; + font-weight: 800; + font-display: swap; +} + @custom-variant dark (&:is(.dark *)); @theme inline { diff --git a/components/admin/guide-overview-form.tsx b/components/admin/guide-overview-form.tsx index 24bd4b0..714277f 100644 --- a/components/admin/guide-overview-form.tsx +++ b/components/admin/guide-overview-form.tsx @@ -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; }; }) { 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).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 }, + 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((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 ( @@ -169,6 +263,47 @@ export function GuideOverviewForm({ ) : null} + + Materials Overview +
+ +
+
+ + + + +
+ + อัปโหลดภาพตัวละคร แล้วลากบนตัวอย่างเพื่อจัดตำแหน่ง สีด้านบนและล่างจะตรวจให้อัตโนมัติ + +
diff --git a/components/admin/media-uploader.tsx b/components/admin/media-uploader.tsx index 5c9f13f..f7fa41e 100644 --- a/components/admin/media-uploader.tsx +++ b/components/admin/media-uploader.tsx @@ -19,8 +19,10 @@ export interface UploadedMedia { export function MediaUploader({ onReady, + onFileSelected, }: { onReady: (media: UploadedMedia) => void; + onFileSelected?: (file: File) => void; }) { const inputId = useId(); const [state, setState] = useState<"idle" | "uploading">("idle"); @@ -67,7 +69,10 @@ export function MediaUploader({ onChange={(event) => { const file = event.target.files?.[0]; event.target.value = ""; - if (file) void upload(file); + if (file) { + onFileSelected?.(file); + void upload(file); + } }} />