feat : update
CI / Verify (push) Successful in 1m25s
CI / Build immutable images and deploy (push) Has been cancelled

This commit is contained in:
2026-09-06 15:07:28 +07:00 Unverified
parent c3a7a44d02
commit 03149a1499
15 changed files with 4162 additions and 18 deletions
+75 -1
View File
@@ -1,6 +1,10 @@
"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";
@@ -8,6 +12,7 @@ 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 { Button } from "@/components/ui/button";
import {
Card,
CardContent,
@@ -32,6 +37,31 @@ 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;
};
function SortableBleedingLayer({ layer, onChange, onRemove }: { layer: BleedingLayer; onChange: (patch: Partial<Pick<BleedingLayer, "amount" | "scale">>) => 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" />
</div>
<Button type="button" variant="ghost" size="icon" onClick={onRemove} aria-label="ลบ Bleeding"><Trash2Icon className="size-4" /></Button>
</div>
);
}
export function GuideOverviewForm({
guide,
}: {
@@ -49,6 +79,7 @@ export function GuideOverviewForm({
posterArtOffsetY: number;
posterTopColors: [string, string];
posterBottomColors: [string, string];
posterBleeding: Array<{ id: string; mediaId: string; imageUrl: string | null; amount: number; scale: number; sortOrder: number }>;
character: Omit<PublicGuide["character"], "aliases">;
};
}) {
@@ -68,6 +99,8 @@ export function GuideOverviewForm({
});
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 }) => ({ id, mediaId, imageUrl, amount, scale })));
const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
const youtubeVideo = parseYouTubeUrl(youtubeUrl.trim());
const invalidYoutubeUrl = Boolean(youtubeUrl.trim() && !youtubeVideo);
const value = {
@@ -79,6 +112,7 @@ export function GuideOverviewForm({
posterArtOffsetY: posterArtOffset.y,
posterTopColors,
posterBottomColors,
posterBleeding: bleeding.map(({ mediaId, amount, scale }) => ({ mediaId, amount, scale })),
};
const incompleteIssues = [
!cover.id ? "กรุณาอัปโหลด Guide Image" : null,
@@ -87,7 +121,7 @@ export function GuideOverviewForm({
].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 localDraft = { overview, youtubeUrl, cover, posterArt, posterArtOffset, posterTopColors, posterBottomColors, bleeding };
const autosave = useAutosave({
value,
valid: Boolean(cover.id && overview.trim() && !invalidYoutubeUrl),
@@ -143,6 +177,15 @@ export function GuideOverviewForm({
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 }]
: [];
})
: guide.posterBleeding.map(({ id, mediaId, imageUrl, amount, scale }) => ({ id, mediaId, imageUrl, amount, scale })),
};
},
restoreLocalDraft: (draft) => {
@@ -153,6 +196,7 @@ export function GuideOverviewForm({
setPosterArtOffset(draft.posterArtOffset);
setPosterTopColors(draft.posterTopColors);
setPosterBottomColors(draft.posterBottomColors);
setBleeding(draft.bleeding);
},
});
@@ -193,6 +237,16 @@ export function GuideOverviewForm({
const url = URL.createObjectURL(file);
void detectPosterColors(url).finally(() => URL.revokeObjectURL(url));
}
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>
@@ -273,6 +327,7 @@ export function GuideOverviewForm({
artworkOffsetY={posterArtOffset.y}
topColors={posterTopColors}
bottomColors={posterBottomColors}
bleeding={bleeding}
interactive
onArtworkMove={setPosterArtOffset}
/>
@@ -297,9 +352,28 @@ export function GuideOverviewForm({
artworkOffsetY={posterArtOffset.y}
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">รายการบนสุดจะแสดงก่อน</p>
</div>
<MediaUploader onReady={(asset) => setBleeding((current) => [...current, { id: crypto.randomUUID(), mediaId: asset.id, imageUrl: asset.url, amount: 100, scale: 100 }])} />
</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>
<FieldDescription>
อัปโหลดภาพตัวละคร แล้วลากบนตัวอย่างเพื่อจัดตำแหน่ง สีด้านบนและล่างจะตรวจให้อัตโนมัติ
</FieldDescription>
+62 -3
View File
@@ -7,13 +7,16 @@ import {
PlusIcon,
RefreshCwIcon,
RotateCcwIcon,
XCircleIcon,
Trash2Icon,
} from "lucide-react";
import { useTransition } from "react";
import { useEffect, useState, useTransition } from "react";
import { toast } from "sonner";
import {
deleteStructuredGuide,
cancelCatalogSync,
getCatalogSyncProgress,
moveGuideToTrash,
restoreStructuredGuide,
startCatalogSync,
@@ -40,6 +43,8 @@ import {
} from "@/components/ui/empty";
import { Spinner } from "@/components/ui/spinner";
import { useAdminPresence } from "@/hooks/use-presence";
import type { CatalogSyncJob } from "@/lib/catalog/sync";
import { cn } from "@/lib/utils";
type GuideCard = {
id: string;
@@ -66,6 +71,7 @@ export function StructuredDashboard({
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [syncJob, setSyncJob] = useState<CatalogSyncJob | null>(null);
const presence = useAdminPresence(null);
const catalogReady = Boolean(catalogVersion);
const updateAvailable = Boolean(
@@ -73,6 +79,39 @@ export function StructuredDashboard({
latestCatalogVersion &&
catalogVersion !== latestCatalogVersion,
);
const syncJobId = syncJob?.id;
const syncJobStatus = syncJob?.status;
useEffect(() => {
if (!syncJobId || !syncJobStatus || ["completed", "unchanged", "cancelled", "error"].includes(syncJobStatus)) return;
let stopped = false;
const poll = async () => {
const latest = await getCatalogSyncProgress(syncJobId);
if (!stopped && latest) setSyncJob(latest);
};
void poll();
const timer = window.setInterval(() => void poll(), 1000);
return () => {
stopped = true;
window.clearInterval(timer);
};
}, [syncJobId, syncJobStatus]);
function startSync() {
startTransition(async () => {
const result = await startCatalogSync();
if (result.status === "error") toast.error(result.message);
else setSyncJob(result.job);
});
}
function cancelSync() {
if (!syncJob) return;
startTransition(async () => {
const result = await cancelCatalogSync(syncJob.id);
if (result) setSyncJob(result);
});
}
function run(
action: () => Promise<{ status: string; message?: string }>,
@@ -107,8 +146,8 @@ export function StructuredDashboard({
<div className="flex gap-2">
<Button
variant="outline"
disabled={pending}
onClick={() => run(startCatalogSync, "Sync สำเร็จแล้ว")}
disabled={pending || Boolean(syncJob && !["completed", "unchanged", "cancelled", "error"].includes(syncJob.status))}
onClick={startSync}
>
{pending ? (
<Spinner data-icon="inline-start" />
@@ -117,6 +156,12 @@ export function StructuredDashboard({
)}
Sync Catalog
</Button>
{syncJob && !["completed", "unchanged", "cancelled", "error"].includes(syncJob.status) ? (
<Button variant="destructive" disabled={pending} onClick={cancelSync}>
<XCircleIcon data-icon="inline-start" />
Cancel Sync
</Button>
) : null}
<Button
render={<Link href="/admin/create" />}
nativeButton={false}
@@ -127,6 +172,20 @@ export function StructuredDashboard({
</Button>
</div>
</div>
{syncJob ? (
<Card>
<CardContent className="space-y-2 pt-6">
<div className="flex items-center justify-between gap-3 text-sm">
<span>{syncJob.message}</span>
<span className="text-muted-foreground">{syncJob.total ? `${syncJob.completed}/${syncJob.total}` : "กำลังเริ่ม…"}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div className={cn("h-full bg-primary transition-all", syncJob.total ? "" : "w-1/3 animate-pulse")} style={syncJob.total ? { width: `${Math.min(100, (syncJob.completed / syncJob.total) * 100)}%` } : undefined} />
</div>
{syncJob.status === "error" ? <p className="text-sm text-destructive">{syncJob.error ?? "Sync ไม่สำเร็จ"}</p> : null}
</CardContent>
</Card>
) : null}
{guides.length ? (
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{guides.map((guide) => (
+15
View File
@@ -55,6 +55,7 @@ export function MaterialPoster({
interactive = false,
onArtworkMove,
className,
bleeding = [],
}: {
character: PosterCharacter;
characterArtUrl: string | null;
@@ -65,6 +66,7 @@ export function MaterialPoster({
interactive?: boolean;
onArtworkMove?: (offset: { x: number; y: number }) => void;
className?: string;
bleeding?: Array<{ id: string; imageUrl: string | null; amount: number; scale: number }>;
}) {
const dragStart = useRef<{ pointerId: number; x: number; y: number; offsetX: number; offsetY: number } | null>(null);
const stats = character.baseStats.slice(0, 4);
@@ -141,6 +143,19 @@ export function MaterialPoster({
/>
</div>
) : null}
<div className="pointer-events-none absolute inset-0 overflow-hidden">
{bleeding.map((layer) => layer.imageUrl ? (
<Image
key={layer.id}
src={layer.imageUrl}
alt=""
fill
sizes="(min-width: 1024px) 800px, 100vw"
className="object-contain object-center"
style={{ opacity: Math.max(0, Math.min(100, layer.amount)) / 100, transform: `scale(${Math.max(25, Math.min(300, layer.scale)) / 100})` }}
/>
) : null)}
</div>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-black/10 via-transparent to-black/80" />
<div className="absolute inset-x-[4%] top-[5%]" style={{ fontFamily: "Kanit Poster", fontWeight: 800, fontStyle: "italic", textShadow: "-6px 14px 0 rgba(0,0,0,0.59)" }}>
+1
View File
@@ -279,6 +279,7 @@ function GuideOverview({
artworkOffsetY={guide.posterArtOffsetY}
topColors={guide.posterTopColors}
bottomColors={guide.posterBottomColors}
bleeding={guide.posterBleeding}
/>
</section>
) : null}