feat : update
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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)" }}>
|
||||
|
||||
@@ -279,6 +279,7 @@ function GuideOverview({
|
||||
artworkOffsetY={guide.posterArtOffsetY}
|
||||
topColors={guide.posterTopColors}
|
||||
bottomColors={guide.posterBottomColors}
|
||||
bleeding={guide.posterBleeding}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user