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
+16 -2
View File
@@ -28,7 +28,6 @@ import {
permanentlyDeleteGuide, permanentlyDeleteGuide,
reorderExtraSections, reorderExtraSections,
restoreGuide, restoreGuide,
runCatalogSync,
saveArtifacts, saveArtifacts,
saveConstellations, saveConstellations,
saveExtraSection, saveExtraSection,
@@ -39,6 +38,11 @@ import {
trashGuide, trashGuide,
updateGuideBasics, updateGuideBasics,
} from "@/lib/guides/mutations"; } from "@/lib/guides/mutations";
import {
cancelCatalogSyncJob,
getCatalogSyncJob,
startCatalogSyncJob,
} from "@/lib/catalog/sync";
const serializedBlockSchema = z.strictObject({ const serializedBlockSchema = z.strictObject({
id: z.string().uuid(), id: z.string().uuid(),
@@ -362,12 +366,22 @@ export async function deleteStructuredGuide(guideId: string) {
export async function startCatalogSync() { export async function startCatalogSync() {
await requireAdmin(); await requireAdmin();
try { try {
return await runCatalogSync(); return { status: "started" as const, job: await startCatalogSyncJob() };
} catch (cause) { } catch (cause) {
return { status: "error" as const, message: cause instanceof Error ? cause.message : "เริ่ม Sync ไม่สำเร็จ" }; return { status: "error" as const, message: cause instanceof Error ? cause.message : "เริ่ม Sync ไม่สำเร็จ" };
} }
} }
export async function getCatalogSyncProgress(jobId: string) {
await requireAdmin();
return getCatalogSyncJob(jobId);
}
export async function cancelCatalogSync(jobId: string) {
await requireAdmin();
return cancelCatalogSyncJob(jobId);
}
export async function addGlossaryAlias(input: unknown) { export async function addGlossaryAlias(input: unknown) {
await requireAdmin(); await requireAdmin();
try { try {
+75 -1
View File
@@ -1,6 +1,10 @@
"use client"; "use client";
import Image from "next/image"; 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 { useState } from "react";
import { saveGuideOverview } from "@/app/admin/actions"; 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 { MediaUploader } from "@/components/admin/media-uploader";
import { MaterialPoster } from "@/components/public/material-poster"; import { MaterialPoster } 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 { import {
Card, Card,
CardContent, CardContent,
@@ -32,6 +37,31 @@ import { detectThemeColorsFromPixels } from "@/lib/overview-image";
import type { PublicGuide } from "@/lib/guides/queries"; import type { PublicGuide } from "@/lib/guides/queries";
import { cn } from "@/lib/utils"; 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({ export function GuideOverviewForm({
guide, guide,
}: { }: {
@@ -49,6 +79,7 @@ export function GuideOverviewForm({
posterArtOffsetY: number; posterArtOffsetY: 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 }>;
character: Omit<PublicGuide["character"], "aliases">; character: Omit<PublicGuide["character"], "aliases">;
}; };
}) { }) {
@@ -68,6 +99,8 @@ export function GuideOverviewForm({
}); });
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 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);
const value = { const value = {
@@ -79,6 +112,7 @@ export function GuideOverviewForm({
posterArtOffsetY: posterArtOffset.y, posterArtOffsetY: posterArtOffset.y,
posterTopColors, posterTopColors,
posterBottomColors, posterBottomColors,
posterBleeding: bleeding.map(({ mediaId, amount, scale }) => ({ mediaId, amount, scale })),
}; };
const incompleteIssues = [ const incompleteIssues = [
!cover.id ? "กรุณาอัปโหลด Guide Image" : null, !cover.id ? "กรุณาอัปโหลด Guide Image" : null,
@@ -87,7 +121,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 }; const localDraft = { overview, youtubeUrl, cover, posterArt, posterArtOffset, 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),
@@ -143,6 +177,15 @@ export function GuideOverviewForm({
posterBottomColors: Array.isArray(draft.posterBottomColors) && draft.posterBottomColors.length === 2 posterBottomColors: Array.isArray(draft.posterBottomColors) && draft.posterBottomColors.length === 2
? draft.posterBottomColors as [string, string] ? draft.posterBottomColors as [string, string]
: guide.posterBottomColors, : 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) => { restoreLocalDraft: (draft) => {
@@ -153,6 +196,7 @@ export function GuideOverviewForm({
setPosterArtOffset(draft.posterArtOffset); setPosterArtOffset(draft.posterArtOffset);
setPosterTopColors(draft.posterTopColors); setPosterTopColors(draft.posterTopColors);
setPosterBottomColors(draft.posterBottomColors); setPosterBottomColors(draft.posterBottomColors);
setBleeding(draft.bleeding);
}, },
}); });
@@ -193,6 +237,16 @@ export function GuideOverviewForm({
const url = URL.createObjectURL(file); const url = URL.createObjectURL(file);
void detectPosterColors(url).finally(() => URL.revokeObjectURL(url)); 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 ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -273,6 +327,7 @@ export function GuideOverviewForm({
artworkOffsetY={posterArtOffset.y} artworkOffsetY={posterArtOffset.y}
topColors={posterTopColors} topColors={posterTopColors}
bottomColors={posterBottomColors} bottomColors={posterBottomColors}
bleeding={bleeding}
interactive interactive
onArtworkMove={setPosterArtOffset} onArtworkMove={setPosterArtOffset}
/> />
@@ -297,9 +352,28 @@ export function GuideOverviewForm({
artworkOffsetY={posterArtOffset.y} artworkOffsetY={posterArtOffset.y}
topColors={posterTopColors} topColors={posterTopColors}
bottomColors={posterBottomColors} bottomColors={posterBottomColors}
bleeding={bleeding}
/> />
</GuideImageExport> </GuideImageExport>
</div> </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>
อัปโหลดภาพตัวละคร แล้วลากบนตัวอย่างเพื่อจัดตำแหน่ง สีด้านบนและล่างจะตรวจให้อัตโนมัติ อัปโหลดภาพตัวละคร แล้วลากบนตัวอย่างเพื่อจัดตำแหน่ง สีด้านบนและล่างจะตรวจให้อัตโนมัติ
</FieldDescription> </FieldDescription>
+62 -3
View File
@@ -7,13 +7,16 @@ import {
PlusIcon, PlusIcon,
RefreshCwIcon, RefreshCwIcon,
RotateCcwIcon, RotateCcwIcon,
XCircleIcon,
Trash2Icon, Trash2Icon,
} from "lucide-react"; } from "lucide-react";
import { useTransition } from "react"; import { useEffect, useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
deleteStructuredGuide, deleteStructuredGuide,
cancelCatalogSync,
getCatalogSyncProgress,
moveGuideToTrash, moveGuideToTrash,
restoreStructuredGuide, restoreStructuredGuide,
startCatalogSync, startCatalogSync,
@@ -40,6 +43,8 @@ import {
} from "@/components/ui/empty"; } from "@/components/ui/empty";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useAdminPresence } from "@/hooks/use-presence"; import { useAdminPresence } from "@/hooks/use-presence";
import type { CatalogSyncJob } from "@/lib/catalog/sync";
import { cn } from "@/lib/utils";
type GuideCard = { type GuideCard = {
id: string; id: string;
@@ -66,6 +71,7 @@ export function StructuredDashboard({
}) { }) {
const router = useRouter(); const router = useRouter();
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const [syncJob, setSyncJob] = useState<CatalogSyncJob | null>(null);
const presence = useAdminPresence(null); const presence = useAdminPresence(null);
const catalogReady = Boolean(catalogVersion); const catalogReady = Boolean(catalogVersion);
const updateAvailable = Boolean( const updateAvailable = Boolean(
@@ -73,6 +79,39 @@ export function StructuredDashboard({
latestCatalogVersion && latestCatalogVersion &&
catalogVersion !== 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( function run(
action: () => Promise<{ status: string; message?: string }>, action: () => Promise<{ status: string; message?: string }>,
@@ -107,8 +146,8 @@ export function StructuredDashboard({
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="outline" variant="outline"
disabled={pending} disabled={pending || Boolean(syncJob && !["completed", "unchanged", "cancelled", "error"].includes(syncJob.status))}
onClick={() => run(startCatalogSync, "Sync สำเร็จแล้ว")} onClick={startSync}
> >
{pending ? ( {pending ? (
<Spinner data-icon="inline-start" /> <Spinner data-icon="inline-start" />
@@ -117,6 +156,12 @@ export function StructuredDashboard({
)} )}
Sync Catalog Sync Catalog
</Button> </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 <Button
render={<Link href="/admin/create" />} render={<Link href="/admin/create" />}
nativeButton={false} nativeButton={false}
@@ -127,6 +172,20 @@ export function StructuredDashboard({
</Button> </Button>
</div> </div>
</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 ? ( {guides.length ? (
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{guides.map((guide) => ( {guides.map((guide) => (
+15
View File
@@ -55,6 +55,7 @@ export function MaterialPoster({
interactive = false, interactive = false,
onArtworkMove, onArtworkMove,
className, className,
bleeding = [],
}: { }: {
character: PosterCharacter; character: PosterCharacter;
characterArtUrl: string | null; characterArtUrl: string | null;
@@ -65,6 +66,7 @@ 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 }>;
}) { }) {
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);
@@ -141,6 +143,19 @@ export function MaterialPoster({
/> />
</div> </div>
) : null} ) : 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="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)" }}> <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} artworkOffsetY={guide.posterArtOffsetY}
topColors={guide.posterTopColors} topColors={guide.posterTopColors}
bottomColors={guide.posterBottomColors} bottomColors={guide.posterBottomColors}
bleeding={guide.posterBleeding}
/> />
</section> </section>
) : null} ) : null}
+16
View File
@@ -565,6 +565,22 @@ export const guides = guidesSchema.table(
], ],
); );
export const guidePosterBleeding = guidesSchema.table(
"guide_poster_bleeding",
{
id: uuid("id").defaultRandom().primaryKey(),
guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }),
mediaId: uuid("media_id").notNull().references(() => media.id, { onDelete: "restrict" }),
amount: integer("amount").default(100).notNull(),
scale: integer("scale").default(100).notNull(),
sortOrder: integer("sort_order").default(0).notNull(),
...timestamps,
},
(table) => [
index("guide_poster_bleeding_order_idx").on(table.guideId, table.sortOrder),
],
);
export const guideSections = guidesSchema.table( export const guideSections = guidesSchema.table(
"guide_section", "guide_section",
{ {
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE "guides"."guide_poster_bleeding" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"guide_id" uuid NOT NULL,
"media_id" uuid NOT NULL,
"amount" integer DEFAULT 100 NOT NULL,
"scale" integer DEFAULT 100 NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "guides"."guide_poster_bleeding" ADD CONSTRAINT "guide_poster_bleeding_guide_id_guide_id_fk" FOREIGN KEY ("guide_id") REFERENCES "guides"."guide"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "guides"."guide_poster_bleeding" ADD CONSTRAINT "guide_poster_bleeding_media_id_media_id_fk" FOREIGN KEY ("media_id") REFERENCES "media"."media"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "guide_poster_bleeding_order_idx" ON "guides"."guide_poster_bleeding" USING btree ("guide_id","sort_order");
File diff suppressed because it is too large Load Diff
+7
View File
@@ -232,6 +232,13 @@
"when": 1788680680873, "when": 1788680680873,
"tag": "0032_grey_excalibur", "tag": "0032_grey_excalibur",
"breakpoints": true "breakpoints": true
},
{
"idx": 33,
"version": "7",
"when": 1788681628083,
"tag": "0033_true_captain_marvel",
"breakpoints": true
} }
] ]
} }
+86 -7
View File
@@ -28,6 +28,7 @@ import type {
} from "@/lib/catalog/lunaris"; } from "@/lib/catalog/lunaris";
import { customTravelers, isUpstreamTraveler } from "@/lib/catalog/travelers"; import { customTravelers, isUpstreamTraveler } from "@/lib/catalog/travelers";
import { withCatalogSyncLock } from "@/lib/catalog/sync-lock"; import { withCatalogSyncLock } from "@/lib/catalog/sync-lock";
import { getRedisClient, redisEventPrefix } from "@/lib/redis/client";
import { import {
compareLunarisVersions, compareLunarisVersions,
lunarisVersionSchema, lunarisVersionSchema,
@@ -100,9 +101,11 @@ interface SyncedCharacter {
baseStats: CatalogBaseStat[]; baseStats: CatalogBaseStat[];
} }
async function getJson(url: string): Promise<unknown> { type SyncControl = { checkCancelled: () => Promise<void>; report: (phase: string, completed: number, total: number, message: string) => Promise<void> };
async function getJson(url: string, signal?: AbortSignal): Promise<unknown> {
const response = await fetch(url, { const response = await fetch(url, {
signal: AbortSignal.timeout(30_000), signal: signal ?? AbortSignal.timeout(30_000),
cache: "no-store", cache: "no-store",
}); });
if (!response.ok) if (!response.ok)
@@ -110,9 +113,9 @@ async function getJson(url: string): Promise<unknown> {
return response.json(); return response.json();
} }
async function getOptionalJson(url: string): Promise<unknown | null> { async function getOptionalJson(url: string, signal?: AbortSignal): Promise<unknown | null> {
const response = await fetch(url, { const response = await fetch(url, {
signal: AbortSignal.timeout(30_000), signal: signal ?? AbortSignal.timeout(30_000),
cache: "no-store", cache: "no-store",
}); });
if (response.status === 404) return null; if (response.status === 404) return null;
@@ -149,13 +152,16 @@ export async function getLatestLunarisVersion(): Promise<string> {
async function mapConcurrent<T, R>( async function mapConcurrent<T, R>(
items: T[], items: T[],
task: (item: T) => Promise<R>, task: (item: T) => Promise<R>,
control?: SyncControl,
): Promise<R[]> { ): Promise<R[]> {
const results = new Array<R>(items.length); const results = new Array<R>(items.length);
let next = 0; let next = 0;
async function worker() { async function worker() {
while (next < items.length) { while (next < items.length) {
await control?.checkCancelled();
const index = next++; const index = next++;
results[index] = await task(items[index]); results[index] = await task(items[index]);
await control?.report("characters", index + 1, items.length, `ดึงข้อมูลตัวละคร ${index + 1}/${items.length}`);
} }
} }
await Promise.all( await Promise.all(
@@ -171,12 +177,14 @@ async function uploadAssets(
target: string; target: string;
type?: string; type?: string;
}>, }>,
control?: SyncControl,
): Promise<Set<string>> { ): Promise<Set<string>> {
const storage = await getMediaStorage(); const storage = await getMediaStorage();
let next = 0; let next = 0;
const missing = new Set<string>(); const missing = new Set<string>();
async function worker() { async function worker() {
while (next < assets.length) { while (next < assets.length) {
await control?.checkCancelled();
const asset = assets[next++]; const asset = assets[next++];
if (!(await storage.file(asset.target).exists())) { if (!(await storage.file(asset.target).exists())) {
if (asset.body) { if (asset.body) {
@@ -199,6 +207,7 @@ async function uploadAssets(
}); });
} }
} }
await control?.report("assets", next, assets.length, `ดาวน์โหลดรูปภาพ ${next}/${assets.length}`);
} }
} }
await Promise.all( await Promise.all(
@@ -207,13 +216,15 @@ async function uploadAssets(
return missing; return missing;
} }
async function syncCatalogVersion(version: string): Promise<void> { async function syncCatalogVersion(version: string, control?: SyncControl): Promise<void> {
await control?.report("lists", 0, 4, "กำลังดึงรายการ Catalog จาก Lunaris");
const [charactersRaw, weaponsRaw, artifactsRaw, materialsRaw] = await Promise.all([ const [charactersRaw, weaponsRaw, artifactsRaw, materialsRaw] = await Promise.all([
getJson(`${API_ROOT}/${version}/charlist.json`), getJson(`${API_ROOT}/${version}/charlist.json`),
getJson(`${API_ROOT}/${version}/weaponlist.json`), getJson(`${API_ROOT}/${version}/weaponlist.json`),
getJson(`${API_ROOT}/${version}/artifactlist.json`), getJson(`${API_ROOT}/${version}/artifactlist.json`),
getJson(`${API_ROOT}/${version}/materiallist.json`), getJson(`${API_ROOT}/${version}/materiallist.json`),
]); ]);
await control?.report("lists", 4, 4, "ได้รับรายการ Catalog แล้ว");
const characterData = characterSchema.parse(charactersRaw); const characterData = characterSchema.parse(charactersRaw);
const weaponData = weaponSchema.parse(weaponsRaw); const weaponData = weaponSchema.parse(weaponsRaw);
const artifactData = artifactSchema.parse(artifactsRaw); const artifactData = artifactSchema.parse(artifactsRaw);
@@ -280,6 +291,7 @@ async function syncCatalogVersion(version: string): Promise<void> {
baseStats: detail ? baseStatsSnapshot(detail) : [], baseStats: detail ? baseStatsSnapshot(detail) : [],
}; };
}, },
control,
); );
const customCharacters: SyncedCharacter[] = await mapConcurrent( const customCharacters: SyncedCharacter[] = await mapConcurrent(
[...customTravelers], [...customTravelers],
@@ -315,6 +327,7 @@ async function syncCatalogVersion(version: string): Promise<void> {
baseStats: baseStatsSnapshot(detail), baseStats: baseStatsSnapshot(detail),
}; };
}, },
control,
); );
let characters = [...remoteCharacters, ...customCharacters]; let characters = [...remoteCharacters, ...customCharacters];
let weapons = Object.entries(weaponData) let weapons = Object.entries(weaponData)
@@ -409,7 +422,8 @@ async function syncCatalogVersion(version: string): Promise<void> {
}); });
} }
} }
const missingAssets = await uploadAssets([...uniqueAssets.values()]); await control?.report("assets", 0, uniqueAssets.size, "กำลังดาวน์โหลดรูปภาพ Catalog");
const missingAssets = await uploadAssets([...uniqueAssets.values()], control);
characters = characters.filter((item) => !missingAssets.has(item.imageKey)); characters = characters.filter((item) => !missingAssets.has(item.imageKey));
characters = characters.map((item) => ({ characters = characters.map((item) => ({
...item, ...item,
@@ -498,6 +512,7 @@ async function syncCatalogVersion(version: string): Promise<void> {
), ),
]); ]);
await control?.report("database", 0, 1, "กำลังบันทึก Catalog");
await getDb().transaction(async (tx) => { await getDb().transaction(async (tx) => {
await tx.delete(catalogCharacters); await tx.delete(catalogCharacters);
await tx.delete(catalogMaterials); await tx.delete(catalogMaterials);
@@ -549,6 +564,7 @@ async function syncCatalogVersion(version: string): Promise<void> {
})), })),
); );
}); });
await control?.report("database", 1, 1, "บันทึก Catalog แล้ว");
await storage.write("catalog/current.json", JSON.stringify({ version }), { await storage.write("catalog/current.json", JSON.stringify({ version }), {
type: "application/json", type: "application/json",
acl: "public-read", acl: "public-read",
@@ -557,6 +573,67 @@ async function syncCatalogVersion(version: string): Promise<void> {
export type CatalogSyncResult = "synced" | "unchanged"; export type CatalogSyncResult = "synced" | "unchanged";
export type CatalogSyncJob = {
id: string;
status: "queued" | "running" | "completed" | "unchanged" | "cancelled" | "error";
phase: string;
completed: number;
total: number;
message: string;
version?: string;
error?: string;
};
const syncJobKey = (id: string) => `${redisEventPrefix()}:catalog-sync-job:${id}`;
async function writeSyncJob(job: CatalogSyncJob) {
const redis = await getRedisClient();
await redis.set(syncJobKey(job.id), JSON.stringify(job), "EX", 60 * 60);
return job;
}
export async function getCatalogSyncJob(id: string): Promise<CatalogSyncJob | null> {
const redis = await getRedisClient();
const value = await redis.get(syncJobKey(id));
return value ? JSON.parse(value) as CatalogSyncJob : null;
}
export async function cancelCatalogSyncJob(id: string): Promise<CatalogSyncJob | null> {
const job = await getCatalogSyncJob(id);
if (!job || ["completed", "unchanged", "cancelled", "error"].includes(job.status)) return job;
return writeSyncJob({ ...job, message: "กำลังยกเลิก Sync…", status: "cancelled" });
}
export async function startCatalogSyncJob(): Promise<CatalogSyncJob> {
const id = crypto.randomUUID();
const job = await writeSyncJob({ id, status: "queued", phase: "queued", completed: 0, total: 0, message: "กำลังเตรียม Sync" });
void executeCatalogSyncJob(job).catch(() => undefined);
return job;
}
async function executeCatalogSyncJob(initial: CatalogSyncJob) {
const checkCancelled = async () => {
const current = await getCatalogSyncJob(initial.id);
if (current?.status === "cancelled") throw new Error("CATALOG_SYNC_CANCELLED");
};
const report = async (phase: string, completed: number, total: number, message: string) => {
const current = await getCatalogSyncJob(initial.id);
if (current?.status === "cancelled") throw new Error("CATALOG_SYNC_CANCELLED");
await writeSyncJob({ ...initial, status: "running", phase, completed, total, message });
};
try {
await writeSyncJob({ ...initial, status: "running", phase: "version", message: "กำลังตรวจสอบเวอร์ชัน Lunaris" });
const result = await processCatalogSync(undefined, { checkCancelled, report });
await writeSyncJob({ ...initial, status: result === "unchanged" ? "unchanged" : "completed", phase: "done", completed: 1, total: 1, message: result === "unchanged" ? "Catalog เป็นเวอร์ชันล่าสุดแล้ว" : "Sync Catalog สำเร็จ" });
} catch (cause) {
if (cause instanceof Error && cause.message === "CATALOG_SYNC_CANCELLED") {
await writeSyncJob({ ...initial, status: "cancelled", phase: "cancelled", message: "ยกเลิก Sync แล้ว" });
} else {
await writeSyncJob({ ...initial, status: "error", phase: "error", message: "Sync ไม่สำเร็จ", error: cause instanceof Error ? cause.message : "Unknown sync error" });
}
}
}
export async function getCurrentCatalogVersion(): Promise<string | null> { export async function getCurrentCatalogVersion(): Promise<string | null> {
const storage = await getMediaStorage(); const storage = await getMediaStorage();
const current = storage.file("catalog/current.json"); const current = storage.file("catalog/current.json");
@@ -566,6 +643,7 @@ export async function getCurrentCatalogVersion(): Promise<string | null> {
export async function processCatalogSync( export async function processCatalogSync(
requestedVersion?: string, requestedVersion?: string,
control?: SyncControl,
): Promise<CatalogSyncResult> { ): Promise<CatalogSyncResult> {
const version = lunarisVersionSchema.parse( const version = lunarisVersionSchema.parse(
requestedVersion ?? (await getLatestLunarisVersion()), requestedVersion ?? (await getLatestLunarisVersion()),
@@ -579,7 +657,8 @@ export async function processCatalogSync(
) { ) {
return "unchanged"; return "unchanged";
} }
await syncCatalogVersion(version); await control?.checkCancelled();
await syncCatalogVersion(version, control);
return "synced"; return "synced";
}); });
} }
+10 -2
View File
@@ -9,6 +9,7 @@ import {
dataSources, dataSources,
dataSourceVersions, dataSourceVersions,
guides, guides,
guidePosterBleeding,
media, media,
pageDataSources, pageDataSources,
pages, pages,
@@ -309,7 +310,7 @@ export async function getMediaVisibility(mediaId: string): Promise<{
retained: boolean; retained: boolean;
}> { }> {
const db = getDb(); const db = getDb();
const [referencedBlocks, portraitCharacters, posterGuides, [mediaRecord]] = await Promise.all([ const [referencedBlocks, portraitCharacters, posterGuides, bleedingGuides, [mediaRecord]] = await Promise.all([
db db
.select({ block: blocks, page: pages, character: characters }) .select({ block: blocks, page: pages, character: characters })
.from(blocks) .from(blocks)
@@ -323,6 +324,11 @@ export async function getMediaVisibility(mediaId: string): Promise<{
.select({ guide: guides }) .select({ guide: guides })
.from(guides) .from(guides)
.where(eq(guides.posterArtMediaId, mediaId)), .where(eq(guides.posterArtMediaId, mediaId)),
db
.select({ guide: guides })
.from(guidePosterBleeding)
.innerJoin(guides, eq(guides.id, guidePosterBleeding.guideId))
.where(eq(guidePosterBleeding.mediaId, mediaId)),
db.select().from(media).where(eq(media.id, mediaId)).limit(1), db.select().from(media).where(eq(media.id, mediaId)).limit(1),
]); ]);
@@ -342,11 +348,13 @@ export async function getMediaVisibility(mediaId: string): Promise<{
references.some( references.some(
({ page, character }) => page.visible && character.visible, ({ page, character }) => page.visible && character.visible,
) || portraitCharacters.some(({ character }) => character.visible) || ) || portraitCharacters.some(({ character }) => character.visible) ||
posterGuides.some(({ guide }) => guide.isPublic && !guide.trashedAt), posterGuides.some(({ guide }) => guide.isPublic && !guide.trashedAt) ||
bleedingGuides.some(({ guide }) => guide.isPublic && !guide.trashedAt),
retained: retained:
references.length > 0 || references.length > 0 ||
portraitCharacters.length > 0 || portraitCharacters.length > 0 ||
posterGuides.length > 0 || posterGuides.length > 0 ||
bleedingGuides.length > 0 ||
Boolean( Boolean(
mediaRecord && mediaRecord &&
(mediaRecord.currentReferenceCount > 0 || (mediaRecord.currentReferenceCount > 0 ||
+31
View File
@@ -15,6 +15,7 @@ import {
extraSectionRows, extraSectionRows,
extraSections, extraSections,
guides, guides,
guidePosterBleeding,
guideSections, guideSections,
media, media,
outboxEvents, outboxEvents,
@@ -137,6 +138,13 @@ export async function updateGuideBasics(guideId: string, input: unknown) {
.limit(1); .limit(1);
if (!poster) throw new Error("ไม่พบภาพตัวละครที่อัปโหลดแล้ว"); if (!poster) throw new Error("ไม่พบภาพตัวละครที่อัปโหลดแล้ว");
} }
if (data.posterBleeding !== undefined) {
const bleedingMediaIds = [...new Set(data.posterBleeding.map((layer) => layer.mediaId))];
const bleedingMedia = bleedingMediaIds.length
? await tx.select({ id: media.id }).from(media).where(and(inArray(media.id, bleedingMediaIds), eq(media.status, "ready")))
: [];
if (bleedingMedia.length !== bleedingMediaIds.length) throw new Error("ไม่พบภาพ Bleeding ที่อัปโหลดแล้ว");
}
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion); const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
await tx.update(guides).set({ await tx.update(guides).set({
overview: data.overview, overview: data.overview,
@@ -148,6 +156,25 @@ export async function updateGuideBasics(guideId: string, input: unknown) {
...(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));
if (data.posterBleeding !== undefined) {
const previous = await tx.select({ mediaId: guidePosterBleeding.mediaId }).from(guidePosterBleeding).where(eq(guidePosterBleeding.guideId, guideId));
for (const layer of previous) {
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, layer.mediaId));
}
await tx.delete(guidePosterBleeding).where(eq(guidePosterBleeding.guideId, guideId));
if (data.posterBleeding.length) {
await tx.insert(guidePosterBleeding).values(data.posterBleeding.map((layer, sortOrder) => ({
guideId,
mediaId: layer.mediaId,
amount: Math.round(layer.amount),
scale: Math.round(layer.scale),
sortOrder,
})));
for (const layer of data.posterBleeding) {
await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, layer.mediaId));
}
}
}
if (current.coverMediaId !== data.coverMediaId) { if (current.coverMediaId !== data.coverMediaId) {
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, current.coverMediaId)); await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, current.coverMediaId));
await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, data.coverMediaId)); await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, data.coverMediaId));
@@ -412,11 +439,15 @@ export async function permanentlyDeleteGuide(guideId: string) {
return getDb().transaction(async (tx) => { return getDb().transaction(async (tx) => {
const [guide] = await tx.select().from(guides).where(eq(guides.id, guideId)).limit(1); const [guide] = await tx.select().from(guides).where(eq(guides.id, guideId)).limit(1);
if (!guide?.trashedAt) throw new Error("Guide ต้องอยู่ในถังขยะก่อนลบถาวร"); if (!guide?.trashedAt) throw new Error("Guide ต้องอยู่ในถังขยะก่อนลบถาวร");
const bleeding = await tx.select({ mediaId: guidePosterBleeding.mediaId }).from(guidePosterBleeding).where(eq(guidePosterBleeding.guideId, guideId));
await tx.delete(guides).where(eq(guides.id, guideId)); await tx.delete(guides).where(eq(guides.id, guideId));
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, guide.coverMediaId)); await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, guide.coverMediaId));
if (guide.posterArtMediaId) { if (guide.posterArtMediaId) {
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, guide.posterArtMediaId)); await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, guide.posterArtMediaId));
} }
for (const layer of bleeding) {
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, layer.mediaId));
}
await emitGuideEvents(tx, guide.id, guide.version + 1); await emitGuideEvents(tx, guide.id, guide.version + 1);
return { version: guide.version + 1 }; return { version: guide.version + 1 };
}); });
+7 -2
View File
@@ -16,6 +16,7 @@ import {
constellationRecommendations, constellationRecommendations,
extraSectionRows, extraSectionRows,
guides, guides,
guidePosterBleeding,
guideSections, guideSections,
glossaryAliases, glossaryAliases,
media, media,
@@ -124,13 +125,14 @@ export async function getGuideByCharacterKey(characterKey: string) {
const materialKeys = [...new Set( const materialKeys = [...new Set(
row.character.ascension.flatMap((group) => group.items.map((item) => item.key)), row.character.ascension.flatMap((group) => group.items.map((item) => item.key)),
)]; )];
const [materials, poster] = await Promise.all([ const [materials, poster, bleeding] = await Promise.all([
materialKeys.length materialKeys.length
? getDb().select().from(catalogMaterials).where(inArray(catalogMaterials.key, materialKeys)) ? getDb().select().from(catalogMaterials).where(inArray(catalogMaterials.key, materialKeys))
: [], : [],
row.guide.posterArtMediaId row.guide.posterArtMediaId
? getDb().select().from(media).where(eq(media.id, row.guide.posterArtMediaId)).limit(1) ? getDb().select().from(media).where(eq(media.id, row.guide.posterArtMediaId)).limit(1)
: Promise.resolve([]), : Promise.resolve([]),
getDb().select({ layer: guidePosterBleeding, asset: media }).from(guidePosterBleeding).leftJoin(media, eq(media.id, guidePosterBleeding.mediaId)).where(eq(guidePosterBleeding.guideId, row.guide.id)).orderBy(asc(guidePosterBleeding.sortOrder)),
]); ]);
const materialByKey = new Map(materials.map((item) => [item.key, item])); const materialByKey = new Map(materials.map((item) => [item.key, item]));
const sections = await getDb().select().from(guideSections).where(eq(guideSections.guideId, row.guide.id)).orderBy(asc(guideSections.sortOrder)); const sections = await getDb().select().from(guideSections).where(eq(guideSections.guideId, row.guide.id)).orderBy(asc(guideSections.sortOrder));
@@ -138,6 +140,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 })),
character: catalogCharacterWithUrls(row.character, materialByKey), character: catalogCharacterWithUrls(row.character, materialByKey),
sections, sections,
}; };
@@ -159,7 +162,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
const materialKeys = [...new Set( const materialKeys = [...new Set(
row.character.ascension.flatMap((group) => group.items.map((item) => item.key)), row.character.ascension.flatMap((group) => group.items.map((item) => item.key)),
)]; )];
const [sections, weaponConditionsRows, groups, profile, constellationGroups, teams, aliases, materials, poster] = await Promise.all([ const [sections, weaponConditionsRows, groups, profile, constellationGroups, teams, aliases, materials, poster, bleeding] = await Promise.all([
getDb().select().from(guideSections).where(and(eq(guideSections.guideId, row.guide.id), eq(guideSections.enabled, true))).orderBy(asc(guideSections.sortOrder)), getDb().select().from(guideSections).where(and(eq(guideSections.guideId, row.guide.id), eq(guideSections.enabled, true))).orderBy(asc(guideSections.sortOrder)),
getDb().select().from(weaponConditions).where(eq(weaponConditions.guideId, row.guide.id)).orderBy(asc(weaponConditions.sortOrder)), getDb().select().from(weaponConditions).where(eq(weaponConditions.guideId, row.guide.id)).orderBy(asc(weaponConditions.sortOrder)),
getDb().select().from(artifactGroups).where(eq(artifactGroups.guideId, row.guide.id)).orderBy(asc(artifactGroups.sortOrder)), getDb().select().from(artifactGroups).where(eq(artifactGroups.guideId, row.guide.id)).orderBy(asc(artifactGroups.sortOrder)),
@@ -173,6 +176,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
row.guide.posterArtMediaId row.guide.posterArtMediaId
? getDb().select().from(media).where(eq(media.id, row.guide.posterArtMediaId)).limit(1) ? getDb().select().from(media).where(eq(media.id, row.guide.posterArtMediaId)).limit(1)
: Promise.resolve([]), : Promise.resolve([]),
getDb().select({ layer: guidePosterBleeding, asset: media }).from(guidePosterBleeding).leftJoin(media, eq(media.id, guidePosterBleeding.mediaId)).where(eq(guidePosterBleeding.guideId, row.guide.id)).orderBy(asc(guidePosterBleeding.sortOrder)),
]); ]);
const groupedAliases = aliasesByItem(aliases); const groupedAliases = aliasesByItem(aliases);
const materialByKey = new Map(materials.map((item) => [item.key, item])); const materialByKey = new Map(materials.map((item) => [item.key, item]));
@@ -221,6 +225,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 })),
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,
+5
View File
@@ -41,6 +41,11 @@ export const updateGuideSchema = z.strictObject({
posterArtOffsetY: z.number().finite().min(-50).max(50).optional(), posterArtOffsetY: z.number().finite().min(-50).max(50).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({
mediaId: z.string().uuid(),
amount: z.number().finite().min(0).max(100),
scale: z.number().finite().min(25).max(300),
})).max(30).optional(),
}); });
export const weaponSectionSchema = z.strictObject({ export const weaponSectionSchema = z.strictObject({