feat : update
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
google.com, pub-9687404323559597, DIRECT, f08c47fec0942fa0
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Script from "next/script";
|
|
||||||
import { Anuphan, Geist_Mono } from "next/font/google";
|
import { Anuphan, Geist_Mono } from "next/font/google";
|
||||||
|
|
||||||
import { DeploymentUpdateNotifier } from "@/components/deployment-update-notifier";
|
import { DeploymentUpdateNotifier } from "@/components/deployment-update-notifier";
|
||||||
@@ -40,11 +39,6 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
|
|||||||
className={`dark ${anuphan.variable} ${geistMono.variable} h-full antialiased`}
|
className={`dark ${anuphan.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
<body className="flex min-h-full flex-col">
|
<body className="flex min-h-full flex-col">
|
||||||
<Script
|
|
||||||
src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-9687404323559597"
|
|
||||||
async
|
|
||||||
crossOrigin="anonymous"
|
|
||||||
/>
|
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<PageProgress />
|
<PageProgress />
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useState } from "react";
|
|||||||
import { saveGuideOverview } from "@/app/admin/actions";
|
import { saveGuideOverview } from "@/app/admin/actions";
|
||||||
import { AutosaveStatus } from "@/components/admin/autosave-status";
|
import { AutosaveStatus } from "@/components/admin/autosave-status";
|
||||||
import { MediaUploader } from "@/components/admin/media-uploader";
|
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 { GuideImageExport } from "@/components/public/guide-image-export";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -43,9 +43,12 @@ type BleedingLayer = {
|
|||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
amount: number;
|
amount: number;
|
||||||
scale: 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 });
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: layer.id });
|
||||||
return (
|
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")}>
|
<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" />
|
<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>
|
<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" />
|
<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>
|
</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" onClick={onRemove} aria-label="ลบ Bleeding"><Trash2Icon className="size-4" /></Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,9 +83,10 @@ export function GuideOverviewForm({
|
|||||||
posterArtUrl: string | null;
|
posterArtUrl: string | null;
|
||||||
posterArtOffsetX: number;
|
posterArtOffsetX: number;
|
||||||
posterArtOffsetY: number;
|
posterArtOffsetY: number;
|
||||||
|
posterArtScale: number;
|
||||||
posterTopColors: [string, string];
|
posterTopColors: [string, string];
|
||||||
posterBottomColors: [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">;
|
character: Omit<PublicGuide["character"], "aliases">;
|
||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
@@ -97,9 +104,10 @@ export function GuideOverviewForm({
|
|||||||
x: guide.posterArtOffsetX,
|
x: guide.posterArtOffsetX,
|
||||||
y: guide.posterArtOffsetY,
|
y: guide.posterArtOffsetY,
|
||||||
});
|
});
|
||||||
|
const [posterArtScale, setPosterArtScale] = useState(guide.posterArtScale);
|
||||||
const [posterTopColors, setPosterTopColors] = useState(guide.posterTopColors);
|
const [posterTopColors, setPosterTopColors] = useState(guide.posterTopColors);
|
||||||
const [posterBottomColors, setPosterBottomColors] = useState(guide.posterBottomColors);
|
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 sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
|
||||||
const youtubeVideo = parseYouTubeUrl(youtubeUrl.trim());
|
const youtubeVideo = parseYouTubeUrl(youtubeUrl.trim());
|
||||||
const invalidYoutubeUrl = Boolean(youtubeUrl.trim() && !youtubeVideo);
|
const invalidYoutubeUrl = Boolean(youtubeUrl.trim() && !youtubeVideo);
|
||||||
@@ -110,9 +118,10 @@ export function GuideOverviewForm({
|
|||||||
posterArtMediaId: posterArt.id,
|
posterArtMediaId: posterArt.id,
|
||||||
posterArtOffsetX: posterArtOffset.x,
|
posterArtOffsetX: posterArtOffset.x,
|
||||||
posterArtOffsetY: posterArtOffset.y,
|
posterArtOffsetY: posterArtOffset.y,
|
||||||
|
posterArtScale,
|
||||||
posterTopColors,
|
posterTopColors,
|
||||||
posterBottomColors,
|
posterBottomColors,
|
||||||
posterBleeding: bleeding.map(({ mediaId, amount, scale }) => ({ mediaId, amount, scale })),
|
posterBleeding: bleeding.map(({ mediaId, amount, scale, blendMode }) => ({ mediaId, amount, scale, blendMode })),
|
||||||
};
|
};
|
||||||
const incompleteIssues = [
|
const incompleteIssues = [
|
||||||
!cover.id ? "กรุณาอัปโหลด Guide Image" : null,
|
!cover.id ? "กรุณาอัปโหลด Guide Image" : null,
|
||||||
@@ -121,7 +130,7 @@ export function GuideOverviewForm({
|
|||||||
].filter((issue): issue is string => Boolean(issue));
|
].filter((issue): issue is string => Boolean(issue));
|
||||||
const invalidCover = !cover.id;
|
const invalidCover = !cover.id;
|
||||||
const invalidOverview = !overview.trim();
|
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({
|
const autosave = useAutosave({
|
||||||
value,
|
value,
|
||||||
valid: Boolean(cover.id && overview.trim() && !invalidYoutubeUrl),
|
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,
|
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 },
|
: { 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
|
posterTopColors: Array.isArray(draft.posterTopColors) && draft.posterTopColors.length === 2
|
||||||
? draft.posterTopColors as [string, string]
|
? draft.posterTopColors as [string, string]
|
||||||
: guide.posterTopColors,
|
: guide.posterTopColors,
|
||||||
@@ -182,10 +192,10 @@ export function GuideOverviewForm({
|
|||||||
if (!item || typeof item !== "object") return [];
|
if (!item || typeof item !== "object") return [];
|
||||||
const layer = item as Record<string, unknown>;
|
const layer = item as Record<string, unknown>;
|
||||||
return typeof layer.id === "string" && typeof layer.mediaId === "string" && typeof layer.imageUrl === "string"
|
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) => {
|
restoreLocalDraft: (draft) => {
|
||||||
@@ -194,6 +204,7 @@ export function GuideOverviewForm({
|
|||||||
setCover(draft.cover);
|
setCover(draft.cover);
|
||||||
setPosterArt(draft.posterArt);
|
setPosterArt(draft.posterArt);
|
||||||
setPosterArtOffset(draft.posterArtOffset);
|
setPosterArtOffset(draft.posterArtOffset);
|
||||||
|
setPosterArtScale(draft.posterArtScale);
|
||||||
setPosterTopColors(draft.posterTopColors);
|
setPosterTopColors(draft.posterTopColors);
|
||||||
setPosterBottomColors(draft.posterBottomColors);
|
setPosterBottomColors(draft.posterBottomColors);
|
||||||
setBleeding(draft.bleeding);
|
setBleeding(draft.bleeding);
|
||||||
@@ -231,6 +242,9 @@ export function GuideOverviewForm({
|
|||||||
function setUploadedPoster(asset: { id: string; url: string }) {
|
function setUploadedPoster(asset: { id: string; url: string }) {
|
||||||
setPosterArt(asset);
|
setPosterArt(asset);
|
||||||
setPosterArtOffset({ x: 0, y: 0 });
|
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) {
|
function detectPosterColorsFromFile(file: File) {
|
||||||
@@ -238,6 +252,13 @@ export function GuideOverviewForm({
|
|||||||
void detectPosterColors(url).finally(() => URL.revokeObjectURL(url));
|
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) {
|
function handleBleedingDragEnd(event: DragEndEvent) {
|
||||||
if (!event.over || event.active.id === event.over.id) return;
|
if (!event.over || event.active.id === event.over.id) return;
|
||||||
setBleeding((current) => {
|
setBleeding((current) => {
|
||||||
@@ -325,6 +346,7 @@ export function GuideOverviewForm({
|
|||||||
characterArtUrl={posterArt.url}
|
characterArtUrl={posterArt.url}
|
||||||
artworkOffsetX={posterArtOffset.x}
|
artworkOffsetX={posterArtOffset.x}
|
||||||
artworkOffsetY={posterArtOffset.y}
|
artworkOffsetY={posterArtOffset.y}
|
||||||
|
artworkScale={posterArtScale}
|
||||||
topColors={posterTopColors}
|
topColors={posterTopColors}
|
||||||
bottomColors={posterBottomColors}
|
bottomColors={posterBottomColors}
|
||||||
bleeding={bleeding}
|
bleeding={bleeding}
|
||||||
@@ -350,6 +372,7 @@ export function GuideOverviewForm({
|
|||||||
characterArtUrl={posterArt.url}
|
characterArtUrl={posterArt.url}
|
||||||
artworkOffsetX={posterArtOffset.x}
|
artworkOffsetX={posterArtOffset.x}
|
||||||
artworkOffsetY={posterArtOffset.y}
|
artworkOffsetY={posterArtOffset.y}
|
||||||
|
artworkScale={posterArtScale}
|
||||||
topColors={posterTopColors}
|
topColors={posterTopColors}
|
||||||
bottomColors={posterBottomColors}
|
bottomColors={posterBottomColors}
|
||||||
bleeding={bleeding}
|
bleeding={bleeding}
|
||||||
@@ -360,9 +383,9 @@ export function GuideOverviewForm({
|
|||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel>Bleeding layers</FieldLabel>
|
<FieldLabel>Bleeding layers</FieldLabel>
|
||||||
<p className="text-xs text-muted-foreground">รายการบนสุดจะแสดงก่อน</p>
|
<p className="text-xs text-muted-foreground">ใช้ภาพตัวละครเดียวกันด้วย mix-blend-mode · รายการบนสุดจะแสดงก่อน</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{bleeding.length ? (
|
{bleeding.length ? (
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleBleedingDragEnd}>
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleBleedingDragEnd}>
|
||||||
@@ -374,6 +397,10 @@ export function GuideOverviewForm({
|
|||||||
</DndContext>
|
</DndContext>
|
||||||
) : <p className="text-sm text-muted-foreground">อัปโหลดภาพเพื่อเพิ่ม Bleeding layer</p>}
|
) : <p className="text-sm text-muted-foreground">อัปโหลดภาพเพื่อเพิ่ม Bleeding layer</p>}
|
||||||
</div>
|
</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>
|
||||||
อัปโหลดภาพตัวละคร แล้วลากบนตัวอย่างเพื่อจัดตำแหน่ง สีด้านบนและล่างจะตรวจให้อัตโนมัติ
|
อัปโหลดภาพตัวละคร แล้วลากบนตัวอย่างเพื่อจัดตำแหน่ง สีด้านบนและล่างจะตรวจให้อัตโนมัติ
|
||||||
</FieldDescription>
|
</FieldDescription>
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ type PosterMaterial = {
|
|||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PosterBlendMode = "normal" | "multiply" | "screen" | "overlay" | "darken" | "lighten" | "color-dodge" | "color-burn" | "hard-light" | "soft-light" | "difference" | "exclusion" | "hue" | "saturation" | "color" | "luminosity";
|
||||||
|
|
||||||
type PosterCharacter = {
|
type PosterCharacter = {
|
||||||
name: string;
|
name: string;
|
||||||
nameCardImageUrl: string | null;
|
nameCardImageUrl: string | null;
|
||||||
@@ -50,6 +52,7 @@ export function MaterialPoster({
|
|||||||
characterArtUrl,
|
characterArtUrl,
|
||||||
artworkOffsetX,
|
artworkOffsetX,
|
||||||
artworkOffsetY,
|
artworkOffsetY,
|
||||||
|
artworkScale,
|
||||||
topColors,
|
topColors,
|
||||||
bottomColors,
|
bottomColors,
|
||||||
interactive = false,
|
interactive = false,
|
||||||
@@ -66,7 +69,8 @@ export function MaterialPoster({
|
|||||||
interactive?: boolean;
|
interactive?: boolean;
|
||||||
onArtworkMove?: (offset: { x: number; y: number }) => void;
|
onArtworkMove?: (offset: { x: number; y: number }) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
bleeding?: Array<{ id: string; imageUrl: string | null; amount: number; scale: number }>;
|
artworkScale: number;
|
||||||
|
bleeding?: Array<{ id: string; imageUrl: string | null; amount: number; scale: number; blendMode: PosterBlendMode }>;
|
||||||
}) {
|
}) {
|
||||||
const dragStart = useRef<{ pointerId: number; x: number; y: number; offsetX: number; offsetY: number } | null>(null);
|
const dragStart = useRef<{ pointerId: number; x: number; y: number; offsetX: number; offsetY: number } | null>(null);
|
||||||
const stats = character.baseStats.slice(0, 4);
|
const stats = character.baseStats.slice(0, 4);
|
||||||
@@ -131,7 +135,7 @@ export function MaterialPoster({
|
|||||||
{characterArtUrl ? (
|
{characterArtUrl ? (
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute inset-0"
|
className="pointer-events-none absolute inset-0"
|
||||||
style={{ transform: `translate(${artworkOffsetX}%, ${artworkOffsetY}%)` }}
|
style={{ transform: `translate(${artworkOffsetX}%, ${artworkOffsetY}%) scale(${artworkScale / 100})` }}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={characterArtUrl}
|
src={characterArtUrl}
|
||||||
@@ -152,7 +156,7 @@ export function MaterialPoster({
|
|||||||
fill
|
fill
|
||||||
sizes="(min-width: 1024px) 800px, 100vw"
|
sizes="(min-width: 1024px) 800px, 100vw"
|
||||||
className="object-contain object-center"
|
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})` }}
|
style={{ opacity: Math.max(0, Math.min(100, layer.amount)) / 100, mixBlendMode: layer.blendMode, transform: `scale(${Math.max(25, Math.min(300, layer.scale)) / 100})` }}
|
||||||
/>
|
/>
|
||||||
) : null)}
|
) : null)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -277,6 +277,7 @@ function GuideOverview({
|
|||||||
characterArtUrl={guide.posterArtUrl}
|
characterArtUrl={guide.posterArtUrl}
|
||||||
artworkOffsetX={guide.posterArtOffsetX}
|
artworkOffsetX={guide.posterArtOffsetX}
|
||||||
artworkOffsetY={guide.posterArtOffsetY}
|
artworkOffsetY={guide.posterArtOffsetY}
|
||||||
|
artworkScale={guide.posterArtScale}
|
||||||
topColors={guide.posterTopColors}
|
topColors={guide.posterTopColors}
|
||||||
bottomColors={guide.posterBottomColors}
|
bottomColors={guide.posterBottomColors}
|
||||||
bleeding={guide.posterBleeding}
|
bleeding={guide.posterBleeding}
|
||||||
|
|||||||
@@ -544,6 +544,7 @@ export const guides = guidesSchema.table(
|
|||||||
}),
|
}),
|
||||||
posterArtOffsetX: integer("poster_art_offset_x").default(0).notNull(),
|
posterArtOffsetX: integer("poster_art_offset_x").default(0).notNull(),
|
||||||
posterArtOffsetY: integer("poster_art_offset_y").default(0).notNull(),
|
posterArtOffsetY: integer("poster_art_offset_y").default(0).notNull(),
|
||||||
|
posterArtScale: integer("poster_art_scale").default(100).notNull(),
|
||||||
posterTopColors: jsonb("poster_top_colors")
|
posterTopColors: jsonb("poster_top_colors")
|
||||||
.$type<[string, string]>()
|
.$type<[string, string]>()
|
||||||
.default(["#000000", "#000000"])
|
.default(["#000000", "#000000"])
|
||||||
@@ -573,6 +574,7 @@ export const guidePosterBleeding = guidesSchema.table(
|
|||||||
mediaId: uuid("media_id").notNull().references(() => media.id, { onDelete: "restrict" }),
|
mediaId: uuid("media_id").notNull().references(() => media.id, { onDelete: "restrict" }),
|
||||||
amount: integer("amount").default(100).notNull(),
|
amount: integer("amount").default(100).notNull(),
|
||||||
scale: integer("scale").default(100).notNull(),
|
scale: integer("scale").default(100).notNull(),
|
||||||
|
blendMode: varchar("blend_mode", { length: 32 }).default("multiply").notNull(),
|
||||||
sortOrder: integer("sort_order").default(0).notNull(),
|
sortOrder: integer("sort_order").default(0).notNull(),
|
||||||
...timestamps,
|
...timestamps,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "guides"."guide_poster_bleeding" ADD COLUMN "blend_mode" varchar(32) DEFAULT 'multiply' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "guides"."guide" ADD COLUMN "poster_art_scale" integer DEFAULT 100 NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -239,6 +239,13 @@
|
|||||||
"when": 1788681628083,
|
"when": 1788681628083,
|
||||||
"tag": "0033_true_captain_marvel",
|
"tag": "0033_true_captain_marvel",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 34,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788683110679,
|
||||||
|
"tag": "0034_confused_storm",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+11
-1
@@ -641,6 +641,15 @@ export async function getCurrentCatalogVersion(): Promise<string | null> {
|
|||||||
return currentVersionSchema.parse(await current.json()).version;
|
return currentVersionSchema.parse(await current.json()).version;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function hasCatalogRecords(): Promise<boolean> {
|
||||||
|
const db = getDb();
|
||||||
|
const [character] = await db.select({ key: catalogCharacters.key }).from(catalogCharacters).limit(1);
|
||||||
|
const [material] = await db.select({ key: catalogMaterials.key }).from(catalogMaterials).limit(1);
|
||||||
|
const [weapon] = await db.select({ key: catalogWeapons.key }).from(catalogWeapons).limit(1);
|
||||||
|
const [artifact] = await db.select({ key: catalogArtifacts.key }).from(catalogArtifacts).limit(1);
|
||||||
|
return Boolean(character && material && weapon && artifact);
|
||||||
|
}
|
||||||
|
|
||||||
export async function processCatalogSync(
|
export async function processCatalogSync(
|
||||||
requestedVersion?: string,
|
requestedVersion?: string,
|
||||||
control?: SyncControl,
|
control?: SyncControl,
|
||||||
@@ -653,7 +662,8 @@ export async function processCatalogSync(
|
|||||||
const currentVersion = await getCurrentCatalogVersion();
|
const currentVersion = await getCurrentCatalogVersion();
|
||||||
if (
|
if (
|
||||||
currentVersion &&
|
currentVersion &&
|
||||||
compareLunarisVersions(version, currentVersion) <= 0
|
compareLunarisVersions(version, currentVersion) <= 0 &&
|
||||||
|
await hasCatalogRecords()
|
||||||
) {
|
) {
|
||||||
return "unchanged";
|
return "unchanged";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ export async function updateGuideBasics(guideId: string, input: unknown) {
|
|||||||
...(data.posterArtMediaId !== undefined ? { posterArtMediaId } : {}),
|
...(data.posterArtMediaId !== undefined ? { posterArtMediaId } : {}),
|
||||||
...(data.posterArtOffsetX !== undefined ? { posterArtOffsetX: Math.round(data.posterArtOffsetX) } : {}),
|
...(data.posterArtOffsetX !== undefined ? { posterArtOffsetX: Math.round(data.posterArtOffsetX) } : {}),
|
||||||
...(data.posterArtOffsetY !== undefined ? { posterArtOffsetY: Math.round(data.posterArtOffsetY) } : {}),
|
...(data.posterArtOffsetY !== undefined ? { posterArtOffsetY: Math.round(data.posterArtOffsetY) } : {}),
|
||||||
|
...(data.posterArtScale !== undefined ? { posterArtScale: Math.round(data.posterArtScale) } : {}),
|
||||||
...(data.posterTopColors !== undefined ? { posterTopColors: data.posterTopColors } : {}),
|
...(data.posterTopColors !== undefined ? { posterTopColors: data.posterTopColors } : {}),
|
||||||
...(data.posterBottomColors !== undefined ? { posterBottomColors: data.posterBottomColors } : {}),
|
...(data.posterBottomColors !== undefined ? { posterBottomColors: data.posterBottomColors } : {}),
|
||||||
}).where(eq(guides.id, guideId));
|
}).where(eq(guides.id, guideId));
|
||||||
@@ -168,6 +169,7 @@ export async function updateGuideBasics(guideId: string, input: unknown) {
|
|||||||
mediaId: layer.mediaId,
|
mediaId: layer.mediaId,
|
||||||
amount: Math.round(layer.amount),
|
amount: Math.round(layer.amount),
|
||||||
scale: Math.round(layer.scale),
|
scale: Math.round(layer.scale),
|
||||||
|
blendMode: layer.blendMode,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
})));
|
})));
|
||||||
for (const layer of data.posterBleeding) {
|
for (const layer of data.posterBleeding) {
|
||||||
|
|||||||
@@ -27,8 +27,10 @@ import {
|
|||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage";
|
import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage";
|
||||||
import { aliasesByItem } from "@/lib/glossary";
|
import { aliasesByItem } from "@/lib/glossary";
|
||||||
|
import type { PosterBlendMode } from "@/components/public/material-poster";
|
||||||
|
|
||||||
const catalogVersionSchema = z.object({ version: z.string() });
|
const catalogVersionSchema = z.object({ version: z.string() });
|
||||||
|
const posterBlendModes = new Set(["normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", "hard-light", "soft-light", "difference", "exclusion", "hue", "saturation", "color", "luminosity"]);
|
||||||
|
|
||||||
function catalogCharacterWithUrls(
|
function catalogCharacterWithUrls(
|
||||||
item: typeof catalogCharacters.$inferSelect,
|
item: typeof catalogCharacters.$inferSelect,
|
||||||
@@ -140,7 +142,7 @@ export async function getGuideByCharacterKey(characterKey: string) {
|
|||||||
...row.guide,
|
...row.guide,
|
||||||
coverUrl: publicMediaUrl(row.cover.objectKey),
|
coverUrl: publicMediaUrl(row.cover.objectKey),
|
||||||
posterArtUrl: poster[0] ? publicMediaUrl(poster[0].objectKey) : null,
|
posterArtUrl: poster[0] ? publicMediaUrl(poster[0].objectKey) : null,
|
||||||
posterBleeding: bleeding.map(({ layer, asset }) => ({ ...layer, imageUrl: asset ? publicMediaUrl(asset.objectKey) : null })),
|
posterBleeding: bleeding.map(({ layer, asset }) => ({ ...layer, blendMode: (posterBlendModes.has(layer.blendMode) ? layer.blendMode : "multiply") as PosterBlendMode, imageUrl: asset ? publicMediaUrl(asset.objectKey) : null })),
|
||||||
character: catalogCharacterWithUrls(row.character, materialByKey),
|
character: catalogCharacterWithUrls(row.character, materialByKey),
|
||||||
sections,
|
sections,
|
||||||
};
|
};
|
||||||
@@ -225,7 +227,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
|
|||||||
...row.guide,
|
...row.guide,
|
||||||
coverUrl: publicMediaUrl(row.cover.objectKey),
|
coverUrl: publicMediaUrl(row.cover.objectKey),
|
||||||
posterArtUrl: poster[0] ? publicMediaUrl(poster[0].objectKey) : null,
|
posterArtUrl: poster[0] ? publicMediaUrl(poster[0].objectKey) : null,
|
||||||
posterBleeding: bleeding.map(({ layer, asset }) => ({ ...layer, imageUrl: asset ? publicMediaUrl(asset.objectKey) : null })),
|
posterBleeding: bleeding.map(({ layer, asset }) => ({ ...layer, blendMode: (posterBlendModes.has(layer.blendMode) ? layer.blendMode : "multiply") as PosterBlendMode, imageUrl: asset ? publicMediaUrl(asset.objectKey) : null })),
|
||||||
character: { ...catalogCharacterWithUrls(row.character, materialByKey), aliases: groupedAliases.character.get(row.character.key) ?? [] },
|
character: { ...catalogCharacterWithUrls(row.character, materialByKey), aliases: groupedAliases.character.get(row.character.key) ?? [] },
|
||||||
elementIconUrls,
|
elementIconUrls,
|
||||||
sections,
|
sections,
|
||||||
|
|||||||
@@ -39,12 +39,14 @@ export const updateGuideSchema = z.strictObject({
|
|||||||
posterArtMediaId: z.string().uuid().nullable().optional(),
|
posterArtMediaId: z.string().uuid().nullable().optional(),
|
||||||
posterArtOffsetX: z.number().finite().min(-50).max(50).optional(),
|
posterArtOffsetX: z.number().finite().min(-50).max(50).optional(),
|
||||||
posterArtOffsetY: z.number().finite().min(-50).max(50).optional(),
|
posterArtOffsetY: z.number().finite().min(-50).max(50).optional(),
|
||||||
|
posterArtScale: z.number().finite().min(50).max(200).optional(),
|
||||||
posterTopColors: z.tuple([z.string().regex(/^#[0-9a-fA-F]{6}$/u), z.string().regex(/^#[0-9a-fA-F]{6}$/u)]).optional(),
|
posterTopColors: z.tuple([z.string().regex(/^#[0-9a-fA-F]{6}$/u), z.string().regex(/^#[0-9a-fA-F]{6}$/u)]).optional(),
|
||||||
posterBottomColors: z.tuple([z.string().regex(/^#[0-9a-fA-F]{6}$/u), z.string().regex(/^#[0-9a-fA-F]{6}$/u)]).optional(),
|
posterBottomColors: z.tuple([z.string().regex(/^#[0-9a-fA-F]{6}$/u), z.string().regex(/^#[0-9a-fA-F]{6}$/u)]).optional(),
|
||||||
posterBleeding: z.array(z.strictObject({
|
posterBleeding: z.array(z.strictObject({
|
||||||
mediaId: z.string().uuid(),
|
mediaId: z.string().uuid(),
|
||||||
amount: z.number().finite().min(0).max(100),
|
amount: z.number().finite().min(0).max(100),
|
||||||
scale: z.number().finite().min(25).max(300),
|
scale: z.number().finite().min(25).max(300),
|
||||||
|
blendMode: z.enum(["normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", "hard-light", "soft-light", "difference", "exclusion", "hue", "saturation", "color", "luminosity"]),
|
||||||
})).max(30).optional(),
|
})).max(30).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user