From ce126810bbeb142a6227c33e41ee8fa6e77cfa9d Mon Sep 17 00:00:00 2001 From: gunshiz Date: Sun, 6 Sep 2026 17:16:38 +0700 Subject: [PATCH] feat : update --- .env.example | 1 + README.md | 1 + components/admin/guide-overview-form.tsx | 103 ++++++++++++----- components/admin/media-uploader.tsx | 4 +- components/public/material-poster.tsx | 76 ++++++++----- components/public/structured-guide.tsx | 134 +++++++++++++---------- components/ui/slider.tsx | 51 +++++++++ k8s.md | 91 ++++++++------- lib/catalog/lunaris.test.ts | 12 +- lib/catalog/lunaris.ts | 6 +- lib/catalog/sync.ts | 72 +++++++++--- lib/media/validation.test.ts | 2 +- lib/media/validation.ts | 2 +- 13 files changed, 384 insertions(+), 171 deletions(-) create mode 100644 components/ui/slider.tsx diff --git a/.env.example b/.env.example index e7a7452..648c672 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,7 @@ REDIS_CACHE_MAX_ENTRY_BYTES=5242880 OUTBOX_BATCH_SIZE=20 OUTBOX_POLL_MS=1000 OUTBOX_LEASE_MS=30000 +CATALOG_SYNC_CONCURRENCY=12 # Discord-triggered Lunaris catalog sync DISCORD_BOT_TOKEN=replace-with-discord-bot-token diff --git a/README.md b/README.md index 8fc055f..f32308d 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,7 @@ Before the first rollout, a cluster administrator must provision a DATABASE_URL BETTER_AUTH_SECRET REDIS_URL +CATALOG_SYNC_CONCURRENCY S3_ENDPOINT S3_PUBLIC_URL S3_BUCKET diff --git a/components/admin/guide-overview-form.tsx b/components/admin/guide-overview-form.tsx index 833a2ee..dbbdc42 100644 --- a/components/admin/guide-overview-form.tsx +++ b/components/admin/guide-overview-form.tsx @@ -29,6 +29,15 @@ import { FieldLabel, } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; +import { Slider } from "@/components/ui/slider"; import { Textarea } from "@/components/ui/textarea"; import { useAutosave } from "@/hooks/use-autosave"; import { adminDraftKey } from "@/lib/editor/local-draft"; @@ -42,28 +51,53 @@ type BleedingLayer = { mediaId: string; imageUrl: string | null; amount: number; - scale: number; blendMode: PosterBlendMode; }; const blendModes: PosterBlendMode[] = ["normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", "hard-light", "soft-light", "difference", "exclusion", "hue", "saturation", "color", "luminosity"]; +const blendModeItems = blendModes.map((value) => ({ + value, + label: value.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" "), +})); +let bleedingLayerIdCounter = 0; -function SortableBleedingLayer({ layer, onChange, onRemove }: { layer: BleedingLayer; onChange: (patch: Partial>) => void; onRemove: () => void }) { +function createBleedingLayerId(): string { + const browserCrypto = globalThis.crypto; + if (typeof browserCrypto?.randomUUID === "function") return `bleeding-${browserCrypto.randomUUID()}`; + bleedingLayerIdCounter += 1; + return `bleeding-${Date.now()}-${bleedingLayerIdCounter}`; +} + +function SortableBleedingLayer({ layer, onChange, onRemove }: { layer: BleedingLayer; onChange: (patch: Partial>) => void; onRemove: () => void }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: layer.id }); + const selectedMode = blendModeItems.find((mode) => mode.value === layer.blendMode); return ( -
- +
+ {layer.imageUrl ? :
} -
-
Amount{layer.amount}%
- onChange({ amount: Number(event.target.value) })} aria-label="Bleeding amount" /> -
Scale{layer.scale}%
- onChange({ scale: Number(event.target.value) })} aria-label="Bleeding scale" /> - +
+ value && onChange({ blendMode: value.value })} + itemToStringLabel={(item) => item.label} + itemToStringValue={(item) => item.value} + filter={(item, query) => `${item.label} ${item.value}`.toLowerCase().includes(query.trim().toLowerCase())} + > + + + ไม่พบ Blend mode + + {(item) => {item.label}} + + + + + Amount {layer.amount}% + onChange({ amount: typeof value === "number" ? value : value[0] ?? 0 })} /> +
- +
); } @@ -107,7 +141,7 @@ export function GuideOverviewForm({ const [posterArtScale, setPosterArtScale] = useState(guide.posterArtScale); const [posterTopColors, setPosterTopColors] = useState(guide.posterTopColors); const [posterBottomColors, setPosterBottomColors] = useState(guide.posterBottomColors); - const [bleeding, setBleeding] = useState(guide.posterBleeding.map(({ id, mediaId, imageUrl, amount, scale, blendMode }) => ({ id, mediaId, imageUrl, amount, scale, blendMode }))); + const [bleeding, setBleeding] = useState(guide.posterBleeding.map(({ id, mediaId, imageUrl, amount, blendMode }) => ({ id, mediaId, imageUrl, amount, blendMode }))); const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })); const youtubeVideo = parseYouTubeUrl(youtubeUrl.trim()); const invalidYoutubeUrl = Boolean(youtubeUrl.trim() && !youtubeVideo); @@ -121,7 +155,7 @@ export function GuideOverviewForm({ posterArtScale, posterTopColors, posterBottomColors, - posterBleeding: bleeding.map(({ mediaId, amount, scale, blendMode }) => ({ mediaId, amount, scale, blendMode })), + posterBleeding: bleeding.map(({ mediaId, amount, blendMode }) => ({ mediaId, amount, scale: 100, blendMode })), }; const incompleteIssues = [ !cover.id ? "กรุณาอัปโหลด Guide Image" : null, @@ -192,10 +226,10 @@ export function GuideOverviewForm({ if (!item || typeof item !== "object") return []; const layer = item as Record; return typeof layer.id === "string" && typeof layer.mediaId === "string" && typeof layer.imageUrl === "string" - ? [{ id: layer.id, mediaId: layer.mediaId, imageUrl: layer.imageUrl, amount: typeof layer.amount === "number" ? Math.max(0, Math.min(100, layer.amount)) : 100, scale: typeof layer.scale === "number" ? Math.max(25, Math.min(300, layer.scale)) : 100, blendMode: typeof layer.blendMode === "string" && blendModes.includes(layer.blendMode as PosterBlendMode) ? layer.blendMode as PosterBlendMode : "multiply" }] + ? [{ id: layer.id, mediaId: layer.mediaId, imageUrl: layer.imageUrl, amount: typeof layer.amount === "number" ? Math.max(0, Math.min(100, layer.amount)) : 100, blendMode: typeof layer.blendMode === "string" && blendModes.includes(layer.blendMode as PosterBlendMode) ? layer.blendMode as PosterBlendMode : "multiply" }] : []; }) - : guide.posterBleeding.map(({ id, mediaId, imageUrl, amount, scale, blendMode }) => ({ id, mediaId, imageUrl, amount, scale, blendMode })), + : guide.posterBleeding.map(({ id, mediaId, imageUrl, amount, blendMode }) => ({ id, mediaId, imageUrl, amount, blendMode })), }; }, restoreLocalDraft: (draft) => { @@ -244,7 +278,16 @@ export function GuideOverviewForm({ setPosterArtOffset({ x: 0, y: 0 }); setBleeding((current) => current.length ? current.map((layer) => ({ ...layer, mediaId: asset.id, imageUrl: asset.url })) - : [{ id: crypto.randomUUID(), mediaId: asset.id, imageUrl: asset.url, amount: 65, scale: 100, blendMode: "multiply" }]); + : [{ id: createBleedingLayerId(), mediaId: asset.id, imageUrl: asset.url, amount: 65, blendMode: "multiply" }]); + } + + function removeUploadedPoster() { + setPosterArt({ id: null, url: null }); + setPosterArtOffset({ x: 0, y: 0 }); + setPosterArtScale(100); + setPosterTopColors(["#000000", "#000000"]); + setPosterBottomColors(["#000000", "#000000"]); + setBleeding([]); } function detectPosterColorsFromFile(file: File) { @@ -256,7 +299,7 @@ export function GuideOverviewForm({ if (!posterArt.id || !posterArt.url) return; const mediaId = posterArt.id; const imageUrl = posterArt.url; - setBleeding((current) => [...current, { id: crypto.randomUUID(), mediaId, imageUrl, amount: 65, scale: 100, blendMode: "multiply" }]); + setBleeding((current) => [...current, { id: createBleedingLayerId(), mediaId, imageUrl, amount: 65, blendMode: "multiply" }]); } function handleBleedingDragEnd(event: DragEndEvent) { @@ -359,12 +402,18 @@ export function GuideOverviewForm({ onReady={setUploadedPoster} onFileSelected={detectPosterColorsFromFile} /> + {posterArt.id ? ( + + ) : null}
-
+
Bleeding layers @@ -390,17 +439,17 @@ export function GuideOverviewForm({ {bleeding.length ? ( layer.id)} strategy={verticalListSortingStrategy}> -
+
{bleeding.map((layer) => setBleeding((current) => current.map((item) => item.id === layer.id ? { ...item, ...patch } : item))} onRemove={() => setBleeding((current) => current.filter((item) => item.id !== layer.id))} />)}
) :

อัปโหลดภาพเพื่อเพิ่ม Bleeding layer

}
-
-
Character image zoom{posterArtScale}%
- setPosterArtScale(Number(event.target.value))} aria-label="Character image zoom" /> -
+ +
Character image zoom{posterArtScale}%
+ setPosterArtScale(typeof value === "number" ? value : value[0] ?? 100)} /> +
อัปโหลดภาพตัวละคร แล้วลากบนตัวอย่างเพื่อจัดตำแหน่ง สีด้านบนและล่างจะตรวจให้อัตโนมัติ diff --git a/components/admin/media-uploader.tsx b/components/admin/media-uploader.tsx index f7fa41e..2211a2d 100644 --- a/components/admin/media-uploader.tsx +++ b/components/admin/media-uploader.tsx @@ -34,7 +34,7 @@ export function MediaUploader({ return; } if (file.size <= 0 || file.size > MAX_MEDIA_BYTES) { - toast.error("ไฟล์ต้องมีขนาดไม่เกิน 20 MB"); + toast.error("ไฟล์ต้องมีขนาดไม่เกิน 100 MB"); return; } @@ -88,7 +88,7 @@ export function MediaUploader({ {state === "idle" && "อัปโหลดและเพิ่มรูปภาพ"} {!busy ? : null} - PNG, JPEG, WebP หรือ GIF ขนาดไม่เกิน 20 MB + PNG, JPEG, WebP หรือ GIF ขนาดไม่เกิน 100 MB ); } diff --git a/components/public/material-poster.tsx b/components/public/material-poster.tsx index 6f79348..4d457a5 100644 --- a/components/public/material-poster.tsx +++ b/components/public/material-poster.tsx @@ -7,6 +7,7 @@ import type { CatalogAscensionGroup, CatalogBaseStat, } from "@/lib/catalog/lunaris"; +import { accentGradient } from "@/lib/overview-image"; import { cn } from "@/lib/utils"; type PosterMaterial = { @@ -37,6 +38,14 @@ function statLabel(key: string): string { return statLabels[key.toLowerCase()] ?? key; } +function statIconName(key: string): string | null { + const normalized = key.toLowerCase().replace(/[^a-z]/gu, ""); + if (["hp", "atk", "def", "em", "er"].includes(normalized)) return normalized; + if (["critrate", "crit", "criticalrate"].includes(normalized)) return "critrate"; + if (["critdmg", "critdamage", "criticaldamage"].includes(normalized)) return "critdmg"; + return null; +} + function statValue(key: string, value: number | string): string { const formatted = typeof value === "number" ? value.toLocaleString("en-US", { maximumFractionDigits: 2 }) @@ -67,11 +76,10 @@ export function MaterialPoster({ onArtworkMove?: (offset: { x: number; y: number }) => void; className?: string; artworkScale: number; - bleeding?: Array<{ id: string; imageUrl: string | null; amount: number; scale: number; blendMode: PosterBlendMode }>; + bleeding?: Array<{ id: string; imageUrl: string | null; amount: number; blendMode: PosterBlendMode }>; }) { const dragStart = useRef<{ pointerId: number; x: number; y: number; offsetX: number; offsetY: number } | null>(null); - const preferredStats = ["hp", "atk", "def", "em"]; - const stats = preferredStats.map((key) => character.baseStats.find((stat) => stat.key.toLowerCase() === key)); + const stats = character.baseStats.slice(0, 4); const materials = [...character.ascension, ...character.leveling].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%)`; @@ -143,49 +151,67 @@ export function MaterialPoster({ className="object-contain object-center" priority={!interactive} /> + {[...bleeding].reverse().map((layer) => ( + + ))}
) : null} -
- {bleeding.map((layer) => layer.imageUrl ? ( - - ) : null)} -
+
+
-
-

BASE STATS

-
+
+

BASE STATS

+
{Array.from({ length: 4 }, (_, index) => { const stat = stats[index]; + const iconName = stat ? statIconName(stat.key) : null; return (
- {stat ? statLabel(stat.key) : "—"} - {stat ? statValue(stat.key, stat.value) : "—"} + + {iconName ? ( + + ) : null} + {stat ? statLabel(stat.key) : "—"} + + {stat ? statValue(stat.key, stat.value) : "—"}
); })}
-
-

MATERIALS

+
+

MATERIALS

LV.90

{materials.map((material, index) => ( -
+
{material.imageUrl ? : null}
- ×{material.amount.toLocaleString("en-US")} + ×{material.amount.toLocaleString("en-US")}
))} diff --git a/components/public/structured-guide.tsx b/components/public/structured-guide.tsx index 4aed397..2c75179 100644 --- a/components/public/structured-guide.tsx +++ b/components/public/structured-guide.tsx @@ -209,8 +209,8 @@ function GuideOverview({

{guide.name}

{guide.overview}

-
-
+
+
{`ไกด์
- {sections.length ? ( - - ) : ( - - - กำลังจัดทำเนื้อหา - - กลับมาตรวจสอบอีกครั้งหลังผู้ดูแลเพิ่มข้อมูล - - - - )} -
- {guide.posterArtUrl || youtubeVideo ? ( -
+
+ +
+ {guide.posterArtUrl || youtubeVideo ? ( + <> {guide.posterArtUrl ? ( -
+
+ + +
) : null} {youtubeVideo ? ( -
+