feat : update

This commit is contained in:
2026-09-06 17:16:38 +07:00 Unverified
parent 5f6b8c1059
commit ce126810bb
13 changed files with 384 additions and 171 deletions
+76 -27
View File
@@ -29,6 +29,15 @@ import {
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { Slider } from "@/components/ui/slider";
import { Textarea } from "@/components/ui/textarea";
import { useAutosave } from "@/hooks/use-autosave";
import { adminDraftKey } from "@/lib/editor/local-draft";
@@ -42,28 +51,53 @@ type BleedingLayer = {
mediaId: string;
imageUrl: string | null;
amount: number;
scale: number;
blendMode: PosterBlendMode;
};
const blendModes: PosterBlendMode[] = ["normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", "hard-light", "soft-light", "difference", "exclusion", "hue", "saturation", "color", "luminosity"];
const blendModeItems = blendModes.map((value) => ({
value,
label: value.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" "),
}));
let bleedingLayerIdCounter = 0;
function SortableBleedingLayer({ layer, onChange, onRemove }: { layer: BleedingLayer; onChange: (patch: Partial<Pick<BleedingLayer, "amount" | "scale" | "blendMode">>) => void; onRemove: () => void }) {
function createBleedingLayerId(): string {
const browserCrypto = globalThis.crypto;
if (typeof browserCrypto?.randomUUID === "function") return `bleeding-${browserCrypto.randomUUID()}`;
bleedingLayerIdCounter += 1;
return `bleeding-${Date.now()}-${bleedingLayerIdCounter}`;
}
function SortableBleedingLayer({ layer, onChange, onRemove }: { layer: BleedingLayer; onChange: (patch: Partial<Pick<BleedingLayer, "amount" | "blendMode">>) => void; onRemove: () => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: layer.id });
const selectedMode = blendModeItems.find((mode) => mode.value === layer.blendMode);
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>
<div ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition }} className={cn("flex items-start gap-2 rounded-lg border bg-card p-3", isDragging && "relative z-10 opacity-70 shadow-lg")}>
<Button type="button" variant="ghost" size="icon-sm" className="touch-none cursor-grab active:cursor-grabbing" aria-label="จัดลำดับ Bleeding" {...attributes} {...listeners}><GripVerticalIcon data-icon="inline-start" /></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" />
<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 className="flex min-w-0 flex-1 flex-col gap-3">
<Combobox
items={blendModeItems}
value={selectedMode}
onValueChange={(value) => value && onChange({ blendMode: value.value })}
itemToStringLabel={(item) => item.label}
itemToStringValue={(item) => item.value}
filter={(item, query) => `${item.label} ${item.value}`.toLowerCase().includes(query.trim().toLowerCase())}
>
<ComboboxInput className="w-full" placeholder="เลือก Blend mode…" />
<ComboboxContent>
<ComboboxEmpty>ไม่พบ Blend mode</ComboboxEmpty>
<ComboboxList>
{(item) => <ComboboxItem key={item.value} value={item}>{item.label}</ComboboxItem>}
</ComboboxList>
</ComboboxContent>
</Combobox>
<Field>
<FieldLabel htmlFor={`bleeding-amount-${layer.id}`}>Amount {layer.amount}%</FieldLabel>
<Slider id={`bleeding-amount-${layer.id}`} min={0} max={100} step={5} value={[layer.amount]} onValueChange={(value) => onChange({ amount: typeof value === "number" ? value : value[0] ?? 0 })} />
</Field>
</div>
<Button type="button" variant="ghost" size="icon" onClick={onRemove} aria-label="ลบ Bleeding"><Trash2Icon className="size-4" /></Button>
<Button type="button" variant="ghost" size="icon-sm" onClick={onRemove} aria-label="ลบ Bleeding"><Trash2Icon data-icon="inline-start" /></Button>
</div>
);
}
@@ -107,7 +141,7 @@ export function GuideOverviewForm({
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, blendMode }) => ({ id, mediaId, imageUrl, amount, scale, blendMode })));
const [bleeding, setBleeding] = useState<BleedingLayer[]>(guide.posterBleeding.map(({ id, mediaId, imageUrl, amount, blendMode }) => ({ id, mediaId, imageUrl, amount, blendMode })));
const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
const youtubeVideo = parseYouTubeUrl(youtubeUrl.trim());
const invalidYoutubeUrl = Boolean(youtubeUrl.trim() && !youtubeVideo);
@@ -121,7 +155,7 @@ export function GuideOverviewForm({
posterArtScale,
posterTopColors,
posterBottomColors,
posterBleeding: bleeding.map(({ mediaId, amount, scale, blendMode }) => ({ mediaId, amount, scale, blendMode })),
posterBleeding: bleeding.map(({ mediaId, amount, blendMode }) => ({ mediaId, amount, scale: 100, blendMode })),
};
const incompleteIssues = [
!cover.id ? "กรุณาอัปโหลด Guide Image" : null,
@@ -192,10 +226,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, blendMode: typeof layer.blendMode === "string" && blendModes.includes(layer.blendMode as PosterBlendMode) ? layer.blendMode as PosterBlendMode : "multiply" }]
? [{ id: layer.id, mediaId: layer.mediaId, imageUrl: layer.imageUrl, amount: typeof layer.amount === "number" ? Math.max(0, Math.min(100, layer.amount)) : 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, blendMode }) => ({ id, mediaId, imageUrl, amount, scale, blendMode })),
: guide.posterBleeding.map(({ id, mediaId, imageUrl, amount, blendMode }) => ({ id, mediaId, imageUrl, amount, blendMode })),
};
},
restoreLocalDraft: (draft) => {
@@ -244,7 +278,16 @@ export function GuideOverviewForm({
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" }]);
: [{ id: createBleedingLayerId(), mediaId: asset.id, imageUrl: asset.url, amount: 65, blendMode: "multiply" }]);
}
function removeUploadedPoster() {
setPosterArt({ id: null, url: null });
setPosterArtOffset({ x: 0, y: 0 });
setPosterArtScale(100);
setPosterTopColors(["#000000", "#000000"]);
setPosterBottomColors(["#000000", "#000000"]);
setBleeding([]);
}
function detectPosterColorsFromFile(file: File) {
@@ -256,7 +299,7 @@ export function GuideOverviewForm({
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" }]);
setBleeding((current) => [...current, { id: createBleedingLayerId(), mediaId, imageUrl, amount: 65, blendMode: "multiply" }]);
}
function handleBleedingDragEnd(event: DragEndEvent) {
@@ -359,12 +402,18 @@ export function GuideOverviewForm({
onReady={setUploadedPoster}
onFileSelected={detectPosterColorsFromFile}
/>
{posterArt.id ? (
<Button type="button" variant="outline" onClick={removeUploadedPoster}>
<Trash2Icon data-icon="inline-start" />
ลบภาพตัวละคร
</Button>
) : null}
<GuideImageExport
width={1600}
height={2000}
format="webp"
format="png"
disabled={!posterArt.url}
fileName={`${guide.name}-materials.webp`}
fileName={`${guide.name}-materials.png`}
captureClassName="p-0"
>
<MaterialPoster
@@ -379,7 +428,7 @@ export function GuideOverviewForm({
/>
</GuideImageExport>
</div>
<div className="space-y-2 rounded-lg border p-3">
<div className="flex flex-col gap-2 rounded-lg border p-3">
<div className="flex items-center justify-between gap-2">
<div>
<FieldLabel>Bleeding layers</FieldLabel>
@@ -390,17 +439,17 @@ export function GuideOverviewForm({
{bleeding.length ? (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleBleedingDragEnd}>
<SortableContext items={bleeding.map((layer) => layer.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-2">
<div className="flex flex-col gap-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>
<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={500} value={posterArtScale} onChange={(event) => setPosterArtScale(Number(event.target.value))} aria-label="Character image zoom" />
</div>
<Field className="rounded-lg border p-3">
<div className="flex items-center justify-between gap-2"><FieldLabel htmlFor="poster-art-scale">Character image zoom</FieldLabel><span className="text-sm text-muted-foreground">{posterArtScale}%</span></div>
<Slider id="poster-art-scale" min={50} max={500} step={5} value={[posterArtScale]} onValueChange={(value) => setPosterArtScale(typeof value === "number" ? value : value[0] ?? 100)} />
</Field>
<FieldDescription>
อัปโหลดภาพตัวละคร แล้วลากบนตัวอย่างเพื่อจัดตำแหน่ง สีด้านบนและล่างจะตรวจให้อัตโนมัติ
</FieldDescription>
+2 -2
View File
@@ -34,7 +34,7 @@ export function MediaUploader({
return;
}
if (file.size <= 0 || file.size > MAX_MEDIA_BYTES) {
toast.error("ไฟล์ต้องมีขนาดไม่เกิน 20 MB");
toast.error("ไฟล์ต้องมีขนาดไม่เกิน 100 MB");
return;
}
@@ -88,7 +88,7 @@ export function MediaUploader({
{state === "idle" && "อัปโหลดและเพิ่มรูปภาพ"}
{!busy ? <UploadCloudIcon className="ml-auto" /> : null}
</Button>
<FieldDescription>PNG, JPEG, WebP หรือ GIF ขนาดไม่เกิน 20 MB</FieldDescription>
<FieldDescription>PNG, JPEG, WebP หรือ GIF ขนาดไม่เกิน 100 MB</FieldDescription>
</Field>
);
}