feat : update
This commit is contained in:
@@ -17,6 +17,14 @@
|
|||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Kanit Poster";
|
||||||
|
src: url("/Kanit-ExtraBoldItalic.ttf") format("truetype");
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 800;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ 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 { GuideImageExport } from "@/components/public/guide-image-export";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -26,6 +28,8 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { useAutosave } from "@/hooks/use-autosave";
|
import { useAutosave } from "@/hooks/use-autosave";
|
||||||
import { adminDraftKey } from "@/lib/editor/local-draft";
|
import { adminDraftKey } from "@/lib/editor/local-draft";
|
||||||
import { parseYouTubeUrl } from "@/lib/guides/youtube";
|
import { parseYouTubeUrl } from "@/lib/guides/youtube";
|
||||||
|
import { detectThemeColorsFromPixels } from "@/lib/overview-image";
|
||||||
|
import type { PublicGuide } from "@/lib/guides/queries";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function GuideOverviewForm({
|
export function GuideOverviewForm({
|
||||||
@@ -39,6 +43,13 @@ export function GuideOverviewForm({
|
|||||||
coverMediaId: string;
|
coverMediaId: string;
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
posterArtMediaId: string | null;
|
||||||
|
posterArtUrl: string | null;
|
||||||
|
posterArtOffsetX: number;
|
||||||
|
posterArtOffsetY: number;
|
||||||
|
posterTopColors: [string, string];
|
||||||
|
posterBottomColors: [string, string];
|
||||||
|
character: Omit<PublicGuide["character"], "aliases">;
|
||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
const [overview, setOverview] = useState(guide.overview);
|
const [overview, setOverview] = useState(guide.overview);
|
||||||
@@ -47,9 +58,28 @@ export function GuideOverviewForm({
|
|||||||
id: guide.coverMediaId,
|
id: guide.coverMediaId,
|
||||||
url: guide.coverUrl,
|
url: guide.coverUrl,
|
||||||
});
|
});
|
||||||
|
const [posterArt, setPosterArt] = useState({
|
||||||
|
id: guide.posterArtMediaId,
|
||||||
|
url: guide.posterArtUrl,
|
||||||
|
});
|
||||||
|
const [posterArtOffset, setPosterArtOffset] = useState({
|
||||||
|
x: guide.posterArtOffsetX,
|
||||||
|
y: guide.posterArtOffsetY,
|
||||||
|
});
|
||||||
|
const [posterTopColors, setPosterTopColors] = useState(guide.posterTopColors);
|
||||||
|
const [posterBottomColors, setPosterBottomColors] = useState(guide.posterBottomColors);
|
||||||
const youtubeVideo = parseYouTubeUrl(youtubeUrl.trim());
|
const youtubeVideo = parseYouTubeUrl(youtubeUrl.trim());
|
||||||
const invalidYoutubeUrl = Boolean(youtubeUrl.trim() && !youtubeVideo);
|
const invalidYoutubeUrl = Boolean(youtubeUrl.trim() && !youtubeVideo);
|
||||||
const value = { coverMediaId: cover.id, overview, youtubeUrl };
|
const value = {
|
||||||
|
coverMediaId: cover.id,
|
||||||
|
overview,
|
||||||
|
youtubeUrl,
|
||||||
|
posterArtMediaId: posterArt.id,
|
||||||
|
posterArtOffsetX: posterArtOffset.x,
|
||||||
|
posterArtOffsetY: posterArtOffset.y,
|
||||||
|
posterTopColors,
|
||||||
|
posterBottomColors,
|
||||||
|
};
|
||||||
const incompleteIssues = [
|
const incompleteIssues = [
|
||||||
!cover.id ? "กรุณาอัปโหลด Guide Image" : null,
|
!cover.id ? "กรุณาอัปโหลด Guide Image" : null,
|
||||||
!overview.trim() ? "กรุณากรอก Overview" : null,
|
!overview.trim() ? "กรุณากรอก Overview" : null,
|
||||||
@@ -57,7 +87,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 };
|
const localDraft = { overview, youtubeUrl, cover, posterArt, posterArtOffset, posterTopColors, posterBottomColors };
|
||||||
const autosave = useAutosave({
|
const autosave = useAutosave({
|
||||||
value,
|
value,
|
||||||
valid: Boolean(cover.id && overview.trim() && !invalidYoutubeUrl),
|
valid: Boolean(cover.id && overview.trim() && !invalidYoutubeUrl),
|
||||||
@@ -91,14 +121,78 @@ export function GuideOverviewForm({
|
|||||||
youtubeUrl:
|
youtubeUrl:
|
||||||
typeof draft.youtubeUrl === "string" ? draft.youtubeUrl : "",
|
typeof draft.youtubeUrl === "string" ? draft.youtubeUrl : "",
|
||||||
cover: { id: draftCover.id, url: draftCover.url },
|
cover: { id: draftCover.id, url: draftCover.url },
|
||||||
|
posterArt: draft.posterArt && typeof draft.posterArt === "object"
|
||||||
|
? {
|
||||||
|
id: typeof (draft.posterArt as Record<string, unknown>).id === "string"
|
||||||
|
? (draft.posterArt as Record<string, unknown>).id as string
|
||||||
|
: null,
|
||||||
|
url: typeof (draft.posterArt as Record<string, unknown>).url === "string"
|
||||||
|
? (draft.posterArt as Record<string, unknown>).url as string
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
: { id: guide.posterArtMediaId, url: guide.posterArtUrl },
|
||||||
|
posterArtOffset: draft.posterArtOffset && typeof draft.posterArtOffset === "object"
|
||||||
|
? {
|
||||||
|
x: typeof (draft.posterArtOffset as Record<string, unknown>).x === "number" ? (draft.posterArtOffset as Record<string, unknown>).x 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 },
|
||||||
|
posterTopColors: Array.isArray(draft.posterTopColors) && draft.posterTopColors.length === 2
|
||||||
|
? draft.posterTopColors as [string, string]
|
||||||
|
: guide.posterTopColors,
|
||||||
|
posterBottomColors: Array.isArray(draft.posterBottomColors) && draft.posterBottomColors.length === 2
|
||||||
|
? draft.posterBottomColors as [string, string]
|
||||||
|
: guide.posterBottomColors,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
restoreLocalDraft: (draft) => {
|
restoreLocalDraft: (draft) => {
|
||||||
setOverview(draft.overview);
|
setOverview(draft.overview);
|
||||||
setYoutubeUrl(draft.youtubeUrl);
|
setYoutubeUrl(draft.youtubeUrl);
|
||||||
setCover(draft.cover);
|
setCover(draft.cover);
|
||||||
|
setPosterArt(draft.posterArt);
|
||||||
|
setPosterArtOffset(draft.posterArtOffset);
|
||||||
|
setPosterTopColors(draft.posterTopColors);
|
||||||
|
setPosterBottomColors(draft.posterBottomColors);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function detectPosterColors(url: string) {
|
||||||
|
try {
|
||||||
|
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const candidate = new window.Image();
|
||||||
|
candidate.crossOrigin = "anonymous";
|
||||||
|
candidate.onload = () => resolve(candidate);
|
||||||
|
candidate.onerror = reject;
|
||||||
|
candidate.src = url;
|
||||||
|
});
|
||||||
|
const scale = Math.min(1, 512 / Math.max(image.naturalWidth, image.naturalHeight));
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = Math.max(1, Math.round(image.naturalWidth * scale));
|
||||||
|
canvas.height = Math.max(1, Math.round(image.naturalHeight * scale));
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) return;
|
||||||
|
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||||
|
const colors = detectThemeColorsFromPixels(
|
||||||
|
context.getImageData(0, 0, canvas.width, canvas.height).data,
|
||||||
|
canvas.width,
|
||||||
|
canvas.height,
|
||||||
|
);
|
||||||
|
setPosterTopColors(colors.top);
|
||||||
|
setPosterBottomColors(colors.bottom);
|
||||||
|
} catch {
|
||||||
|
// Keep the current colors when the uploaded media host disallows pixel reads.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUploadedPoster(asset: { id: string; url: string }) {
|
||||||
|
setPosterArt(asset);
|
||||||
|
setPosterArtOffset({ x: 0, y: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectPosterColorsFromFile(file: File) {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
void detectPosterColors(url).finally(() => URL.revokeObjectURL(url));
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -169,6 +263,47 @@ export function GuideOverviewForm({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Materials Overview</FieldLabel>
|
||||||
|
<div className="mx-auto w-full max-w-md overflow-hidden rounded-lg border">
|
||||||
|
<MaterialPoster
|
||||||
|
character={guide.character}
|
||||||
|
characterArtUrl={posterArt.url}
|
||||||
|
artworkOffsetX={posterArtOffset.x}
|
||||||
|
artworkOffsetY={posterArtOffset.y}
|
||||||
|
topColors={posterTopColors}
|
||||||
|
bottomColors={posterBottomColors}
|
||||||
|
interactive
|
||||||
|
onArtworkMove={setPosterArtOffset}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
<MediaUploader
|
||||||
|
onReady={setUploadedPoster}
|
||||||
|
onFileSelected={detectPosterColorsFromFile}
|
||||||
|
/>
|
||||||
|
<GuideImageExport
|
||||||
|
width={1600}
|
||||||
|
height={2000}
|
||||||
|
format="webp"
|
||||||
|
disabled={!posterArt.url}
|
||||||
|
fileName={`${guide.name}-materials.webp`}
|
||||||
|
captureClassName="p-0"
|
||||||
|
>
|
||||||
|
<MaterialPoster
|
||||||
|
character={guide.character}
|
||||||
|
characterArtUrl={posterArt.url}
|
||||||
|
artworkOffsetX={posterArtOffset.x}
|
||||||
|
artworkOffsetY={posterArtOffset.y}
|
||||||
|
topColors={posterTopColors}
|
||||||
|
bottomColors={posterBottomColors}
|
||||||
|
/>
|
||||||
|
</GuideImageExport>
|
||||||
|
</div>
|
||||||
|
<FieldDescription>
|
||||||
|
อัปโหลดภาพตัวละคร แล้วลากบนตัวอย่างเพื่อจัดตำแหน่ง สีด้านบนและล่างจะตรวจให้อัตโนมัติ
|
||||||
|
</FieldDescription>
|
||||||
|
</Field>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="justify-end">
|
<CardFooter className="justify-end">
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ export interface UploadedMedia {
|
|||||||
|
|
||||||
export function MediaUploader({
|
export function MediaUploader({
|
||||||
onReady,
|
onReady,
|
||||||
|
onFileSelected,
|
||||||
}: {
|
}: {
|
||||||
onReady: (media: UploadedMedia) => void;
|
onReady: (media: UploadedMedia) => void;
|
||||||
|
onFileSelected?: (file: File) => void;
|
||||||
}) {
|
}) {
|
||||||
const inputId = useId();
|
const inputId = useId();
|
||||||
const [state, setState] = useState<"idle" | "uploading">("idle");
|
const [state, setState] = useState<"idle" | "uploading">("idle");
|
||||||
@@ -67,7 +69,10 @@ export function MediaUploader({
|
|||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
if (file) void upload(file);
|
if (file) {
|
||||||
|
onFileSelected?.(file);
|
||||||
|
void upload(file);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -20,12 +20,16 @@ export function GuideImageExport({
|
|||||||
fileName,
|
fileName,
|
||||||
width = EXPORT_WIDTH,
|
width = EXPORT_WIDTH,
|
||||||
height,
|
height,
|
||||||
|
format = "png",
|
||||||
|
disabled = false,
|
||||||
captureClassName,
|
captureClassName,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
|
format?: "png" | "webp";
|
||||||
|
disabled?: boolean;
|
||||||
captureClassName?: string;
|
captureClassName?: string;
|
||||||
}) {
|
}) {
|
||||||
const captureRef = useRef<HTMLDivElement>(null);
|
const captureRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -45,6 +49,7 @@ export function GuideImageExport({
|
|||||||
await Promise.all([document.fonts.ready, waitUntilLoad(node)]);
|
await Promise.all([document.fonts.ready, waitUntilLoad(node)]);
|
||||||
const blob = await domToBlob(node, {
|
const blob = await domToBlob(node, {
|
||||||
backgroundColor: getComputedStyle(node).backgroundColor,
|
backgroundColor: getComputedStyle(node).backgroundColor,
|
||||||
|
type: `image/${format}`,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
@@ -70,7 +75,7 @@ export function GuideImageExport({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [capturing, fileName, height, width]);
|
}, [capturing, fileName, format, height, width]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -78,7 +83,7 @@ export function GuideImageExport({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={capturing}
|
disabled={capturing || disabled}
|
||||||
onClick={() => setCapturing(true)}
|
onClick={() => setCapturing(true)}
|
||||||
>
|
>
|
||||||
{capturing ? (
|
{capturing ? (
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useRef, type PointerEvent } from "react";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CatalogAscensionGroup,
|
||||||
|
CatalogBaseStat,
|
||||||
|
} from "@/lib/catalog/lunaris";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type PosterMaterial = {
|
||||||
|
key: string;
|
||||||
|
amount: number;
|
||||||
|
name: string;
|
||||||
|
imageUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PosterCharacter = {
|
||||||
|
name: string;
|
||||||
|
nameCardImageUrl: string | null;
|
||||||
|
baseStats: CatalogBaseStat[];
|
||||||
|
ascension: Array<Omit<CatalogAscensionGroup, "items"> & { items: PosterMaterial[] }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statLabels: Record<string, string> = {
|
||||||
|
hp: "HP",
|
||||||
|
atk: "ATK",
|
||||||
|
def: "DEF",
|
||||||
|
em: "EM",
|
||||||
|
};
|
||||||
|
|
||||||
|
function statLabel(key: string): string {
|
||||||
|
return statLabels[key.toLowerCase()] ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statValue(key: string, value: number | string): string {
|
||||||
|
const formatted = typeof value === "number"
|
||||||
|
? value.toLocaleString("en-US", { maximumFractionDigits: 2 })
|
||||||
|
: value;
|
||||||
|
return key.includes("%") ? `${formatted}%` : formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampOffset(value: number): number {
|
||||||
|
return Math.max(-50, Math.min(50, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MaterialPoster({
|
||||||
|
character,
|
||||||
|
characterArtUrl,
|
||||||
|
artworkOffsetX,
|
||||||
|
artworkOffsetY,
|
||||||
|
topColors,
|
||||||
|
bottomColors,
|
||||||
|
interactive = false,
|
||||||
|
onArtworkMove,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
character: PosterCharacter;
|
||||||
|
characterArtUrl: string | null;
|
||||||
|
artworkOffsetX: number;
|
||||||
|
artworkOffsetY: number;
|
||||||
|
topColors: [string, string];
|
||||||
|
bottomColors: [string, string];
|
||||||
|
interactive?: boolean;
|
||||||
|
onArtworkMove?: (offset: { x: number; y: number }) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const dragStart = useRef<{ pointerId: number; x: number; y: number; offsetX: number; offsetY: number } | null>(null);
|
||||||
|
const stats = character.baseStats.slice(0, 4);
|
||||||
|
const materials = character.ascension.flatMap((group) => group.items);
|
||||||
|
const background = `linear-gradient(180deg, ${topColors[0]} 0%, ${topColors[1]} 22%, rgba(0,0,0,0) 42%, rgba(0,0,0,0) 62%, ${bottomColors[0]} 88%, ${bottomColors[1]} 100%)`;
|
||||||
|
|
||||||
|
function handlePointerDown(event: PointerEvent<HTMLDivElement>) {
|
||||||
|
if (!interactive || !characterArtUrl || !onArtworkMove) return;
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
dragStart.current = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
offsetX: artworkOffsetX,
|
||||||
|
offsetY: artworkOffsetY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(event: PointerEvent<HTMLDivElement>) {
|
||||||
|
if (!dragStart.current || dragStart.current.pointerId !== event.pointerId || !onArtworkMove) return;
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
onArtworkMove({
|
||||||
|
x: clampOffset(dragStart.current.offsetX + ((event.clientX - dragStart.current.x) / rect.width) * 100),
|
||||||
|
y: clampOffset(dragStart.current.offsetY + ((event.clientY - dragStart.current.y) / rect.height) * 100),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp(event: PointerEvent<HTMLDivElement>) {
|
||||||
|
if (dragStart.current?.pointerId === event.pointerId) dragStart.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("@container relative aspect-[4/5] w-full select-none overflow-hidden bg-black text-white", interactive && characterArtUrl && "cursor-move", className)}
|
||||||
|
style={{ backgroundImage: background }}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerUp}
|
||||||
|
role={interactive && characterArtUrl ? "application" : undefined}
|
||||||
|
aria-label={interactive && characterArtUrl ? "ลากเพื่อเลื่อนภาพตัวละคร" : undefined}
|
||||||
|
>
|
||||||
|
{character.nameCardImageUrl ? (
|
||||||
|
<Image
|
||||||
|
src={character.nameCardImageUrl}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
sizes="(min-width: 1024px) 800px, 100vw"
|
||||||
|
className="object-cover opacity-75"
|
||||||
|
priority={!interactive}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="absolute inset-0 bg-black/55" />
|
||||||
|
<Image
|
||||||
|
src="/img/mat/fade.png"
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
sizes="(min-width: 1024px) 800px, 100vw"
|
||||||
|
className="pointer-events-none object-cover opacity-75"
|
||||||
|
priority={!interactive}
|
||||||
|
/>
|
||||||
|
{characterArtUrl ? (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0"
|
||||||
|
style={{ transform: `translate(${artworkOffsetX}%, ${artworkOffsetY}%)` }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={characterArtUrl}
|
||||||
|
alt={character.name}
|
||||||
|
fill
|
||||||
|
sizes="(min-width: 1024px) 800px, 100vw"
|
||||||
|
className="object-contain object-center"
|
||||||
|
priority={!interactive}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<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)" }}>
|
||||||
|
<h2 className="text-[7cqw] leading-none">BASE STATS</h2>
|
||||||
|
<div className="mt-[4%] flex w-[53%] flex-col gap-[1.5%]">
|
||||||
|
{Array.from({ length: 4 }, (_, index) => {
|
||||||
|
const stat = stats[index];
|
||||||
|
return (
|
||||||
|
<div key={stat?.key ?? index} className="flex h-[7cqw] items-center justify-between rounded-[1.5cqw] bg-black/95 px-[4%] text-[3.2cqw] leading-none">
|
||||||
|
<span>{stat ? statLabel(stat.key) : "—"}</span>
|
||||||
|
<span>{stat ? statValue(stat.key, stat.value) : "—"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute inset-x-[4%] top-[51%]" style={{ fontFamily: "Kanit Poster", fontWeight: 800, fontStyle: "italic", textShadow: "-6px 14px 0 rgba(0,0,0,0.59)" }}>
|
||||||
|
<h2 className="text-[7cqw] leading-none">MATERIALS</h2>
|
||||||
|
<p className="mt-[1%] text-[4.5cqw] leading-none">LV.90</p>
|
||||||
|
<div className="mt-[5%] grid grid-cols-8 gap-[1.2%]">
|
||||||
|
{materials.map((material, index) => (
|
||||||
|
<div key={`${material.key}-${index}`} className="aspect-[0.65] overflow-hidden rounded-[1.3cqw] bg-black/95 ring-1 ring-white/10">
|
||||||
|
<div className="flex h-[68%] items-center justify-center rounded-br-[1.3cqw] bg-[#c1c1c1] p-[8%]">
|
||||||
|
{material.imageUrl ? <Image src={material.imageUrl} alt="" width={96} height={96} className="size-full object-contain" /> : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex h-[32%] items-center justify-center px-[4%] text-center text-[2.2cqw] leading-none">
|
||||||
|
<span>×{material.amount.toLocaleString("en-US")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import { TeamArtifactIcon, TeamSearch } from "@/components/public/team-search";
|
|||||||
import { GuideImageExport } from "@/components/public/guide-image-export";
|
import { GuideImageExport } from "@/components/public/guide-image-export";
|
||||||
import { GuideExportBranding } from "@/components/public/guide-export-branding";
|
import { GuideExportBranding } from "@/components/public/guide-export-branding";
|
||||||
import { ConstellationImage } from "@/components/public/constellation-image";
|
import { ConstellationImage } from "@/components/public/constellation-image";
|
||||||
|
import { MaterialPoster } from "@/components/public/material-poster";
|
||||||
import { RichTextContent } from "@/components/rich-text-content";
|
import { RichTextContent } from "@/components/rich-text-content";
|
||||||
import { LiveRefresh } from "@/components/live-refresh";
|
import { LiveRefresh } from "@/components/live-refresh";
|
||||||
import { rarityGradientClass, rarityTextClass } from "@/lib/catalog/rarity";
|
import { rarityGradientClass, rarityTextClass } from "@/lib/catalog/rarity";
|
||||||
@@ -267,34 +268,50 @@ function GuideOverview({
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{youtubeVideo ? (
|
{guide.posterArtUrl || youtubeVideo ? (
|
||||||
<section className="flex flex-col items-center gap-3">
|
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1.25fr)_minmax(20rem,0.75fr)]">
|
||||||
<div className="aspect-video w-full overflow-hidden rounded-xl border bg-muted">
|
{guide.posterArtUrl ? (
|
||||||
<iframe
|
<section className="order-1 lg:order-2">
|
||||||
src={youtubeVideo.embedUrl}
|
<MaterialPoster
|
||||||
title={`วิดีโอไกด์ ${guide.name}`}
|
character={guide.character}
|
||||||
loading="lazy"
|
characterArtUrl={guide.posterArtUrl}
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
artworkOffsetX={guide.posterArtOffsetX}
|
||||||
referrerPolicy="strict-origin-when-cross-origin"
|
artworkOffsetY={guide.posterArtOffsetY}
|
||||||
allowFullScreen
|
topColors={guide.posterTopColors}
|
||||||
className="size-full"
|
bottomColors={guide.posterBottomColors}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
render={
|
|
||||||
<a
|
|
||||||
href={youtubeVideo.watchUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
/>
|
/>
|
||||||
}
|
</section>
|
||||||
nativeButton={false}
|
) : null}
|
||||||
>
|
{youtubeVideo ? (
|
||||||
ดูวิดีโอต้นฉบับบน YouTube
|
<section className="order-2 flex flex-col items-center gap-3 lg:order-1">
|
||||||
<ArrowUpRightIcon data-icon="inline-end" />
|
<div className="aspect-video w-full overflow-hidden rounded-xl border bg-muted">
|
||||||
</Button>
|
<iframe
|
||||||
</section>
|
src={youtubeVideo.embedUrl}
|
||||||
|
title={`วิดีโอไกด์ ${guide.name}`}
|
||||||
|
loading="lazy"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
|
referrerPolicy="strict-origin-when-cross-origin"
|
||||||
|
allowFullScreen
|
||||||
|
className="size-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
render={
|
||||||
|
<a
|
||||||
|
href={youtubeVideo.watchUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
nativeButton={false}
|
||||||
|
>
|
||||||
|
ดูวิดีโอต้นฉบับบน YouTube
|
||||||
|
<ArrowUpRightIcon data-icon="inline-end" />
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -520,7 +537,7 @@ function WeaponSectionContent({
|
|||||||
: "text-sm sm:text-base",
|
: "text-sm sm:text-base",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
ดาเมททั้งทีม
|
ดาเมจทั้งทีม
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
CatalogAscensionGroup,
|
CatalogAscensionGroup,
|
||||||
|
CatalogBaseStat,
|
||||||
CatalogConstellation,
|
CatalogConstellation,
|
||||||
CatalogTalent,
|
CatalogTalent,
|
||||||
} from "@/lib/catalog/lunaris";
|
} from "@/lib/catalog/lunaris";
|
||||||
@@ -456,6 +457,7 @@ export const catalogCharacters = catalogSchema.table(
|
|||||||
constellations: jsonb("constellations").$type<CatalogConstellation[]>().default([]).notNull(),
|
constellations: jsonb("constellations").$type<CatalogConstellation[]>().default([]).notNull(),
|
||||||
talents: jsonb("talents").$type<CatalogTalent[]>().default([]).notNull(),
|
talents: jsonb("talents").$type<CatalogTalent[]>().default([]).notNull(),
|
||||||
ascension: jsonb("ascension").$type<CatalogAscensionGroup[]>().default([]).notNull(),
|
ascension: jsonb("ascension").$type<CatalogAscensionGroup[]>().default([]).notNull(),
|
||||||
|
baseStats: jsonb("base_stats").$type<CatalogBaseStat[]>().default([]).notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
primaryKey({ columns: [table.key] }),
|
primaryKey({ columns: [table.key] }),
|
||||||
@@ -537,6 +539,19 @@ export const guides = guidesSchema.table(
|
|||||||
coverMediaId: uuid("cover_media_id")
|
coverMediaId: uuid("cover_media_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => media.id, { onDelete: "restrict" }),
|
.references(() => media.id, { onDelete: "restrict" }),
|
||||||
|
posterArtMediaId: uuid("poster_art_media_id").references(() => media.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
posterArtOffsetX: integer("poster_art_offset_x").default(0).notNull(),
|
||||||
|
posterArtOffsetY: integer("poster_art_offset_y").default(0).notNull(),
|
||||||
|
posterTopColors: jsonb("poster_top_colors")
|
||||||
|
.$type<[string, string]>()
|
||||||
|
.default(["#000000", "#000000"])
|
||||||
|
.notNull(),
|
||||||
|
posterBottomColors: jsonb("poster_bottom_colors")
|
||||||
|
.$type<[string, string]>()
|
||||||
|
.default(["#000000", "#000000"])
|
||||||
|
.notNull(),
|
||||||
version: integer("version").default(1).notNull(),
|
version: integer("version").default(1).notNull(),
|
||||||
isPublic: boolean("is_public").default(false).notNull(),
|
isPublic: boolean("is_public").default(false).notNull(),
|
||||||
sortOrder: integer("sort_order").default(0).notNull(),
|
sortOrder: integer("sort_order").default(0).notNull(),
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ALTER TABLE "catalog"."character" ADD COLUMN "base_stats" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "guides"."guide" ADD COLUMN "poster_art_media_id" uuid;--> statement-breakpoint
|
||||||
|
ALTER TABLE "guides"."guide" ADD COLUMN "poster_art_offset_x" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "guides"."guide" ADD COLUMN "poster_art_offset_y" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "guides"."guide" ADD COLUMN "poster_top_colors" jsonb DEFAULT '["#000000","#000000"]'::jsonb NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "guides"."guide" ADD COLUMN "poster_bottom_colors" jsonb DEFAULT '["#000000","#000000"]'::jsonb NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "guides"."guide" ADD CONSTRAINT "guide_poster_art_media_id_media_id_fk" FOREIGN KEY ("poster_art_media_id") REFERENCES "media"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -225,6 +225,13 @@
|
|||||||
"when": 1788673251760,
|
"when": 1788673251760,
|
||||||
"tag": "0031_watery_sumo",
|
"tag": "0031_watery_sumo",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 32,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788680680873,
|
||||||
|
"tag": "0032_grey_excalibur",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
avatarIconName,
|
avatarIconName,
|
||||||
ascensionSnapshot,
|
ascensionSnapshot,
|
||||||
|
baseStatsSnapshot,
|
||||||
characterDetailSchema,
|
characterDetailSchema,
|
||||||
cleanLunarisText,
|
cleanLunarisText,
|
||||||
constellationSnapshot,
|
constellationSnapshot,
|
||||||
@@ -79,6 +80,22 @@ describe("Lunaris character data", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("selects dynamic level 90 base stats", () => {
|
||||||
|
const detail = characterDetailSchema.parse({
|
||||||
|
attributes: [
|
||||||
|
{ level: 1, ascension: 0, hp: 1, atk: 2, def: 3 },
|
||||||
|
{ level: 90, ascension: 6, hp: 12981, atk: 335, def: 787, "CRIT DMG%": "38.4" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(baseStatsSnapshot(detail)).toEqual([
|
||||||
|
{ key: "hp", value: 12981 },
|
||||||
|
{ key: "atk", value: 335 },
|
||||||
|
{ key: "def", value: 787 },
|
||||||
|
{ key: "CRIT DMG%", value: "38.4" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("reads the supported weapon type from character details", () => {
|
it("reads the supported weapon type from character details", () => {
|
||||||
const detail = characterDetailSchema.parse({
|
const detail = characterDetailSchema.parse({
|
||||||
info: { element: "Cryo", weapon: "WEAPON_SWORD_ONE_HAND" },
|
info: { element: "Cryo", weapon: "WEAPON_SWORD_ONE_HAND" },
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ export interface CatalogMaterial {
|
|||||||
imageKey: string;
|
imageKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CatalogBaseStat {
|
||||||
|
key: string;
|
||||||
|
value: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
const constellationSchema = z.object({
|
const constellationSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
@@ -60,6 +65,9 @@ export const characterDetailSchema = z.object({
|
|||||||
z.string(),
|
z.string(),
|
||||||
z.union([z.number().int(), z.record(z.string(), z.number().int())]),
|
z.union([z.number().int(), z.record(z.string(), z.number().int())]),
|
||||||
).nullish(),
|
).nullish(),
|
||||||
|
attributes: z.array(
|
||||||
|
z.record(z.string(), z.union([z.number(), z.string()])),
|
||||||
|
).nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function cleanLunarisText(value: string): string {
|
export function cleanLunarisText(value: string): string {
|
||||||
@@ -141,3 +149,13 @@ export function ascensionSnapshot(
|
|||||||
: Object.entries(entries).map(([key, amount]) => ({ key, amount })),
|
: Object.entries(entries).map(([key, amount]) => ({ key, amount })),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function baseStatsSnapshot(
|
||||||
|
value: z.infer<typeof characterDetailSchema>,
|
||||||
|
): CatalogBaseStat[] {
|
||||||
|
const level90 = value.attributes?.find((entry) => entry.level === 90);
|
||||||
|
if (!level90) return [];
|
||||||
|
return Object.entries(level90)
|
||||||
|
.filter(([key]) => key !== "level" && key !== "ascension")
|
||||||
|
.map(([key, stat]) => ({ key, value: stat }));
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
ascensionSnapshot,
|
ascensionSnapshot,
|
||||||
avatarIconName,
|
avatarIconName,
|
||||||
|
baseStatsSnapshot,
|
||||||
characterDetailSchema,
|
characterDetailSchema,
|
||||||
constellationSnapshot,
|
constellationSnapshot,
|
||||||
nameCardImageName,
|
nameCardImageName,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
} from "@/lib/catalog/lunaris";
|
} from "@/lib/catalog/lunaris";
|
||||||
import type {
|
import type {
|
||||||
CatalogAscensionGroup,
|
CatalogAscensionGroup,
|
||||||
|
CatalogBaseStat,
|
||||||
CatalogConstellation,
|
CatalogConstellation,
|
||||||
CatalogMaterial,
|
CatalogMaterial,
|
||||||
CatalogTalent,
|
CatalogTalent,
|
||||||
@@ -95,6 +97,7 @@ interface SyncedCharacter {
|
|||||||
constellations: CatalogConstellation[];
|
constellations: CatalogConstellation[];
|
||||||
talents: CatalogTalent[];
|
talents: CatalogTalent[];
|
||||||
ascension: CatalogAscensionGroup[];
|
ascension: CatalogAscensionGroup[];
|
||||||
|
baseStats: CatalogBaseStat[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getJson(url: string): Promise<unknown> {
|
async function getJson(url: string): Promise<unknown> {
|
||||||
@@ -274,6 +277,7 @@ async function syncCatalogVersion(version: string): Promise<void> {
|
|||||||
)
|
)
|
||||||
: [],
|
: [],
|
||||||
ascension: detail ? ascensionSnapshot(detail) : [],
|
ascension: detail ? ascensionSnapshot(detail) : [],
|
||||||
|
baseStats: detail ? baseStatsSnapshot(detail) : [],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -308,6 +312,7 @@ async function syncCatalogVersion(version: string): Promise<void> {
|
|||||||
(icon) => `catalog/characters/talents/${webpName(icon)}`,
|
(icon) => `catalog/characters/talents/${webpName(icon)}`,
|
||||||
),
|
),
|
||||||
ascension: ascensionSnapshot(detail),
|
ascension: ascensionSnapshot(detail),
|
||||||
|
baseStats: baseStatsSnapshot(detail),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -456,6 +461,7 @@ async function syncCatalogVersion(version: string): Promise<void> {
|
|||||||
constellations: item.constellations,
|
constellations: item.constellations,
|
||||||
talents: item.talents,
|
talents: item.talents,
|
||||||
ascension: item.ascension,
|
ascension: item.ascension,
|
||||||
|
baseStats: item.baseStats,
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
{ type: "application/json", acl: "public-read" },
|
{ type: "application/json", acl: "public-read" },
|
||||||
@@ -514,6 +520,7 @@ async function syncCatalogVersion(version: string): Promise<void> {
|
|||||||
constellations: item.constellations,
|
constellations: item.constellations,
|
||||||
talents: item.talents,
|
talents: item.talents,
|
||||||
ascension: item.ascension,
|
ascension: item.ascension,
|
||||||
|
baseStats: item.baseStats,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
if (materials.length)
|
if (materials.length)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
characters,
|
characters,
|
||||||
dataSources,
|
dataSources,
|
||||||
dataSourceVersions,
|
dataSourceVersions,
|
||||||
|
guides,
|
||||||
media,
|
media,
|
||||||
pageDataSources,
|
pageDataSources,
|
||||||
pages,
|
pages,
|
||||||
@@ -308,7 +309,7 @@ export async function getMediaVisibility(mediaId: string): Promise<{
|
|||||||
retained: boolean;
|
retained: boolean;
|
||||||
}> {
|
}> {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const [referencedBlocks, portraitCharacters, [mediaRecord]] = await Promise.all([
|
const [referencedBlocks, portraitCharacters, posterGuides, [mediaRecord]] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({ block: blocks, page: pages, character: characters })
|
.select({ block: blocks, page: pages, character: characters })
|
||||||
.from(blocks)
|
.from(blocks)
|
||||||
@@ -318,6 +319,10 @@ export async function getMediaVisibility(mediaId: string): Promise<{
|
|||||||
.select({ character: characters })
|
.select({ character: characters })
|
||||||
.from(characters)
|
.from(characters)
|
||||||
.where(eq(characters.portraitMediaId, mediaId)),
|
.where(eq(characters.portraitMediaId, mediaId)),
|
||||||
|
db
|
||||||
|
.select({ guide: guides })
|
||||||
|
.from(guides)
|
||||||
|
.where(eq(guides.posterArtMediaId, mediaId)),
|
||||||
db.select().from(media).where(eq(media.id, mediaId)).limit(1),
|
db.select().from(media).where(eq(media.id, mediaId)).limit(1),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -336,10 +341,12 @@ export async function getMediaVisibility(mediaId: string): Promise<{
|
|||||||
public:
|
public:
|
||||||
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),
|
||||||
retained:
|
retained:
|
||||||
references.length > 0 ||
|
references.length > 0 ||
|
||||||
portraitCharacters.length > 0 ||
|
portraitCharacters.length > 0 ||
|
||||||
|
posterGuides.length > 0 ||
|
||||||
Boolean(
|
Boolean(
|
||||||
mediaRecord &&
|
mediaRecord &&
|
||||||
(mediaRecord.currentReferenceCount > 0 ||
|
(mediaRecord.currentReferenceCount > 0 ||
|
||||||
|
|||||||
@@ -126,16 +126,40 @@ export async function updateGuideBasics(guideId: string, input: unknown) {
|
|||||||
const [cover] = await getDb().select().from(media).where(and(eq(media.id, data.coverMediaId), eq(media.status, "ready"))).limit(1);
|
const [cover] = await getDb().select().from(media).where(and(eq(media.id, data.coverMediaId), eq(media.status, "ready"))).limit(1);
|
||||||
if (!cover) throw new Error("ไม่พบภาพ Guide ที่อัปโหลดแล้ว");
|
if (!cover) throw new Error("ไม่พบภาพ Guide ที่อัปโหลดแล้ว");
|
||||||
return getDb().transaction(async (tx) => {
|
return getDb().transaction(async (tx) => {
|
||||||
|
const posterArtMediaId = data.posterArtMediaId === undefined
|
||||||
|
? current.posterArtMediaId
|
||||||
|
: data.posterArtMediaId;
|
||||||
|
if (posterArtMediaId) {
|
||||||
|
const [poster] = await tx
|
||||||
|
.select({ id: media.id })
|
||||||
|
.from(media)
|
||||||
|
.where(and(eq(media.id, posterArtMediaId), eq(media.status, "ready")))
|
||||||
|
.limit(1);
|
||||||
|
if (!poster) throw new Error("ไม่พบภาพตัวละครที่อัปโหลดแล้ว");
|
||||||
|
}
|
||||||
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,
|
||||||
coverMediaId: data.coverMediaId,
|
coverMediaId: data.coverMediaId,
|
||||||
...(data.youtubeUrl !== undefined ? { youtubeUrl: data.youtubeUrl } : {}),
|
...(data.youtubeUrl !== undefined ? { youtubeUrl: data.youtubeUrl } : {}),
|
||||||
|
...(data.posterArtMediaId !== undefined ? { posterArtMediaId } : {}),
|
||||||
|
...(data.posterArtOffsetX !== undefined ? { posterArtOffsetX: Math.round(data.posterArtOffsetX) } : {}),
|
||||||
|
...(data.posterArtOffsetY !== undefined ? { posterArtOffsetY: Math.round(data.posterArtOffsetY) } : {}),
|
||||||
|
...(data.posterTopColors !== undefined ? { posterTopColors: data.posterTopColors } : {}),
|
||||||
|
...(data.posterBottomColors !== undefined ? { posterBottomColors: data.posterBottomColors } : {}),
|
||||||
}).where(eq(guides.id, guideId));
|
}).where(eq(guides.id, guideId));
|
||||||
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));
|
||||||
}
|
}
|
||||||
|
if (current.posterArtMediaId !== posterArtMediaId) {
|
||||||
|
if (current.posterArtMediaId) {
|
||||||
|
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, current.posterArtMediaId));
|
||||||
|
}
|
||||||
|
if (posterArtMediaId) {
|
||||||
|
await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, posterArtMediaId));
|
||||||
|
}
|
||||||
|
}
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -390,6 +414,9 @@ export async function permanentlyDeleteGuide(guideId: string) {
|
|||||||
if (!guide?.trashedAt) throw new Error("Guide ต้องอยู่ในถังขยะก่อนลบถาวร");
|
if (!guide?.trashedAt) throw new Error("Guide ต้องอยู่ในถังขยะก่อนลบถาวร");
|
||||||
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) {
|
||||||
|
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, guide.posterArtMediaId));
|
||||||
|
}
|
||||||
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 };
|
||||||
});
|
});
|
||||||
|
|||||||
+26
-3
@@ -114,14 +114,33 @@ export async function listPublicGuideCards() {
|
|||||||
|
|
||||||
export async function getGuideByCharacterKey(characterKey: string) {
|
export async function getGuideByCharacterKey(characterKey: string) {
|
||||||
const [row] = await getDb()
|
const [row] = await getDb()
|
||||||
.select({ guide: guides, cover: media })
|
.select({ guide: guides, cover: media, character: catalogCharacters })
|
||||||
.from(guides)
|
.from(guides)
|
||||||
.innerJoin(media, eq(media.id, guides.coverMediaId))
|
.innerJoin(media, eq(media.id, guides.coverMediaId))
|
||||||
|
.innerJoin(catalogCharacters, eq(catalogCharacters.key, guides.characterKey))
|
||||||
.where(and(eq(guides.characterKey, characterKey), isNull(guides.trashedAt)))
|
.where(and(eq(guides.characterKey, characterKey), isNull(guides.trashedAt)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
|
const materialKeys = [...new Set(
|
||||||
|
row.character.ascension.flatMap((group) => group.items.map((item) => item.key)),
|
||||||
|
)];
|
||||||
|
const [materials, poster] = await Promise.all([
|
||||||
|
materialKeys.length
|
||||||
|
? getDb().select().from(catalogMaterials).where(inArray(catalogMaterials.key, materialKeys))
|
||||||
|
: [],
|
||||||
|
row.guide.posterArtMediaId
|
||||||
|
? getDb().select().from(media).where(eq(media.id, row.guide.posterArtMediaId)).limit(1)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
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));
|
||||||
return { ...row.guide, coverUrl: publicMediaUrl(row.cover.objectKey), sections };
|
return {
|
||||||
|
...row.guide,
|
||||||
|
coverUrl: publicMediaUrl(row.cover.objectKey),
|
||||||
|
posterArtUrl: poster[0] ? publicMediaUrl(poster[0].objectKey) : null,
|
||||||
|
character: catalogCharacterWithUrls(row.character, materialByKey),
|
||||||
|
sections,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getGuideContent(slug: string, publicOnly: boolean) {
|
async function getGuideContent(slug: string, publicOnly: boolean) {
|
||||||
@@ -140,7 +159,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] = await Promise.all([
|
const [sections, weaponConditionsRows, groups, profile, constellationGroups, teams, aliases, materials, poster] = 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)),
|
||||||
@@ -151,6 +170,9 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
|
|||||||
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
|
||||||
|
? getDb().select().from(media).where(eq(media.id, row.guide.posterArtMediaId)).limit(1)
|
||||||
|
: Promise.resolve([]),
|
||||||
]);
|
]);
|
||||||
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]));
|
||||||
@@ -198,6 +220,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
|
|||||||
return {
|
return {
|
||||||
...row.guide,
|
...row.guide,
|
||||||
coverUrl: publicMediaUrl(row.cover.objectKey),
|
coverUrl: publicMediaUrl(row.cover.objectKey),
|
||||||
|
posterArtUrl: poster[0] ? publicMediaUrl(poster[0].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,
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ export const updateGuideSchema = z.strictObject({
|
|||||||
coverMediaId: z.string().uuid(),
|
coverMediaId: z.string().uuid(),
|
||||||
overview: z.string().trim().min(1).max(500),
|
overview: z.string().trim().min(1).max(500),
|
||||||
youtubeUrl: youtubeUrlSchema,
|
youtubeUrl: youtubeUrlSchema,
|
||||||
|
posterArtMediaId: z.string().uuid().nullable().optional(),
|
||||||
|
posterArtOffsetX: 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(),
|
||||||
|
posterBottomColors: z.tuple([z.string().regex(/^#[0-9a-fA-F]{6}$/u), z.string().regex(/^#[0-9a-fA-F]{6}$/u)]).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const weaponSectionSchema = z.strictObject({
|
export const weaponSectionSchema = z.strictObject({
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 203 KiB |
Reference in New Issue
Block a user