feat : update
This commit is contained in:
@@ -10,7 +10,7 @@ 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 { MaterialPoster, type PosterBlendMode } from "@/components/public/material-poster";
|
||||
import { GuideImageExport } from "@/components/public/guide-image-export";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -43,9 +43,12 @@ type BleedingLayer = {
|
||||
imageUrl: string | null;
|
||||
amount: number;
|
||||
scale: number;
|
||||
blendMode: PosterBlendMode;
|
||||
};
|
||||
|
||||
function SortableBleedingLayer({ layer, onChange, onRemove }: { layer: BleedingLayer; onChange: (patch: Partial<Pick<BleedingLayer, "amount" | "scale">>) => void; onRemove: () => void }) {
|
||||
const blendModes: PosterBlendMode[] = ["normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", "hard-light", "soft-light", "difference", "exclusion", "hue", "saturation", "color", "luminosity"];
|
||||
|
||||
function SortableBleedingLayer({ layer, onChange, onRemove }: { layer: BleedingLayer; onChange: (patch: Partial<Pick<BleedingLayer, "amount" | "scale" | "blendMode">>) => 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")}>
|
||||
@@ -56,6 +59,9 @@ function SortableBleedingLayer({ layer, onChange, onRemove }: { layer: BleedingL
|
||||
<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" />
|
||||
<select className="h-8 w-full rounded-lg border border-input bg-background px-2 text-xs" value={layer.blendMode} onChange={(event) => onChange({ blendMode: event.target.value as PosterBlendMode })} aria-label="Bleeding blend mode">
|
||||
{blendModes.map((mode) => <option key={mode} value={mode}>{mode}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={onRemove} aria-label="ลบ Bleeding"><Trash2Icon className="size-4" /></Button>
|
||||
</div>
|
||||
@@ -77,9 +83,10 @@ export function GuideOverviewForm({
|
||||
posterArtUrl: string | null;
|
||||
posterArtOffsetX: number;
|
||||
posterArtOffsetY: number;
|
||||
posterArtScale: number;
|
||||
posterTopColors: [string, string];
|
||||
posterBottomColors: [string, string];
|
||||
posterBleeding: Array<{ id: string; mediaId: string; imageUrl: string | null; amount: number; scale: number; sortOrder: number }>;
|
||||
posterBleeding: Array<{ id: string; mediaId: string; imageUrl: string | null; amount: number; scale: number; blendMode: PosterBlendMode; sortOrder: number }>;
|
||||
character: Omit<PublicGuide["character"], "aliases">;
|
||||
};
|
||||
}) {
|
||||
@@ -97,9 +104,10 @@ export function GuideOverviewForm({
|
||||
x: guide.posterArtOffsetX,
|
||||
y: guide.posterArtOffsetY,
|
||||
});
|
||||
const [posterArtScale, setPosterArtScale] = useState(guide.posterArtScale);
|
||||
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 [bleeding, setBleeding] = useState<BleedingLayer[]>(guide.posterBleeding.map(({ id, mediaId, imageUrl, amount, scale, blendMode }) => ({ id, mediaId, imageUrl, amount, scale, blendMode })));
|
||||
const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
|
||||
const youtubeVideo = parseYouTubeUrl(youtubeUrl.trim());
|
||||
const invalidYoutubeUrl = Boolean(youtubeUrl.trim() && !youtubeVideo);
|
||||
@@ -110,9 +118,10 @@ export function GuideOverviewForm({
|
||||
posterArtMediaId: posterArt.id,
|
||||
posterArtOffsetX: posterArtOffset.x,
|
||||
posterArtOffsetY: posterArtOffset.y,
|
||||
posterArtScale,
|
||||
posterTopColors,
|
||||
posterBottomColors,
|
||||
posterBleeding: bleeding.map(({ mediaId, amount, scale }) => ({ mediaId, amount, scale })),
|
||||
posterBleeding: bleeding.map(({ mediaId, amount, scale, blendMode }) => ({ mediaId, amount, scale, blendMode })),
|
||||
};
|
||||
const incompleteIssues = [
|
||||
!cover.id ? "กรุณาอัปโหลด Guide Image" : null,
|
||||
@@ -121,7 +130,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, bleeding };
|
||||
const localDraft = { overview, youtubeUrl, cover, posterArt, posterArtOffset, posterArtScale, posterTopColors, posterBottomColors, bleeding };
|
||||
const autosave = useAutosave({
|
||||
value,
|
||||
valid: Boolean(cover.id && overview.trim() && !invalidYoutubeUrl),
|
||||
@@ -171,6 +180,7 @@ export function GuideOverviewForm({
|
||||
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 },
|
||||
posterArtScale: typeof draft.posterArtScale === "number" ? Math.max(50, Math.min(200, draft.posterArtScale)) : guide.posterArtScale,
|
||||
posterTopColors: Array.isArray(draft.posterTopColors) && draft.posterTopColors.length === 2
|
||||
? draft.posterTopColors as [string, string]
|
||||
: guide.posterTopColors,
|
||||
@@ -182,10 +192,10 @@ export function GuideOverviewForm({
|
||||
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 }]
|
||||
? [{ 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, blendMode: typeof layer.blendMode === "string" && blendModes.includes(layer.blendMode as PosterBlendMode) ? layer.blendMode as PosterBlendMode : "multiply" }]
|
||||
: [];
|
||||
})
|
||||
: guide.posterBleeding.map(({ id, mediaId, imageUrl, amount, scale }) => ({ id, mediaId, imageUrl, amount, scale })),
|
||||
: guide.posterBleeding.map(({ id, mediaId, imageUrl, amount, scale, blendMode }) => ({ id, mediaId, imageUrl, amount, scale, blendMode })),
|
||||
};
|
||||
},
|
||||
restoreLocalDraft: (draft) => {
|
||||
@@ -194,6 +204,7 @@ export function GuideOverviewForm({
|
||||
setCover(draft.cover);
|
||||
setPosterArt(draft.posterArt);
|
||||
setPosterArtOffset(draft.posterArtOffset);
|
||||
setPosterArtScale(draft.posterArtScale);
|
||||
setPosterTopColors(draft.posterTopColors);
|
||||
setPosterBottomColors(draft.posterBottomColors);
|
||||
setBleeding(draft.bleeding);
|
||||
@@ -231,6 +242,9 @@ export function GuideOverviewForm({
|
||||
function setUploadedPoster(asset: { id: string; url: string }) {
|
||||
setPosterArt(asset);
|
||||
setPosterArtOffset({ x: 0, y: 0 });
|
||||
setBleeding((current) => current.length
|
||||
? current.map((layer) => ({ ...layer, mediaId: asset.id, imageUrl: asset.url }))
|
||||
: [{ id: crypto.randomUUID(), mediaId: asset.id, imageUrl: asset.url, amount: 65, scale: 100, blendMode: "multiply" }]);
|
||||
}
|
||||
|
||||
function detectPosterColorsFromFile(file: File) {
|
||||
@@ -238,6 +252,13 @@ export function GuideOverviewForm({
|
||||
void detectPosterColors(url).finally(() => URL.revokeObjectURL(url));
|
||||
}
|
||||
|
||||
function addBleedingEffect() {
|
||||
if (!posterArt.id || !posterArt.url) return;
|
||||
const mediaId = posterArt.id;
|
||||
const imageUrl = posterArt.url;
|
||||
setBleeding((current) => [...current, { id: crypto.randomUUID(), mediaId, imageUrl, amount: 65, scale: 100, blendMode: "multiply" }]);
|
||||
}
|
||||
|
||||
function handleBleedingDragEnd(event: DragEndEvent) {
|
||||
if (!event.over || event.active.id === event.over.id) return;
|
||||
setBleeding((current) => {
|
||||
@@ -325,6 +346,7 @@ export function GuideOverviewForm({
|
||||
characterArtUrl={posterArt.url}
|
||||
artworkOffsetX={posterArtOffset.x}
|
||||
artworkOffsetY={posterArtOffset.y}
|
||||
artworkScale={posterArtScale}
|
||||
topColors={posterTopColors}
|
||||
bottomColors={posterBottomColors}
|
||||
bleeding={bleeding}
|
||||
@@ -350,6 +372,7 @@ export function GuideOverviewForm({
|
||||
characterArtUrl={posterArt.url}
|
||||
artworkOffsetX={posterArtOffset.x}
|
||||
artworkOffsetY={posterArtOffset.y}
|
||||
artworkScale={posterArtScale}
|
||||
topColors={posterTopColors}
|
||||
bottomColors={posterBottomColors}
|
||||
bleeding={bleeding}
|
||||
@@ -360,9 +383,9 @@ export function GuideOverviewForm({
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<FieldLabel>Bleeding layers</FieldLabel>
|
||||
<p className="text-xs text-muted-foreground">รายการบนสุดจะแสดงก่อน</p>
|
||||
<p className="text-xs text-muted-foreground">ใช้ภาพตัวละครเดียวกันด้วย mix-blend-mode · รายการบนสุดจะแสดงก่อน</p>
|
||||
</div>
|
||||
<MediaUploader onReady={(asset) => setBleeding((current) => [...current, { id: crypto.randomUUID(), mediaId: asset.id, imageUrl: asset.url, amount: 100, scale: 100 }])} />
|
||||
<Button type="button" variant="outline" size="sm" disabled={!posterArt.id || !posterArt.url} onClick={addBleedingEffect}>เพิ่ม effect</Button>
|
||||
</div>
|
||||
{bleeding.length ? (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleBleedingDragEnd}>
|
||||
@@ -374,6 +397,10 @@ export function GuideOverviewForm({
|
||||
</DndContext>
|
||||
) : <p className="text-sm text-muted-foreground">อัปโหลดภาพเพื่อเพิ่ม Bleeding layer</p>}
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="flex justify-between text-sm"><span>Character image zoom</span><span>{posterArtScale}%</span></div>
|
||||
<Input type="range" min={50} max={200} value={posterArtScale} onChange={(event) => setPosterArtScale(Number(event.target.value))} aria-label="Character image zoom" />
|
||||
</div>
|
||||
<FieldDescription>
|
||||
อัปโหลดภาพตัวละคร แล้วลากบนตัวอย่างเพื่อจัดตำแหน่ง สีด้านบนและล่างจะตรวจให้อัตโนมัติ
|
||||
</FieldDescription>
|
||||
|
||||
Reference in New Issue
Block a user