feat(guides): add structured image-first editor
CI / Verify (push) Successful in 1m0s
CI / Build immutable images and deploy (push) Failing after 2m38s

This commit is contained in:
2026-08-29 13:30:52 +00:00 Unverified
parent b8e3b99033
commit dc5c667b4e
36 changed files with 5611 additions and 381 deletions
+1
View File
@@ -23,6 +23,7 @@ OUTBOX_LEASE_MS=30000
# S3-compatible private media storage
S3_ENDPOINT=https://s3.example.internal
S3_PUBLIC_URL=https://buzz-cdn.astrxl.dev
S3_REGION=auto
S3_BUCKET=buzz-sheet-media
S3_ACCESS_KEY_ID=replace-with-access-key
+45 -183
View File
@@ -1,195 +1,57 @@
# Buzz Sheet Guide Platform
# Buzz Sheet Structured Guide Platform
## Summary
## Product
Build a Thai-first, mobile-responsive guide CMS that converts the ideas found across the five reference Google Sheets into polished character guide websites. It must feel like a game guide—not a spreadsheet—on both the public and admin sides.
Buzz Sheet is a Thai-first, image-first Genshin guide CMS. The public directory
shows a required cover and Overview for every guide. Each guide exposes only
non-empty Weapons, Artifacts & Stats, Constellations, Team Calc, and optional
Extra sections. Use the English terms “Build” and “Team” throughout the UI.
The admin uses a visual Notion-style block editor with forms, cards, drag-and-drop sections, live preview, templates, autosave, and simple arithmetic formula fields. Published pages update from accepted autosaves, with Redis-backed Server-Sent Events (SSE) prompting connected public and admin clients to refetch authoritative state. Executable custom code, HTML, JavaScript, React, and arbitrary Tailwind input are excluded.
The admin workflow stays intentionally simple:
## Product and UI
1. Manually Sync the latest Lunaris catalog.
2. Select one Lunaris character at `/admin/create`.
3. Upload a required Guide Image and write the Overview.
4. Edit one structured section at a time under `/admin/[charid]`.
5. Press Save to update the public website immediately.
- Public routes:
- `/` searchable character directory.
- `/[character]` redirects to the character’s first visible page.
- `/[character]/[page]` renders any custom page, including `/mizuki/info` or `/mizuki/combo`.
- Admin routes:
- `/admin/login`
- `/admin`
- `/admin/[character]/[page]`
- `/admin/templates`
- Allow unlimited characters, custom pages, custom page slugs, custom fields, navigation ordering, and a public note on every page.
- New characters and pages start hidden. A page is public only when both its character and the page are visible. Existing published pages update immediately after an accepted autosave; a character with no visible pages returns 404.
- Provide reusable templates derived from all five workbooks without importing or publishing their original content:
- Classic multi-page character guide
- Combo and rotation guide
- Combined one-page build
- Multi-role/reaction build
- Categorized team database
- Calculation-backed comparison guide
- Initialize official shadcn components with the `base-nova` preset and use Tailwind v4 semantic tokens. Compose existing primitives before creating custom UI, and use only components appropriate to each interaction—not every component literally.
- Use a permanent dark, game-inspired theme with no light-mode switcher or `next-themes`. Keep decoration restrained, hierarchy strong, contrast accessible, and controls at least 44 px on touch surfaces.
- Use Anuphan as the Thai-first interface font with appropriate system fallbacks. Reserve monospace typography for formulas and technical values, and test wrapping for long Thai labels and mixed-language game terms.
- Use one restrained brand accent, semantic status colors, and a limited chart palette. Element and rarity colors appear as badges or markers and never carry meaning alone. Honor reduced motion and keep animation limited to functional panel, drag, chart, and save-state feedback.
- Public pages use guide-oriented blocks rather than editable grids:
- Profile/hero
- Rich text and headings
- Notes and callouts
- Stat cards and custom key/value fields
- Ranked weapon/artifact/constellation comparisons
- Team compositions
- Combo/rotation steps
- Tables and charts
- Images, GIFs, galleries, tabs, accordions, sections, columns, links, and separators
- Build `/` as a responsive visual portrait-card directory with search, compact role/element filters, metadata badges, loading skeletons, image fallbacks, and a useful empty state. Do not add a grid/list mode toggle in v1.
- Give public character guides a sticky character header followed by horizontally scrollable route links. Add a sticky desktop “on this page” section rail and an equivalent titled drawer on mobile.
- Keep prose in a readable content column and allow team layouts, tables, galleries, and charts to use wider responsive regions.
- Compose recommendations with full `Card` or `Item` structures, `Badge` metadata, `Alert` callouts, `Accordion` for secondary explanations, `Tabs` for authored variants, and `Carousel` only for image galleries.
- Show weapon, artifact, and game-term details through `HoverCard` on pointer devices and the same content in a titled `Drawer` on touch devices. Never make hover the only way to access information.
- Keep public comparisons author-curated and read-only. Use horizontal bars for ranked weapons/artifacts, grouped or stacked bars for team totals and member contributions, and cumulative or incremental bars/lines for constellations. Always show exact values and an accessible table fallback; do not chart qualitative recommendations or metrics with incompatible units.
- Admin editing uses a clean three-pane desktop workspace: a collapsible character/page/template `Sidebar`, an editable center canvas, and a resizable right panel with Inspector and Live Preview tabs. On mobile, keep the canvas primary and move navigation, block insertion, and inspection into titled drawers.
- Add a sticky admin toolbar with breadcrumbs, undo/redo, persistent autosave status, visibility, viewport `ToggleGroup`, and preview actions. Use `Badge` and `Spinner` for autosave status; reserve `sonner` for failures, recovery, and completed explicit actions.
- Edit rich text and simple values inline. Use the inspector for layout, data-source selection, formulas, responsive settings, and advanced properties. Support drag and keyboard reordering with visible selection and focus states.
- Use `CommandDialog` for navigation and block insertion. Use accessible contextual menus for block actions, `AlertDialog` for destructive actions, `Empty` for unconfigured blocks, and `Skeleton` for loading states.
- Edit structured comparison data through a compact admin `Table`, opening row creation and complex row editing in structured forms instead of turning the canvas into a spreadsheet.
- Build forms with `FieldGroup`, `Field`, and `InputGroup`. Formula fields use an `fx` addon, field-reference autocomplete, calculated preview, and inline typed errors.
- Comparison data can be entered efficiently in compact tables, but page construction and public rendering must never resemble Google Sheets.
There is one guide per Lunaris character key. Empty sections remain private.
Remove Guide moves content to a recoverable Trash before permanent deletion.
## Architecture and Interfaces
## Structured content
- Use Next.js 16.3.3 App Router, React 19, Bun 1.3.14, TypeScript, PostgreSQL, Drizzle, Better Auth, and Bun’s native S3 client.
- Follow the installed Next.js documentation for Server Components, Server Actions, caching, authentication, and deployment.
- Use one generic content model:
- `Character`: slug, localized identity fields, media, ordering, visibility.
- `Page`: character, slug, title, navigation order, visibility, public note, version.
- `Block`: stable ID, type, schema version, validated configuration, responsive layout.
- `DataSource`: globally reusable structured rows/fields that can feed tables, rankings, cards, and charts.
- `DataSourceVersion`: immutable data snapshot used for dependency tracking, public rendering, and historical restoration.
- `Template`: reusable character/page/block structure without reference-sheet content.
- `Revision`: page snapshot, label, author, timestamp, and source version.
- `SlugAlias`: permanent redirect after character or page slug changes.
- `Media`: private object key, metadata, current references, and revision references.
- `OutboxEvent`: retryable post-commit cache invalidation and SSE notification.
- Add validation and migration handlers for every block schema. Unknown or future block types render a safe unsupported-block notice instead of breaking the page.
- Add a constrained `ComparisonChart` block configuration:
- `dataSourceId`: globally reusable structured data source.
- `categoryField`: label/category field used by the axis and table fallback.
- `series`: one to six numeric fields with authored labels and semantic chart color tokens.
- `presentation`: `horizontal-bar`, `grouped-bar`, `stacked-bar`, or `line`.
- `normalization`: `raw`, `percent-of-total`, or `baseline-100`.
- `sort`: `authored`, `ascending`, or `descending`.
- `showValues` and `showLegend`: authored display controls.
- `tableFallback`: always enabled and not removable by authors.
- Validate a maximum of 20 visible categories and six series. Invalid or missing data renders a typed error or empty state without breaking the page.
- Render charts as isolated client components inside otherwise server-rendered public pages. Use shadcn `ChartContainer`, `ChartTooltip`, and `ChartLegend` with Recharts v3, semantic chart tokens, responsive minimum dimensions, visible touch-friendly values, and `accessibilityLayer`.
- Calculated fields use a restricted arithmetic expression language—not executable code:
- Supports `+`, `-`, `*`, `/`, parentheses, percentages, unary signs, field references, dependency ordering, circular-reference detection, and clear error states.
- Admins access formulas only through an optional advanced setting on individual fields, with field-name autocomplete and a calculated preview.
- Imported regression fixtures may retain A1-style references internally, but normal admin editing uses stable named field references.
- Use deterministic decimal arithmetic without intermediate rounding. Store canonical decimal strings and apply configurable display precision with half-up rounding only for presentation.
- Treat blank or missing references, cycles, and division by zero as typed field errors. Invalid fields and their dependents render safe public error placeholders while unrelated valid changes publish normally.
- Hide formula expressions and internal error details from public visitors.
- Place evaluation behind a `FormulaEngine` interface so a larger engine can be added later without changing page or block schemas.
- Store raw values and formulas as the source of truth; calculated results and public page DTOs are derived snapshots.
- Autosave 750 ms after the last edit, serialize mutations per page, and protect every mutation with versions. Each mutation sends `expectedVersion`; conflicting changes are rejected and shown in a merge/reload dialog rather than silently overwriting work. Retry transient failures with capped exponential backoff, but do not retry validation or conflict responses automatically.
- Version global data sources independently. Normal references follow the latest version. Revisions capture exact data-source versions; restoring a revision creates a new current page version pinned to those historical versions without changing other consumers. Provide an explicit “use latest” action to remove a pin.
- Keep named revisions indefinitely and hourly coalesced checkpoints for 30 days. Revision snapshots retain exact page, block, data-source-version, and media references. Restoring creates a new current version rather than deleting history.
- Cache validated public page snapshots by their page and data-source version vector. Use an external Redis-backed Next.js remote cache and tag handler so both application replicas share cache state and invalidations. Keep admin routes dynamic.
- Write cache invalidation and SSE work to a transactional outbox with the content mutation. A retrying worker invalidates affected page, directory, and data-source tags only after the database commit, then publishes typed Redis events.
- Provide Redis-backed, invalidation-only SSE streams for public pages, the character directory, and authenticated admin sessions. Events carry only opaque IDs and versions; clients refetch authoritative state on connection, reconnection, or notification. Send 90-second heartbeats, close streams after 30 minutes so clients reconnect, and disable Traefik response buffering. SSE is never a source of correctness and never carries page content.
- Authenticate with Better Auth email and password. Permit registration and admin access only when the credential account email exactly matches an address in the comma-separated `ADMIN_EMAIL` configuration; use a temporary `/register` bootstrap page and repeat authorization checks in every mutation, media, and administrative endpoint.
- Upload PNG, JPEG, WebP, and GIF assets up to 20 MB through short-lived presigned requests. Serve them through same-origin `/media/[id]` responses. Allow public access only while an asset is referenced by a currently visible snapshot; otherwise require admin authentication. Retain objects while referenced by either current content or retained revisions.
- Applying a template clones independent pages, blocks, and data sources. Later template edits affect only future applications.
- Do not provide custom HTML, JavaScript, TypeScript, React, CSS, code blocks that execute, external scripts, arbitrary npm packages, or network-capable extensions.
- Weapons: catalog weapon, R1–R5, overall percent and/or personal percent.
- Artifacts: multiple named ranking groups, one-set or two-set rows, percent,
up to two main stats per slot, ordered substats, targets, NA/E/Q priority and
levels, and an optional note.
- Constellations: C1–C6 description and cumulative performance against C0=100%.
- Team Calc: multiple four-character teams, raw damage, calculated member
shares, rotation duration, total DPS, and combo text.
- Extra: multiple sections of image-first rows with heading, short body, and an
optional percentage.
### Content lifecycle
## Catalog and media
```mermaid
flowchart LR
E[Admin editor] -->|autosave + expectedVersion| A[Authenticated mutation]
A --> V{Validate version,<br/>blocks and formulas}
V -->|conflict| C[Merge or reload dialog]
V -->|accepted| T[(PostgreSQL transaction)]
The Sync button enqueues a worker job. It fetches Lunaris `version.json` and
the versioned character, weapon, and artifact lists, validates them, filters
placeholder or missing-image records, mirrors unique WebP assets and normalized
JSON to Bun S3, then atomically activates the completed version. Failed jobs do
not replace the active catalog. There is no automatic sync.
T --> P[Page and blocks]
T --> D[Immutable DataSource version]
T --> R[Revision and public snapshot]
T --> O[Outbox event]
`S3_ENDPOINT` is the private Garage API used for writes.
`S3_PUBLIC_URL=https://buzz-cdn.astrxl.dev` is the public read base. Browser
uploads go to an authenticated application route, which validates PNG, JPEG,
WebP, or GIF files up to 20 MB and writes them server-side through Bun S3.
O --> W[Retrying outbox worker]
W --> X[(Redis)]
X --> K[Shared cache and tag invalidation]
X --> S[SSE invalidation topics]
## Operations and acceptance
S --> U[Public and admin clients]
U -->|refetch authoritative version| Q[Public snapshot query]
Q --> K
K --> B[Guide block renderer]
V -->|field error| F[Typed field error]
F --> T
F --> B
```
## Delivery and Deployment
- Implement internally in four gates:
1. Database model, immutable data-source versions, revisions, transactional outbox, formula engine, and workbook-derived fixtures.
2. Public renderer, templates, and responsive shadcn admin editor.
3. Better Auth email/password authentication, reference-aware S3 media handling, Redis-backed caching and SSE, and conflict recovery.
4. Production builds, HTTP and integration tests, Docker, migrations, Kubernetes, and CI.
- Release to production once all four gates pass.
- During the public/editor gate, initialize shadcn with the `base-nova` preset before adding components. Add only the official components required by the implemented surface, and review generated component source and Base UI composition after each addition.
- Implement and commit each completed feature separately. Do not accumulate the project into one large commit.
- Keep every commit focused, independently reviewable, and limited to one coherent feature or supporting change.
- Run the feature’s relevant checks before committing it, and do not mix unrelated cleanup or formatting into feature commits.
- Use clear conventional commit messages such as `feat(editor): add page block ordering` or `test(formulas): add workbook regression corpus`.
- Provide native Kubernetes manifests through Kustomize, following the referenced Buzz Kubernetes conventions:
- Namespace `buzz-sheet`
- Two application replicas with rolling updates
- ClusterIP service on port 3000
- Traefik ingress for `sheet.sudloh.com`
- Cloudflare edge TLS with the standard HTTP Traefik origin route
- ARM64 workload scheduling matching the production image architecture
- Readiness/liveness health endpoint
- Requests of 500m CPU/512 MiB and limits of 1 CPU/1 GiB
- HPA from 2–6 replicas at 70% CPU
- PDB with minimum one available replica
- Least-privilege CI deployer RBAC
- Use the existing external PostgreSQL, Redis, and S3-compatible services through Kubernetes secrets; do not provision an in-cluster database or Redis instance.
- Share a stable `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` across replicas and set a deployment ID from the immutable image revision to protect Server Actions and client navigation during rolling updates. Notify active clients when that revision changes and offer a full reload into the new release.
- Build immutable application and migration images in Gitea Actions, defaulting to `registry.neko-piranha.ts.net/astral/buzz-sheet`.
- Run a versioned Drizzle migration Job before rollout. Use expand/contract migrations so the previous application image remains compatible during rolling deployment.
- Produce deployment manifests and CI configuration but do not contact or mutate the production cluster during implementation.
## Test Plan and Acceptance Criteria
- Commit sanitized fixtures containing workbook and sheet identity, A1-to-field mappings, inputs, formulas, and expected results without original guide text or media. Assert that the corpus contains exactly all 379 extracted formula cells: 314 use addition, 335 use division, 43 contain decimal literals, and none use functions, subtraction, multiplication, percentages, unary signs, cross-sheet references, or absolute references.
- Verify those 379 formulas with deterministic decimal results, then separately test precedence, subtraction, multiplication, percentages, unary signs, blank and missing references, cycles, dependency-propagated failures, rounding, and division by zero.
- Test block validation, schema migration, unknown-block fallback, templates, custom slugs, redirects, public notes, visibility, ordering, and revision restore.
- Test autosave debounce, serialization, transient retries, concurrent conflicts, checkpoint coalescing, named revisions, 30-day expiry, global data-source dependency updates, historical version pinning, “use latest,” and media retention.
- Test email/password registration restrictions and authorization on every write/media endpoint.
- Test upload limits, file validation, failed upload recovery, and same-origin media delivery.
- Test cached public snapshots and immediate dependency-aware invalidation following accepted autosaves.
- Test SSE authorization, public-event privacy, heartbeat, reconnection and authoritative refetch, duplicate or missed notifications, and directory/page/admin topics.
- Run integration tests against two application instances sharing Redis to verify cross-replica cache invalidation and SSE delivery.
- Test character directory search and all generic route combinations.
- Test directory search/filter states, card image failures, empty results, sticky guide navigation, section navigation, long Thai labels, and characters with many custom pages.
- Test each comparison presentation with empty, invalid, negative, very large, and crowded datasets. Verify normalization, sorting, visible exact values, touch behavior, and the accessible table fallback.
- Test desktop panel resizing and collapse state, Inspector/Preview switching, inline editing, inspector synchronization, command insertion, mobile drawers, keyboard block movement, and autosave/conflict states.
- Run responsive browser and visual regression tests for common mobile, tablet, desktop, and wide-desktop sizes, including touch editing and Thai text overflow.
- Validate accessibility for keyboard navigation, focus states, charts, drawers, dialogs, menus, command search, drag alternatives, form labels, contrast, and reduced motion.
- Pass unit/integration tests, `bunx tsc --noEmit`, linting, production application and migration image builds, and `kubectl kustomize k8s/`.
- Acceptance requires that all reference guide structures can be reproduced using visual blocks and formula fields without custom code or a spreadsheet-style public interface.
## Assumptions
- The interface and authored guide content are Thai-first; official character, weapon, and game terms may remain in their entered language.
- Public comparisons are authored and read-only in v1; visitors cannot construct custom comparison sets.
- Decorative artwork is optional and content-driven. The application shell remains restrained so guide imagery and comparison data stay dominant.
- The five supplied spreadsheets are design and formula references only. Their content is not automatically seeded or published.
- Content entry and migration are performed through the new admin editor.
- There is one administrator at launch.
- Formulas are permitted only as restricted arithmetic expressions; “no code” means no executable or presentation code.
- SSE is a live-refresh enhancement. PostgreSQL versions, public snapshots, and authoritative refetches remain the source of correctness.
- Named revisions remain indefinitely. Expired checkpoints release their media references only when no current content or other retained revision still references those assets.
- Advanced spreadsheet functions, collaborative cursor editing, comments, arbitrary plugins, and public user accounts are outside the initial release.
- PostgreSQL migrations run before the ARM64 web and worker rollout.
- Better Auth users and sessions are preserved; the legacy block-editor guide
content is intentionally reset by migration `0002`.
- Redis outbox events invalidate the directory and guide streams after saves,
trash, restore, or delete actions.
- Verification requires `bun run test`, `bun run typecheck`, `bun run lint`,
`kubectl kustomize k8s/`, a successful ARM64 container build, ready health
checks, CDN reads, authenticated upload, catalog sync, and public rendering.
- Do not use Chromium or Playwright. Use Vitest, HTTP checks, and manual browser
verification.
+1
View File
@@ -210,6 +210,7 @@ BETTER_AUTH_SECRET
ADMIN_EMAIL
REDIS_URL
S3_ENDPOINT
S3_PUBLIC_URL
S3_BUCKET
S3_ACCESS_KEY_ID
S3_SECRET_ACCESS_KEY
+11 -16
View File
@@ -1,28 +1,23 @@
import type { Metadata } from "next";
import { notFound, permanentRedirect } from "next/navigation";
import { notFound } from "next/navigation";
import { connection } from "next/server";
import { GuideShell } from "@/components/public/guide-shell";
import { loadPublicPage } from "@/lib/content/loaders";
import { publicSections, StructuredGuideShell } from "@/components/public/structured-guide";
import { getPublicGuide } from "@/lib/guides/queries";
type RouteParams = Promise<{ character: string; page: string }>;
type Params = Promise<{ character: string; page: string }>;
export async function generateMetadata({ params }: { params: RouteParams }): Promise<Metadata> {
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
await connection();
const { character, page } = await params;
const result = await loadPublicPage(character, page);
if (result.type !== "page") return { title: "ไม่พบคู่มือ" };
return {
title: `${result.snapshot.page.title} — ${result.snapshot.character.nameTh}`,
description: result.snapshot.character.description ?? undefined,
};
const guide = await getPublicGuide(character);
return guide ? { title: `${page} — ${guide.name} Build Guide`, description: guide.overview } : { title: "ไม่พบ Guide" };
}
export default async function GuidePage({ params }: { params: RouteParams }) {
export default async function GuideSectionPage({ params }: { params: Params }) {
await connection();
const { character, page } = await params;
const result = await loadPublicPage(character, page);
if (result.type === "redirect") permanentRedirect(result.location);
if (result.type === "not-found") notFound();
return <GuideShell snapshot={result.snapshot} />;
const guide = await getPublicGuide(character);
if (!guide || !publicSections(guide).some((section) => section.slug === page)) notFound();
return <StructuredGuideShell guide={guide} section={page} />;
}
+16 -11
View File
@@ -1,16 +1,21 @@
import { notFound, permanentRedirect } from "next/navigation";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { connection } from "next/server";
import { loadPublicPage } from "@/lib/content/loaders";
import { StructuredGuideShell } from "@/components/public/structured-guide";
import { getPublicGuide } from "@/lib/guides/queries";
export default async function CharacterPage({
params,
}: {
params: Promise<{ character: string }>;
}) {
type Params = Promise<{ character: string }>;
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
await connection();
const { character } = await params;
const result = await loadPublicPage(character);
if (result.type === "redirect") permanentRedirect(result.location);
notFound();
const guide = await getPublicGuide((await params).character);
return guide ? { title: `${guide.name} Build Guide`, description: guide.overview } : { title: "ไม่พบ Guide" };
}
export default async function GuideOverviewPage({ params }: { params: Params }) {
await connection();
const guide = await getPublicGuide((await params).character);
if (!guide) notFound();
return <StructuredGuideShell guide={guide} />;
}
+14 -25
View File
@@ -1,34 +1,23 @@
import Link from "next/link";
import { ArrowLeftIcon } from "lucide-react";
import { notFound, redirect } from "next/navigation";
import { connection } from "next/server";
import { EditorWorkspace } from "@/components/admin/editor-workspace";
import { LiveRefresh } from "@/components/live-refresh";
import { GuideSectionEditor } from "@/components/admin/guide-section-editor";
import { Button } from "@/components/ui/button";
import { getAdminSession } from "@/lib/auth/server";
import { loadAdminNavigation, loadAdminPage } from "@/lib/content/loaders";
import { getActiveCatalog, getGuideByCharacterKey, getPublicGuide } from "@/lib/guides/queries";
export default async function AdminEditorPage({
params,
}: {
params: Promise<{ character: string; page: string }>;
}) {
const sections = ["weapon", "artifact", "constellations", "team", "extra"] as const;
export default async function StructuredEditorPage({ params }: { params: Promise<{ character: string; page: string }> }) {
await connection();
if (!(await getAdminSession())) redirect("/admin/login");
const { character, page } = await params;
const [snapshot, navigation] = await Promise.all([
loadAdminPage(character, page),
loadAdminNavigation(),
]);
if (!snapshot) notFound();
return (
<>
{process.env.BUZZ_DEMO_MODE === "true" ? null : (
<LiveRefresh endpoint="/api/events/admin" />
)}
<EditorWorkspace
key={`${snapshot.page.id}:${snapshot.page.version}`}
snapshot={snapshot}
navigation={navigation}
/>
</>
);
if (!sections.includes(page as (typeof sections)[number])) notFound();
const guide = await getGuideByCharacterKey(character);
if (!guide) notFound();
const [catalog, content] = await Promise.all([getActiveCatalog(), getPublicGuide(guide.slug)]);
if (!catalog || !content) notFound();
return <main className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6 lg:p-8"><header className="flex items-center gap-4"><Button variant="outline" size="icon" render={<Link href={`/admin/${character}`} />} nativeButton={false} aria-label="กลับหน้า Guide"><ArrowLeftIcon /></Button><div><p className="text-sm text-muted-foreground">{guide.name}</p><h1 className="font-heading text-3xl font-semibold">{guide.sections.find((section) => section.slug === page)?.title ?? page}</h1></div></header><GuideSectionEditor section={page as (typeof sections)[number]} guide={guide} weapons={catalog.weapons} artifacts={catalog.artifacts} characters={catalog.characters} initial={{ weapons: content.weapons, artifactGroups: content.artifactGroups.map((group) => ({ id: group.id, name: group.name, rows: group.rows.map((row) => ({ firstArtifactKey: row.firstArtifactKey, secondArtifactKey: row.secondArtifactKey, percent: row.percent })) })), artifactProfile: content.artifactProfile, constellations: content.constellations, teams: content.teams.map((team) => ({ title: team.title, rotationSeconds: team.rotationSeconds, combo: team.combo, members: team.members.map((member) => ({ characterKey: member.characterKey, damage: member.damage })) })), extras: content.extras.map((section) => ({ title: section.title, rows: section.rows.map((row) => ({ mediaId: row.mediaId, heading: row.heading, body: row.body, percent: row.percent, imageUrl: row.imageUrl })) })) }} /></main>;
}
+27
View File
@@ -0,0 +1,27 @@
import Image from "next/image";
import Link from "next/link";
import { ArrowLeftIcon, BoxesIcon, GemIcon, SparklesIcon, SwordsIcon, UsersIcon } from "lucide-react";
import { notFound, redirect } from "next/navigation";
import { connection } from "next/server";
import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { GuideOverviewForm } from "@/components/admin/guide-overview-form";
import { getAdminSession } from "@/lib/auth/server";
import { getGuideByCharacterKey } from "@/lib/guides/queries";
const sections = [
{ slug: "weapon", title: "Weapons", description: "จัดอันดับอาวุธ Refinement และเปอร์เซ็นต์", icon: SwordsIcon },
{ slug: "artifact", title: "Artifacts & Stats", description: "ชุด Artifact, Main/Sub Stats และ Talent", icon: GemIcon },
{ slug: "constellations", title: "Constellations", description: "รายละเอียด C1–C6 และเปอร์เซ็นต์สะสม", icon: SparklesIcon },
{ slug: "team", title: "Team Calc", description: "ตัวละคร 4 ตัว Damage share, DPS และ Combo", icon: UsersIcon },
];
export default async function GuideOverviewPage({ params }: { params: Promise<{ character: string }> }) {
await connection();
if (!(await getAdminSession())) redirect("/admin/login");
const { character } = await params;
const guide = await getGuideByCharacterKey(character);
if (!guide) notFound();
return <main className="mx-auto flex w-full max-w-6xl flex-col gap-8 p-4 sm:p-6 lg:p-8"><header className="flex items-center gap-4"><Button variant="outline" size="icon" render={<Link href="/admin" />} nativeButton={false} aria-label="กลับ Dashboard"><ArrowLeftIcon /></Button><div className="relative size-16 overflow-hidden rounded-lg border"><Image src={guide.coverUrl} alt={guide.name} fill className="object-cover" /></div><div><p className="text-sm text-muted-foreground">Where do you want to edit?</p><h1 className="font-heading text-3xl font-semibold">{guide.name}</h1></div></header><GuideOverviewForm guide={guide} /><div className="grid gap-4 md:grid-cols-2">{sections.map(({ slug, title, description, icon: Icon }) => <Link key={slug} href={`/admin/${guide.characterKey}/${slug}`} className="rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"><Card className="h-full"><CardHeader><Icon className="size-6 text-primary" /><CardTitle>{title}</CardTitle><CardDescription>{description}</CardDescription></CardHeader></Card></Link>)}<Link href={`/admin/${guide.characterKey}/extra`} className="rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"><Card className="h-full"><CardHeader><BoxesIcon className="size-6 text-primary" /><CardTitle>Extra sections</CardTitle><CardDescription>ส่วนเสริมแบบภาพ หัวข้อ ข้อความสั้น และเปอร์เซ็นต์</CardDescription></CardHeader></Card></Link></div></main>;
}
+94
View File
@@ -14,6 +14,19 @@ import type { DataSourceSnapshot, PageSnapshot } from "@/lib/content/types";
import { requireAdmin } from "@/lib/auth/server";
import { builtInTemplates } from "@/lib/templates";
import { persistTemplateApplication } from "@/lib/templates/persist";
import {
createGuide,
enqueueCatalogSync,
permanentlyDeleteGuide,
restoreGuide,
saveArtifacts,
saveConstellations,
saveExtraSections,
saveTeams,
saveWeapons,
trashGuide,
updateGuideBasics,
} from "@/lib/guides/mutations";
const serializedBlockSchema = z.strictObject({
id: z.string().uuid(),
@@ -188,3 +201,84 @@ export async function applyEditorTemplate(input: unknown): Promise<
};
}
}
type GuideActionResult =
| { status: "saved"; version?: number; location?: string }
| { status: "error"; message: string };
async function runGuideAction(action: () => Promise<{ version?: number }>): Promise<GuideActionResult> {
try {
const result = await action();
return { status: "saved", version: result.version };
} catch (cause) {
if (cause instanceof z.ZodError) {
return { status: "error", message: cause.issues.map((issue) => issue.message).join(" • ") };
}
return { status: "error", message: cause instanceof Error ? cause.message : "บันทึกไม่สำเร็จ" };
}
}
export async function createStructuredGuide(input: unknown): Promise<GuideActionResult> {
await requireAdmin();
try {
const guide = await createGuide(input);
return { status: "saved", version: guide.version, location: `/admin/${guide.characterKey}` };
} catch (cause) {
return { status: "error", message: cause instanceof Error ? cause.message : "สร้าง Guide ไม่สำเร็จ" };
}
}
export async function saveWeaponSection(guideId: string, input: unknown) {
await requireAdmin();
return runGuideAction(() => saveWeapons(guideId, input));
}
export async function saveGuideOverview(guideId: string, input: unknown) {
await requireAdmin();
return runGuideAction(() => updateGuideBasics(guideId, input));
}
export async function saveArtifactSection(guideId: string, input: unknown) {
await requireAdmin();
return runGuideAction(() => saveArtifacts(guideId, input));
}
export async function saveConstellationSection(guideId: string, input: unknown) {
await requireAdmin();
return runGuideAction(() => saveConstellations(guideId, input));
}
export async function saveTeamSection(guideId: string, input: unknown) {
await requireAdmin();
return runGuideAction(() => saveTeams(guideId, input));
}
export async function saveExtraSectionList(guideId: string, input: unknown) {
await requireAdmin();
return runGuideAction(() => saveExtraSections(guideId, input));
}
export async function moveGuideToTrash(guideId: string) {
await requireAdmin();
return runGuideAction(() => trashGuide(guideId));
}
export async function restoreStructuredGuide(guideId: string) {
await requireAdmin();
return runGuideAction(() => restoreGuide(guideId));
}
export async function deleteStructuredGuide(guideId: string) {
await requireAdmin();
return runGuideAction(() => permanentlyDeleteGuide(guideId));
}
export async function startCatalogSync() {
const admin = await requireAdmin();
try {
const job = await enqueueCatalogSync(admin.user.id);
return { status: "saved" as const, jobId: job.id };
} catch (cause) {
return { status: "error" as const, message: cause instanceof Error ? cause.message : "เริ่ม Sync ไม่สำเร็จ" };
}
}
+25
View File
@@ -0,0 +1,25 @@
import Link from "next/link";
import { ArrowLeftIcon } from "lucide-react";
import { redirect } from "next/navigation";
import { connection } from "next/server";
import { CreateGuideFlow } from "@/components/admin/create-guide-flow";
import { Button } from "@/components/ui/button";
import { getAdminSession } from "@/lib/auth/server";
import { getActiveCatalog, listGuideCards } from "@/lib/guides/queries";
export default async function CreateGuidePage() {
await connection();
if (!(await getAdminSession())) redirect("/admin/login");
const [catalog, guides] = await Promise.all([getActiveCatalog(), listGuideCards()]);
if (!catalog) redirect("/admin?sync=required");
return (
<main className="mx-auto flex w-full max-w-7xl flex-col gap-8 p-4 sm:p-6 lg:p-8">
<header className="flex items-center gap-4">
<Button variant="outline" size="icon" render={<Link href="/admin" />} nativeButton={false} aria-label="กลับ Dashboard"><ArrowLeftIcon /></Button>
<div><p className="text-sm text-muted-foreground">Catalog {catalog.version}</p><h1 className="font-heading text-3xl font-semibold">สร้าง Guide ใหม่</h1></div>
</header>
<CreateGuideFlow characters={catalog.characters} existingKeys={guides.map((guide) => guide.characterKey)} />
</main>
);
}
+12 -80
View File
@@ -1,91 +1,23 @@
import Link from "next/link";
import { EyeIcon, FileTextIcon, LayoutTemplateIcon, PlusIcon } from "lucide-react";
import { redirect } from "next/navigation";
import { connection } from "next/server";
import { AdminSidebar } from "@/components/admin/admin-sidebar";
import { LiveRefresh } from "@/components/live-refresh";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { StructuredDashboard } from "@/components/admin/structured-dashboard";
import { getAdminSession } from "@/lib/auth/server";
import { loadAdminNavigation } from "@/lib/content/loaders";
import { getActiveCatalog, getLatestCatalogSyncJob, listGuideCards } from "@/lib/guides/queries";
export default async function AdminPage() {
await connection();
if (!(await getAdminSession())) redirect("/admin/login");
const navigation = await loadAdminNavigation();
const [guides, trash, syncJob, catalog] = await Promise.all([
listGuideCards(),
listGuideCards(true),
getLatestCatalogSyncJob(),
getActiveCatalog(),
]);
return (
<SidebarProvider>
{process.env.BUZZ_DEMO_MODE === "true" ? null : (
<LiveRefresh endpoint="/api/events/admin" />
)}
<AdminSidebar navigation={navigation} />
<SidebarInset>
<header className="flex min-h-14 items-center gap-3 border-b px-4">
<SidebarTrigger />
<div className="min-w-0 flex-1">
<h1 className="font-heading font-semibold">ภาพรวมเนื้อหา</h1>
<p className="text-xs text-muted-foreground">ตัวละคร หน้า และสถานะการเผยแพร่</p>
</div>
<Button variant="outline" render={<Link href="/admin/templates" />}>
<LayoutTemplateIcon />เทมเพลต
</Button>
</header>
<main className="mx-auto w-full max-w-6xl space-y-8 p-4 sm:p-6 lg:p-8">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-sm font-medium text-primary">คลังคู่มือ</p>
<h2 className="mt-1 font-heading text-2xl font-semibold">ตัวละครทั้งหมด</h2>
</div>
<Button render={<Link href="/admin/templates" />}><PlusIcon />สร้างจากเทมเพลต</Button>
</div>
{navigation.characters.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{navigation.characters.map((character) => (
<Card key={character.id}>
<CardHeader>
<CardTitle>{character.nameTh}</CardTitle>
<CardDescription>{character.nameEn ?? character.slug}</CardDescription>
<CardAction>
<Badge variant={character.visible ? "default" : "outline"}>
{character.visible ? "เผยแพร่" : "ซ่อน"}
</Badge>
</CardAction>
</CardHeader>
<CardContent className="space-y-2">
{character.pages.map((page) => (
<div key={page.id} className="flex min-h-11 items-center gap-2 rounded-lg border px-3">
<FileTextIcon className="size-4 text-muted-foreground" />
<Link href={`/admin/${character.slug}/${page.slug}`} className="min-w-0 flex-1 truncate text-sm font-medium hover:text-primary">
{page.title}
</Link>
<Badge variant="outline">v{page.version}</Badge>
{page.visible && character.visible ? (
<Button variant="ghost" size="icon-xs" render={<Link href={`/${character.slug}/${page.slug}`} target="_blank" />} aria-label={`เปิด ${page.title}`}>
<EyeIcon />
</Button>
) : null}
</div>
))}
</CardContent>
</Card>
))}
</div>
) : (
<Card className="border-dashed"><CardContent className="grid min-h-48 place-items-center text-center text-muted-foreground">ยังไม่มีตัวละคร เริ่มต้นด้วยเทมเพลตที่เตรียมไว้</CardContent></Card>
)}
</main>
</SidebarInset>
</SidebarProvider>
<main className="mx-auto flex w-full max-w-7xl flex-col gap-8 p-4 sm:p-6 lg:p-8">
<header><p className="text-sm font-medium text-primary">Buzz Sheet Admin</p><h1 className="font-heading text-3xl font-semibold">Guide Dashboard</h1><p className="mt-2 text-muted-foreground">จัดการ Guide แบบภาพ-first และบันทึกขึ้นเว็บไซต์ทันที</p></header>
<StructuredDashboard guides={guides} trash={trash} syncJob={syncJob} catalogVersion={catalog?.version ?? null} />
</main>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/auth/server";
import { uploadPublicMedia } from "@/lib/media/repository";
export async function POST(request: Request) {
const admin = await requireAdmin();
const form = await request.formData();
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "กรุณาเลือกไฟล์รูปภาพ" }, { status: 400 });
}
try {
return NextResponse.json(await uploadPublicMedia(file, admin.user.id), { status: 201 });
} catch (cause) {
return NextResponse.json(
{ error: cause instanceof Error ? cause.message : "อัปโหลดไม่สำเร็จ" },
{ status: 400 },
);
}
}
+5 -25
View File
@@ -1,32 +1,12 @@
import { connection } from "next/server";
import { CharacterDirectory } from "@/components/public/character-directory";
import { SiteHeader } from "@/components/public/site-header";
import { LiveRefresh } from "@/components/live-refresh";
import { loadPublicDirectory } from "@/lib/content/loaders";
import { StructuredDirectory } from "@/components/public/structured-directory";
import { SiteHeader } from "@/components/public/site-header";
import { listGuideCards } from "@/lib/guides/queries";
export default async function HomePage() {
await connection();
const characters = await loadPublicDirectory();
return (
<div className="min-h-svh bg-background">
{process.env.BUZZ_DEMO_MODE === "true" ? null : (
<LiveRefresh endpoint="/api/events/directory" />
)}
<SiteHeader />
<main className="mx-auto w-full max-w-7xl px-4 py-10 sm:px-6 sm:py-14">
<header className="mb-8 max-w-3xl space-y-3">
<p className="text-sm font-medium text-primary">คลังคู่มือตัวละคร</p>
<h1 className="font-heading text-3xl font-semibold tracking-tight sm:text-4xl">
หาคู่มือที่ต้องการ แล้วเริ่มจัดทีมได้เลย
</h1>
<p className="text-base leading-7 text-muted-foreground sm:text-lg">
ค้นหาตามชื่อ ธาตุ หรือบทบาท ทุกหน้าจัดรูปแบบมาให้อ่านง่ายทั้งมือถือและเดสก์ท็อป
</p>
</header>
<CharacterDirectory characters={characters} />
</main>
</div>
);
const guides = await listGuideCards();
return <div className="min-h-svh bg-background"><LiveRefresh endpoint="/api/events/directory" /><SiteHeader /><main className="mx-auto flex w-full max-w-7xl flex-col gap-8 px-4 py-10 sm:px-6 sm:py-14"><header className="max-w-3xl"><p className="text-sm font-medium text-primary">Genshin Impact Build Guides</p><h1 className="mt-2 font-heading text-4xl font-semibold">เลือกตัวละคร แล้วจัด Build และ Team ได้เลย</h1><p className="mt-3 text-lg leading-8 text-muted-foreground">คู่มือแบบภาพ-first สำหรับ Weapons, Artifacts, Constellations และ Team Calc</p></header><StructuredDirectory guides={guides} /></main></div>;
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { ArrowLeftIcon, CheckIcon, SearchIcon } from "lucide-react";
import { useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { createStructuredGuide } from "@/app/admin/actions";
import { MediaUploader, type UploadedMedia } from "@/components/admin/media-uploader";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
type Character = { key: string; name: string; element: string | null; rarity: number | null; imageUrl: string };
export function CreateGuideFlow({ characters, existingKeys }: { characters: Character[]; existingKeys: string[] }) {
const router = useRouter();
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<Character | null>(null);
const [cover, setCover] = useState<UploadedMedia | null>(null);
const [overview, setOverview] = useState("");
const [pending, startTransition] = useTransition();
const existing = useMemo(() => new Set(existingKeys), [existingKeys]);
const filtered = characters.filter((character) => character.name.toLowerCase().includes(query.trim().toLowerCase()));
if (selected) {
return (
<Card className="mx-auto max-w-2xl">
<CardHeader>
<CardTitle>ตั้งค่า Guide ของ {selected.name}</CardTitle>
<CardDescription>เพิ่มภาพหน้าปกและ Overview ก่อนสร้าง Guide สาธารณะ</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<Field>
<FieldLabel>Guide Image</FieldLabel>
{cover ? (
<div className="relative aspect-video overflow-hidden rounded-lg border">
<Image src={cover.url} alt={`Guide cover ${selected.name}`} fill className="object-cover" />
</div>
) : null}
<MediaUploader onReady={setCover} />
</Field>
<Field>
<FieldLabel htmlFor="overview">Overview</FieldLabel>
<Textarea id="overview" value={overview} maxLength={500} onChange={(event) => setOverview(event.target.value)} placeholder="สรุปแนวทางเล่น จุดเด่น และสิ่งที่ผู้ชมจะพบใน Guide นี้" />
<FieldDescription>{overview.length}/500 ตัวอักษร</FieldDescription>
</Field>
</FieldGroup>
</CardContent>
<CardFooter className="justify-between">
<Button type="button" variant="outline" onClick={() => setSelected(null)}><ArrowLeftIcon data-icon="inline-start" />เลือกใหม่</Button>
<Button disabled={pending || !cover || !overview.trim()} onClick={() => startTransition(async () => {
const result = await createStructuredGuide({ characterKey: selected.key, coverMediaId: cover?.id, overview });
if (result.status === "error") { toast.error(result.message); return; }
toast.success("สร้าง Guide แล้ว");
router.push(result.location!);
})}>{pending ? <Spinner data-icon="inline-start" /> : <CheckIcon data-icon="inline-start" />}สร้าง Guide</Button>
</CardFooter>
</Card>
);
}
return (
<div className="flex flex-col gap-6">
<InputGroup className="max-w-xl">
<InputGroupAddon><SearchIcon /></InputGroupAddon>
<InputGroupInput value={query} onChange={(event) => setQuery(event.target.value)} placeholder="ค้นหาตัวละคร…" aria-label="ค้นหาตัวละคร" />
</InputGroup>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{filtered.map((character) => {
const disabled = existing.has(character.key);
return (
<Card key={character.key} className="overflow-hidden py-0">
<div className="relative aspect-square bg-muted"><Image src={character.imageUrl} alt={character.name} fill className="object-cover" /></div>
<CardHeader className="pt-4">
<CardTitle>{character.name}</CardTitle>
<CardDescription>{character.element ?? "Unknown element"}</CardDescription>
</CardHeader>
<CardContent className="flex gap-2">{character.rarity ? <Badge variant="secondary">{character.rarity}★</Badge> : null}{disabled ? <Badge variant="outline">มี Guide แล้ว</Badge> : null}</CardContent>
<CardFooter className="pb-4"><Button className="w-full" disabled={disabled} onClick={() => setSelected(character)}>เลือกตัวละคร</Button></CardFooter>
</Card>
);
})}
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
"use client";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { SaveIcon } from "lucide-react";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { saveGuideOverview } from "@/app/admin/actions";
import { MediaUploader } from "@/components/admin/media-uploader";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
export function GuideOverviewForm({ guide }: { guide: { id: string; version: number; overview: string; coverMediaId: string; coverUrl: string; name: string } }) {
const router = useRouter();
const [overview, setOverview] = useState(guide.overview);
const [cover, setCover] = useState({ id: guide.coverMediaId, url: guide.coverUrl });
const [pending, startTransition] = useTransition();
return <Card><CardHeader><CardTitle>Overview</CardTitle><CardDescription>ภาพและข้อความนี้แสดงบนหน้าแรกและส่วนแนะนำของ Guide</CardDescription></CardHeader><CardContent><FieldGroup><Field><FieldLabel>Guide Image</FieldLabel><div className="relative aspect-video max-w-2xl overflow-hidden rounded-lg border"><Image src={cover.url} alt={`Guide ${guide.name}`} fill className="object-cover" /></div><MediaUploader onReady={(asset) => setCover(asset)} /></Field><Field><FieldLabel htmlFor="guide-overview">Overview</FieldLabel><Textarea id="guide-overview" value={overview} maxLength={500} onChange={(event) => setOverview(event.target.value)} /><FieldDescription>{overview.length}/500 ตัวอักษร</FieldDescription></Field></FieldGroup></CardContent><CardFooter className="justify-end"><Button disabled={pending || !overview.trim()} onClick={() => startTransition(async () => { const result = await saveGuideOverview(guide.id, { expectedVersion: guide.version, coverMediaId: cover.id, overview }); if (result.status === "error") { toast.error(result.message); return; } toast.success("บันทึก Overview แล้ว"); router.refresh(); })}>{pending ? <Spinner data-icon="inline-start" /> : <SaveIcon data-icon="inline-start" />}Save</Button></CardFooter></Card>;
}
File diff suppressed because one or more lines are too long
+12 -37
View File
@@ -11,10 +11,8 @@ import { IMAGE_MIME_TYPES, MAX_MEDIA_BYTES } from "@/lib/media/validation";
export interface UploadedMedia {
id: string;
fileName: string;
mimeType: string;
byteSize: number;
url: string;
fileName: string;
}
export function MediaUploader({
@@ -23,7 +21,7 @@ export function MediaUploader({
onReady: (media: UploadedMedia) => void;
}) {
const inputId = useId();
const [state, setState] = useState<"idle" | "preparing" | "uploading" | "checking">("idle");
const [state, setState] = useState<"idle" | "uploading">("idle");
const busy = state !== "idle";
async function upload(file: File) {
@@ -36,41 +34,20 @@ export function MediaUploader({
return;
}
let mediaId: string | undefined;
try {
setState("preparing");
const presignResponse = await fetch("/api/admin/media/presign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fileName: file.name, mimeType: file.type, byteSize: file.size }),
});
if (!presignResponse.ok) throw new Error("presign");
const upload = await presignResponse.json() as {
mediaId: string;
uploadUrl: string;
headers: Record<string, string>;
};
mediaId = upload.mediaId;
setState("uploading");
const putResponse = await fetch(upload.uploadUrl, {
method: "PUT",
headers: upload.headers,
body: file,
const body = new FormData();
body.set("file", file);
const response = await fetch("/api/admin/media", {
method: "POST",
body,
});
if (!putResponse.ok) throw new Error("upload");
setState("checking");
const completeResponse = await fetch(`/api/admin/media/${mediaId}/complete`, { method: "POST" });
if (!completeResponse.ok) throw new Error("complete");
const result = await completeResponse.json() as { media: UploadedMedia };
onReady(result.media);
const result = await response.json() as UploadedMedia & { error?: string };
if (!response.ok) throw new Error(result.error || "upload");
onReady(result);
toast.success("อัปโหลดรูปภาพแล้ว");
} catch {
if (mediaId) {
await fetch(`/api/admin/media/${mediaId}`, { method: "DELETE" }).catch(() => undefined);
}
toast.error("อัปโหลดไม่สำเร็จ ไฟล์ชั่วคราวถูกเก็บกวาดแล้ว");
} catch (cause) {
toast.error(cause instanceof Error ? cause.message : "อัปโหลดไม่สำเร็จ");
} finally {
setState("idle");
}
@@ -99,9 +76,7 @@ export function MediaUploader({
render={<label htmlFor={inputId} />}
>
{busy ? <Spinner /> : <ImagePlusIcon />}
{state === "preparing" && "กำลังเตรียม…"}
{state === "uploading" && "กำลังอัปโหลด…"}
{state === "checking" && "กำลังตรวจสอบไฟล์…"}
{state === "idle" && "อัปโหลดและเพิ่มรูปภาพ"}
{!busy ? <UploadCloudIcon className="ml-auto" /> : null}
</Button>
+60
View File
@@ -0,0 +1,60 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { PlusIcon, RefreshCwIcon, RotateCcwIcon, Trash2Icon } from "lucide-react";
import { useEffect, useTransition } from "react";
import { toast } from "sonner";
import { deleteStructuredGuide, moveGuideToTrash, restoreStructuredGuide, startCatalogSync } from "@/app/admin/actions";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
import { Spinner } from "@/components/ui/spinner";
type GuideCard = { id: string; characterKey: string; name: string; overview: string; coverUrl: string; version: number; updatedAt: Date; trashedAt: Date | null };
type SyncJob = { id: string; status: string; version: string | null; progress: number; total: number; error: string | null } | null;
export function StructuredDashboard({ guides, trash, syncJob, catalogVersion }: { guides: GuideCard[]; trash: GuideCard[]; syncJob: SyncJob; catalogVersion: string | null }) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const syncing = syncJob?.status === "pending" || syncJob?.status === "processing";
useEffect(() => {
if (!syncing) return;
const timer = window.setInterval(() => router.refresh(), 3_000);
return () => window.clearInterval(timer);
}, [router, syncing]);
function run(action: () => Promise<{ status: string; message?: string }>, success: string) {
startTransition(async () => {
const result = await action();
if (result.status === "error") toast.error(result.message);
else { toast.success(success); router.refresh(); }
});
}
return (
<div className="flex flex-col gap-10">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2"><Badge variant="outline">Lunaris {catalogVersion ?? "ยังไม่ Sync"}</Badge>{syncJob ? <Badge variant={syncJob.status === "failed" ? "destructive" : "secondary"}>{syncJob.status}{syncing && syncJob.total ? ` ${syncJob.progress}/${syncJob.total}` : ""}</Badge> : null}</div>
<div className="flex gap-2"><Button variant="outline" disabled={pending || syncing} onClick={() => run(startCatalogSync, "เริ่ม Sync แล้ว")}>{pending || syncing ? <Spinner data-icon="inline-start" /> : <RefreshCwIcon data-icon="inline-start" />}Sync Catalog</Button><Button render={<Link href="/admin/create" />} nativeButton={false} disabled={!catalogVersion}><PlusIcon data-icon="inline-start" />สร้าง Guide</Button></div>
</div>
{syncJob?.status === "failed" ? <Alert variant="destructive"><AlertTitle>Sync ไม่สำเร็จ</AlertTitle><AlertDescription>{syncJob.error}</AlertDescription></Alert> : null}
{guides.length ? (
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">{guides.map((guide) => (
<Card key={guide.id} className="overflow-hidden py-0">
<div className="relative aspect-video bg-muted"><Image src={guide.coverUrl} alt={`Guide ${guide.name}`} fill className="object-cover" /></div>
<CardHeader className="pt-5"><CardTitle>{guide.name}</CardTitle><CardDescription className="line-clamp-2">{guide.overview}</CardDescription><CardAction><Badge variant="secondary">v{guide.version}</Badge></CardAction></CardHeader>
<CardContent><p className="text-xs text-muted-foreground">แก้ไขล่าสุด {new Date(guide.updatedAt).toLocaleString("th-TH")}</p></CardContent>
<CardFooter className="justify-between pb-5"><Button render={<Link href={`/admin/${guide.characterKey}`} />} nativeButton={false}>แก้ไข Guide</Button><AlertDialog><AlertDialogTrigger render={<Button variant="ghost" size="icon" />}><Trash2Icon /><span className="sr-only">ย้าย {guide.name} ไปถังขยะ</span></AlertDialogTrigger><AlertDialogContent><AlertDialogHeader><AlertDialogTitle>ย้าย {guide.name} ไปถังขยะ?</AlertDialogTitle><AlertDialogDescription>Guide จะหายจากหน้าเว็บไซต์ทันที และสามารถกู้คืนได้ภายหลัง</AlertDialogDescription></AlertDialogHeader><AlertDialogFooter><AlertDialogCancel>ยกเลิก</AlertDialogCancel><AlertDialogAction onClick={() => run(() => moveGuideToTrash(guide.id), "ย้ายไปถังขยะแล้ว")}>ย้ายไปถังขยะ</AlertDialogAction></AlertDialogFooter></AlertDialogContent></AlertDialog></CardFooter>
</Card>
))}</div>
) : <Empty className="border"><EmptyHeader><EmptyTitle>ยังไม่มี Guide</EmptyTitle><EmptyDescription>Sync Catalog แล้วเลือกตัวละครเพื่อสร้าง Guide แรก</EmptyDescription></EmptyHeader><EmptyContent><Button render={<Link href="/admin/create" />} nativeButton={false} disabled={!catalogVersion}>สร้าง Guide</Button></EmptyContent></Empty>}
{trash.length ? <section className="flex flex-col gap-4"><div><h2 className="font-heading text-xl font-semibold">ถังขยะ</h2><p className="text-sm text-muted-foreground">Guide ที่ซ่อนไว้จากเว็บไซต์</p></div><div className="grid gap-3 md:grid-cols-2">{trash.map((guide) => <Card key={guide.id}><CardHeader><CardTitle>{guide.name}</CardTitle><CardDescription>{guide.overview}</CardDescription><CardAction><div className="flex gap-2"><Button variant="outline" size="sm" disabled={pending} onClick={() => run(() => restoreStructuredGuide(guide.id), "กู้คืน Guide แล้ว")}><RotateCcwIcon data-icon="inline-start" />กู้คืน</Button><AlertDialog><AlertDialogTrigger render={<Button variant="destructive" size="sm" />}><Trash2Icon data-icon="inline-start" />ลบถาวร</AlertDialogTrigger><AlertDialogContent><AlertDialogHeader><AlertDialogTitle>ลบ {guide.name} ถาวร?</AlertDialogTitle><AlertDialogDescription>ข้อมูล Weapons, Artifacts, Constellations, Team และ Extra ทั้งหมดจะกู้คืนไม่ได้</AlertDialogDescription></AlertDialogHeader><AlertDialogFooter><AlertDialogCancel>ยกเลิก</AlertDialogCancel><AlertDialogAction onClick={() => run(() => deleteStructuredGuide(guide.id), "ลบ Guide ถาวรแล้ว")}>ยืนยันลบถาวร</AlertDialogAction></AlertDialogFooter></AlertDialogContent></AlertDialog></div></CardAction></CardHeader></Card>)}</div></section> : null}
</div>
);
}
@@ -0,0 +1,20 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { SearchIcon } from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
type GuideCard = { id: string; slug: string; name: string; overview: string; coverUrl: string; version: number };
export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
const [query, setQuery] = useState("");
const normalized = query.trim().toLowerCase();
const filtered = guides.filter((guide) => `${guide.name} ${guide.overview}`.toLowerCase().includes(normalized));
return <div className="flex flex-col gap-8"><InputGroup className="h-11 max-w-2xl"><InputGroupAddon><SearchIcon /></InputGroupAddon><InputGroupInput value={query} onChange={(event) => setQuery(event.target.value)} placeholder="ค้นหา Guide…" aria-label="ค้นหา Guide" /></InputGroup>{filtered.length ? <div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">{filtered.map((guide) => <Link key={guide.id} href={`/${guide.slug}`} className="group rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"><Card className="h-full overflow-hidden py-0 transition-transform group-hover:-translate-y-0.5"><div className="relative aspect-video bg-muted"><Image src={guide.coverUrl} alt={`Guide ${guide.name}`} fill className="object-cover" /></div><CardHeader className="pt-5"><CardTitle>{guide.name}</CardTitle><CardDescription className="line-clamp-3">{guide.overview}</CardDescription></CardHeader><CardContent className="pb-5"><Badge variant="secondary">Build Guide</Badge></CardContent></Card></Link>)}</div> : <Empty className="border"><EmptyHeader><EmptyTitle>ไม่พบ Guide</EmptyTitle><EmptyDescription>ลองค้นหาด้วยชื่อตัวละครอื่น</EmptyDescription></EmptyHeader></Empty>}</div>;
}
+55
View File
@@ -0,0 +1,55 @@
import Image from "next/image";
import Link from "next/link";
import { ChevronLeftIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { SiteHeader } from "@/components/public/site-header";
import { LiveRefresh } from "@/components/live-refresh";
import type { PublicGuide } from "@/lib/guides/queries";
import { cn } from "@/lib/utils";
function hasSection(guide: PublicGuide, slug: string): boolean {
if (slug === "weapon") return guide.weapons.length > 0;
if (slug === "artifact") return guide.artifactGroups.some((group) => group.rows.length > 0) || Boolean(guide.artifactProfile && (guide.artifactProfile.sands.length || guide.artifactProfile.goblet.length || guide.artifactProfile.circlet.length));
if (slug === "constellations") return guide.constellations.length > 0;
if (slug === "team") return guide.teams.length > 0;
return guide.extras.some((extra) => extra.slug === slug && extra.rows.length > 0);
}
export function publicSections(guide: PublicGuide) {
return guide.sections.filter((section) => hasSection(guide, section.slug));
}
export function StructuredGuideShell({ guide, section }: { guide: PublicGuide; section?: string }) {
const sections = publicSections(guide);
return <div className="min-h-svh bg-background"><LiveRefresh endpoint={`/api/events/page/${guide.id}`} /><SiteHeader /><div className="sticky top-0 border-b bg-background/95 supports-backdrop-filter:backdrop-blur"><div className="mx-auto flex min-h-16 w-full max-w-7xl items-center gap-3 px-4 sm:px-6"><Link href="/" aria-label="กลับหน้ารวม" className="grid size-10 place-items-center rounded-lg hover:bg-muted"><ChevronLeftIcon /></Link><div className="relative size-11 overflow-hidden rounded-lg border"><Image src={guide.character.imageUrl} alt={guide.name} fill className="object-cover" /></div><div className="min-w-0 flex-1"><p className="truncate font-heading font-semibold">{guide.name}</p><p className="truncate text-xs text-muted-foreground">Build Guide</p></div>{guide.character.element ? <Badge>{guide.character.element}</Badge> : null}</div><nav className="mx-auto flex min-h-11 w-full max-w-7xl gap-1 overflow-x-auto px-4 sm:px-6"><Link href={`/${guide.slug}`} className={cn("flex items-center rounded-md px-3 text-sm text-muted-foreground", !section && "bg-muted text-foreground")}>Overview</Link>{sections.map((item) => <Link key={item.id} href={`/${guide.slug}/${item.slug}`} className={cn("flex items-center rounded-md px-3 text-sm text-muted-foreground", section === item.slug && "bg-muted text-foreground")}>{item.title}</Link>)}</nav></div><main className="mx-auto w-full max-w-7xl p-4 sm:p-6 lg:p-10">{section ? <GuideSection guide={guide} section={section} /> : <GuideOverview guide={guide} sections={sections} />}</main></div>;
}
function GuideOverview({ guide, sections }: { guide: PublicGuide; sections: ReturnType<typeof publicSections> }) {
return <div className="flex flex-col gap-8"><div className="relative aspect-[16/7] overflow-hidden rounded-xl border bg-muted"><Image src={guide.coverUrl} alt={`Guide ${guide.name}`} fill priority className="object-cover" /></div><div className="mx-auto flex max-w-3xl flex-col gap-3 text-center"><p className="text-sm font-medium text-primary">Build Overview</p><h1 className="font-heading text-4xl font-semibold">{guide.name}</h1><p className="text-lg leading-8 text-muted-foreground">{guide.overview}</p></div>{sections.length ? <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">{sections.map((item) => <Link key={item.id} href={`/${guide.slug}/${item.slug}`}><Card className="h-full"><CardHeader><CardTitle>{item.title}</CardTitle><CardDescription>เปิดดูคำแนะนำฉบับเต็ม</CardDescription></CardHeader></Card></Link>)}</div> : <Card><CardHeader><CardTitle>กำลังจัดทำเนื้อหา</CardTitle><CardDescription>กลับมาตรวจสอบอีกครั้งหลังผู้ดูแลเพิ่มข้อมูล</CardDescription></CardHeader></Card>}</div>;
}
function GuideSection({ guide, section }: { guide: PublicGuide; section: string }) {
if (section === "weapon") return <WeaponSection guide={guide} />;
if (section === "artifact") return <ArtifactSection guide={guide} />;
if (section === "constellations") return <ConstellationSection guide={guide} />;
if (section === "team") return <TeamSection guide={guide} />;
const extra = guide.extras.find((item) => item.slug === section);
return extra ? <ExtraSection extra={extra} /> : null;
}
function metric(value: string | null) { return value === null ? null : `${Number(value).toLocaleString(undefined, { maximumFractionDigits: 2 })}%`; }
function WeaponSection({ guide }: { guide: PublicGuide }) { return <section className="flex flex-col gap-6"><header className="text-center"><p className="text-sm font-medium text-primary">อาวุธที่แนะนำ</p><h1 className="font-heading text-4xl font-semibold">Weapons</h1></header><div className="grid gap-3 lg:grid-cols-2">{guide.weapons.map((row, index) => <Card key={row.id} size="sm"><CardContent className="flex items-center gap-4"><div className="relative size-16 shrink-0 overflow-hidden rounded-lg bg-muted"><Image src={row.weapon.imageUrl} alt={row.weapon.name} fill className="object-cover" /></div><div className="min-w-0 flex-1"><p className="font-semibold">{index + 1}. {row.weapon.name} R{row.refinement}</p><p className="text-sm text-muted-foreground">{row.weapon.weaponType}</p></div>{row.overallPercent ? <div className="text-right"><p className="text-xs text-muted-foreground">Overall</p><p className="text-2xl font-semibold">{metric(row.overallPercent)}</p></div> : null}{row.personalPercent ? <div className="text-right"><p className="text-xs text-muted-foreground">Personal</p><p className="text-2xl font-semibold">{metric(row.personalPercent)}</p></div> : null}</CardContent></Card>)}</div></section>; }
function ArtifactSection({ guide }: { guide: PublicGuide }) { const profile = guide.artifactProfile; return <section className="flex flex-col gap-6"><header className="text-center"><p className="text-sm font-medium text-primary">อาร์ติแฟกต์และสเตตัสที่แนะนำ</p><h1 className="font-heading text-4xl font-semibold">Artifacts & Stats</h1></header><div className="grid gap-5 lg:grid-cols-[1.2fr_1fr]"> <div className="flex flex-col gap-5">{guide.artifactGroups.map((group) => <Card key={group.id}><CardHeader><CardTitle>{group.name}</CardTitle></CardHeader><CardContent className="flex flex-col gap-3">{group.rows.map((row, index) => <div key={row.id} className="flex items-center gap-3"><div className="flex -space-x-3"><Image src={row.first.imageUrl} alt={row.first.name} width={56} height={56} className="rounded-lg border bg-muted" />{row.second ? <Image src={row.second.imageUrl} alt={row.second.name} width={56} height={56} className="rounded-lg border bg-muted" /> : null}</div><div className="min-w-0 flex-1"><p className="font-medium">{index + 1}. {row.first.name}{row.second ? ` + ${row.second.name}` : ""}</p></div><p className="text-2xl font-semibold">{metric(row.percent)}</p></div>)}</CardContent></Card>)}</div>{profile ? <Card><CardHeader><CardTitle>Recommended Stats</CardTitle></CardHeader><CardContent className="flex flex-col gap-5"><StatLine label="Sands" values={profile.sands} /><StatLine label="Goblet" values={profile.goblet} /><StatLine label="Circlet" values={profile.circlet} /><Separator /><StatLine label="Substats" values={profile.substats} />{profile.targets.length ? <div><p className="mb-2 text-sm font-medium">Target Stats</p>{profile.targets.map((target) => <div key={`${target.stat}-${target.value}`} className="flex justify-between py-1 text-sm"><span>{target.stat}</span><strong>{target.value}</strong></div>)}</div> : null}{profile.talents.length ? <div><p className="mb-2 text-sm font-medium">Talent Priority</p><div className="flex gap-2">{[...profile.talents].sort((a,b) => a.priority-b.priority).map((talent) => <Badge key={talent.type} variant="secondary">{talent.priority}. {talent.type}{talent.level ? ` ${talent.level}` : ""}</Badge>)}</div></div> : null}{profile.note ? <p className="text-sm leading-6 text-muted-foreground">{profile.note}</p> : null}</CardContent></Card> : null}</div></section>; }
function StatLine({ label, values }: { label: string; values: string[] }) { return <div><p className="text-sm text-muted-foreground">{label}</p><div className="mt-1 flex flex-wrap gap-2">{values.map((value) => <Badge key={value} variant="outline">{value}</Badge>)}</div></div>; }
function ConstellationSection({ guide }: { guide: PublicGuide }) { return <section className="flex flex-col gap-6"><header className="text-center"><p className="text-sm font-medium text-primary">กลุ่มดาว</p><h1 className="font-heading text-4xl font-semibold">Constellations</h1></header><div className="grid gap-4 md:grid-cols-2">{guide.constellations.map((row) => <Card key={row.level}><CardHeader><CardTitle>C{row.level}</CardTitle><CardDescription>{metric(row.cumulativePercent)} of C0</CardDescription></CardHeader><CardContent><p className="leading-7">{row.detail}</p></CardContent></Card>)}</div></section>; }
function TeamSection({ guide }: { guide: PublicGuide }) { return <section className="flex flex-col gap-6"><header className="text-center"><p className="text-sm font-medium text-primary">คำนวณความเสียหาย</p><h1 className="font-heading text-4xl font-semibold">Team Calc</h1></header><div className="grid gap-5 xl:grid-cols-2">{guide.teams.map((team) => { const total = team.members.reduce((sum, member) => sum + Number(member.damage), 0); const seconds = Number(team.rotationSeconds); return <Card key={team.id}><CardHeader><CardTitle>{team.title}</CardTitle><CardDescription>{seconds}s rotation</CardDescription></CardHeader><CardContent className="flex flex-col gap-4"><div className="grid grid-cols-4 gap-2">{team.members.map((member) => <div key={member.position} className="text-center"><div className="relative aspect-square overflow-hidden rounded-lg bg-muted"><Image src={member.character.imageUrl} alt={member.character.name} fill className="object-cover" /></div><p className="mt-2 truncate text-sm font-medium">{member.character.name}</p><p className="text-xs text-muted-foreground">{total ? (Number(member.damage) / total * 100).toFixed(2) : "0.00"}%</p></div>)}</div><Separator /><div className="flex items-end justify-between"><div><p className="text-sm text-muted-foreground">Total damage</p><p className="font-semibold">{total.toLocaleString()}</p></div><div className="text-right"><p className="text-sm text-muted-foreground">Total DPS</p><p className="text-3xl font-semibold text-primary">{(total / seconds).toLocaleString(undefined, { maximumFractionDigits: 0 })}</p></div></div><p className="rounded-lg bg-muted p-3 text-sm leading-6">{team.combo}</p></CardContent></Card>; })}</div></section>; }
function ExtraSection({ extra }: { extra: PublicGuide["extras"][number] }) { return <section className="flex flex-col gap-6"><header className="text-center"><p className="text-sm font-medium text-primary">Extra Guide</p><h1 className="font-heading text-4xl font-semibold">{extra.title}</h1></header><div className="grid gap-5 md:grid-cols-2">{extra.rows.map((row) => <Card key={row.id} className="overflow-hidden py-0">{row.imageUrl ? <div className="relative aspect-video bg-muted"><Image src={row.imageUrl} alt={row.heading} fill className="object-cover" /></div> : null}<CardHeader className="pt-5"><CardTitle>{row.heading}</CardTitle><CardDescription>{row.body}</CardDescription>{row.percent ? <Badge>{metric(row.percent)}</Badge> : null}</CardHeader></Card>)}</div></section>; }
+297
View File
@@ -0,0 +1,297 @@
"use client"
import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group"
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"
const Combobox = ComboboxPrimitive.Root
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</ComboboxPrimitive.Trigger>
)
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
)
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean
showClear?: boolean
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
render={<ComboboxTrigger />}
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
/>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
)
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn("group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
)
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
className
)}
{...props}
/>
)
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
)
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
)
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
className
)}
{...props}
/>
)
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
className
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
)
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
)
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null)
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
}
+130
View File
@@ -0,0 +1,130 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex items-center gap-0.5", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<Button
variant={isActive ? "outline" : "ghost"}
size={size}
className={cn(className)}
nativeButton={false}
render={
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
{...props}
/>
}
/>
)
}
function PaginationPrevious({
className,
text = "Previous",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("pl-1.5!", className)}
{...props}
>
<ChevronLeftIcon data-icon="inline-start" />
<span className="hidden sm:block">{text}</span>
</PaginationLink>
)
}
function PaginationNext({
className,
text = "Next",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("pr-1.5!", className)}
{...props}
>
<span className="hidden sm:block">{text}</span>
<ChevronRightIcon data-icon="inline-end" />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn(
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<MoreHorizontalIcon
/>
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}
+232
View File
@@ -5,6 +5,7 @@ import {
index,
integer,
jsonb,
numeric,
pgTable,
primaryKey,
text,
@@ -421,6 +422,237 @@ export const outboxEvents = pgTable(
],
);
export const catalogVersions = pgTable(
"catalog_version",
{
version: varchar("version", { length: 32 }).primaryKey(),
active: boolean("active").default(false).notNull(),
sourceTimestamp: bigint("source_timestamp", { mode: "number" }),
syncedAt: timestamp("synced_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [uniqueIndex("catalog_active_unique").on(table.active).where(sql`${table.active} = true`)],
);
export const catalogCharacters = pgTable(
"catalog_character",
{
version: varchar("version", { length: 32 })
.notNull()
.references(() => catalogVersions.version, { onDelete: "cascade" }),
key: varchar("key", { length: 64 }).notNull(),
name: text("name").notNull(),
element: varchar("element", { length: 48 }),
rarity: integer("rarity"),
weaponType: varchar("weapon_type", { length: 64 }),
imageKey: text("image_key").notNull(),
},
(table) => [
primaryKey({ columns: [table.version, table.key] }),
index("catalog_character_name_idx").on(table.version, table.name),
],
);
export const catalogWeapons = pgTable(
"catalog_weapon",
{
version: varchar("version", { length: 32 })
.notNull()
.references(() => catalogVersions.version, { onDelete: "cascade" }),
key: varchar("key", { length: 64 }).notNull(),
name: text("name").notNull(),
rarity: integer("rarity"),
weaponType: varchar("weapon_type", { length: 64 }),
imageKey: text("image_key").notNull(),
},
(table) => [
primaryKey({ columns: [table.version, table.key] }),
index("catalog_weapon_name_idx").on(table.version, table.name),
],
);
export const catalogArtifacts = pgTable(
"catalog_artifact",
{
version: varchar("version", { length: 32 })
.notNull()
.references(() => catalogVersions.version, { onDelete: "cascade" }),
key: varchar("key", { length: 64 }).notNull(),
name: text("name").notNull(),
imageKey: text("image_key").notNull(),
},
(table) => [
primaryKey({ columns: [table.version, table.key] }),
index("catalog_artifact_name_idx").on(table.version, table.name),
],
);
export const catalogSyncJobs = pgTable(
"catalog_sync_job",
{
id: uuid("id").defaultRandom().primaryKey(),
status: varchar("status", { length: 24 }).default("pending").notNull(),
version: varchar("version", { length: 32 }),
progress: integer("progress").default(0).notNull(),
total: integer("total").default(0).notNull(),
error: text("error"),
triggeredById: text("triggered_by_id").references(() => users.id, { onDelete: "set null" }),
startedAt: timestamp("started_at", { withTimezone: true }),
completedAt: timestamp("completed_at", { withTimezone: true }),
...timestamps,
},
(table) => [index("catalog_sync_status_idx").on(table.status, table.createdAt)],
);
export const guides = pgTable(
"guide",
{
id: uuid("id").defaultRandom().primaryKey(),
characterKey: varchar("character_key", { length: 64 }).notNull(),
catalogVersion: varchar("catalog_version", { length: 32 })
.notNull()
.references(() => catalogVersions.version, { onDelete: "restrict" }),
slug: varchar("slug", { length: 120 }).notNull(),
name: text("name").notNull(),
overview: text("overview").notNull(),
coverMediaId: uuid("cover_media_id")
.notNull()
.references(() => media.id, { onDelete: "restrict" }),
version: integer("version").default(1).notNull(),
sortOrder: integer("sort_order").default(0).notNull(),
trashedAt: timestamp("trashed_at", { withTimezone: true }),
...timestamps,
},
(table) => [
uniqueIndex("guide_character_key_unique").on(table.characterKey),
uniqueIndex("guide_slug_unique").on(table.slug),
index("guide_directory_idx").on(table.trashedAt, table.sortOrder),
],
);
export const guideSections = pgTable(
"guide_section",
{
id: uuid("id").defaultRandom().primaryKey(),
guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }),
kind: varchar("kind", { length: 32 }).notNull(),
slug: varchar("slug", { length: 120 }).notNull(),
title: text("title").notNull(),
enabled: boolean("enabled").default(true).notNull(),
sortOrder: integer("sort_order").default(0).notNull(),
},
(table) => [
uniqueIndex("guide_section_slug_unique").on(table.guideId, table.slug),
index("guide_section_order_idx").on(table.guideId, table.sortOrder),
],
);
export const weaponRecommendations = pgTable(
"weapon_recommendation",
{
id: uuid("id").defaultRandom().primaryKey(),
guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }),
catalogVersion: varchar("catalog_version", { length: 32 }).notNull(),
weaponKey: varchar("weapon_key", { length: 64 }).notNull(),
refinement: integer("refinement").notNull(),
overallPercent: numeric("overall_percent", { precision: 8, scale: 2 }),
personalPercent: numeric("personal_percent", { precision: 8, scale: 2 }),
sortOrder: integer("sort_order").default(0).notNull(),
},
(table) => [index("weapon_recommendation_order_idx").on(table.guideId, table.sortOrder)],
);
export const artifactGroups = pgTable(
"artifact_group",
{
id: uuid("id").defaultRandom().primaryKey(),
guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }),
name: text("name").notNull(),
sortOrder: integer("sort_order").default(0).notNull(),
},
(table) => [index("artifact_group_order_idx").on(table.guideId, table.sortOrder)],
);
export const artifactRecommendations = pgTable(
"artifact_recommendation",
{
id: uuid("id").defaultRandom().primaryKey(),
groupId: uuid("group_id").notNull().references(() => artifactGroups.id, { onDelete: "cascade" }),
catalogVersion: varchar("catalog_version", { length: 32 }).notNull(),
firstArtifactKey: varchar("first_artifact_key", { length: 64 }).notNull(),
secondArtifactKey: varchar("second_artifact_key", { length: 64 }),
percent: numeric("percent", { precision: 8, scale: 2 }).notNull(),
sortOrder: integer("sort_order").default(0).notNull(),
},
(table) => [index("artifact_recommendation_order_idx").on(table.groupId, table.sortOrder)],
);
export const artifactProfiles = pgTable("artifact_profile", {
guideId: uuid("guide_id").primaryKey().references(() => guides.id, { onDelete: "cascade" }),
sands: jsonb("sands").$type<string[]>().default([]).notNull(),
goblet: jsonb("goblet").$type<string[]>().default([]).notNull(),
circlet: jsonb("circlet").$type<string[]>().default([]).notNull(),
substats: jsonb("substats").$type<string[]>().default([]).notNull(),
targets: jsonb("targets").$type<Array<{ stat: string; value: string }>>().default([]).notNull(),
talents: jsonb("talents").$type<Array<{ type: "NA" | "E" | "Q"; priority: number; level?: string }>>().default([]).notNull(),
note: text("note"),
...timestamps,
});
export const constellationRecommendations = pgTable(
"constellation_recommendation",
{
guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }),
level: integer("level").notNull(),
detail: text("detail").notNull(),
cumulativePercent: numeric("cumulative_percent", { precision: 8, scale: 2 }).notNull(),
},
(table) => [primaryKey({ columns: [table.guideId, table.level] })],
);
export const teamCompositions = pgTable(
"team_composition",
{
id: uuid("id").defaultRandom().primaryKey(),
guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }),
title: text("title").notNull(),
rotationSeconds: numeric("rotation_seconds", { precision: 8, scale: 2 }).notNull(),
combo: text("combo").notNull(),
sortOrder: integer("sort_order").default(0).notNull(),
},
(table) => [index("team_composition_order_idx").on(table.guideId, table.sortOrder)],
);
export const teamMembers = pgTable(
"team_member",
{
teamId: uuid("team_id").notNull().references(() => teamCompositions.id, { onDelete: "cascade" }),
position: integer("position").notNull(),
catalogVersion: varchar("catalog_version", { length: 32 }).notNull(),
characterKey: varchar("character_key", { length: 64 }).notNull(),
damage: numeric("damage", { precision: 18, scale: 2 }).notNull(),
},
(table) => [primaryKey({ columns: [table.teamId, table.position] })],
);
export const extraSections = pgTable("extra_section", {
sectionId: uuid("section_id").primaryKey().references(() => guideSections.id, { onDelete: "cascade" }),
...timestamps,
});
export const extraSectionRows = pgTable(
"extra_section_row",
{
id: uuid("id").defaultRandom().primaryKey(),
sectionId: uuid("section_id").notNull().references(() => extraSections.sectionId, { onDelete: "cascade" }),
mediaId: uuid("media_id").references(() => media.id, { onDelete: "set null" }),
heading: text("heading").notNull(),
body: text("body").notNull(),
percent: numeric("percent", { precision: 8, scale: 2 }),
sortOrder: integer("sort_order").default(0).notNull(),
},
(table) => [index("extra_section_row_order_idx").on(table.sectionId, table.sortOrder)],
);
export const characterRelations = relations(characters, ({ many, one }) => ({
pages: many(pages),
portrait: one(media, {
+198
View File
@@ -0,0 +1,198 @@
CREATE TABLE "artifact_group" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"guide_id" uuid NOT NULL,
"name" text NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
CREATE TABLE "artifact_profile" (
"guide_id" uuid PRIMARY KEY NOT NULL,
"sands" jsonb DEFAULT '[]'::jsonb NOT NULL,
"goblet" jsonb DEFAULT '[]'::jsonb NOT NULL,
"circlet" jsonb DEFAULT '[]'::jsonb NOT NULL,
"substats" jsonb DEFAULT '[]'::jsonb NOT NULL,
"targets" jsonb DEFAULT '[]'::jsonb NOT NULL,
"talents" jsonb DEFAULT '[]'::jsonb NOT NULL,
"note" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "artifact_recommendation" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"group_id" uuid NOT NULL,
"catalog_version" varchar(32) NOT NULL,
"first_artifact_key" varchar(64) NOT NULL,
"second_artifact_key" varchar(64),
"percent" numeric(8, 2) NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
CREATE TABLE "catalog_artifact" (
"version" varchar(32) NOT NULL,
"key" varchar(64) NOT NULL,
"name" text NOT NULL,
"image_key" text NOT NULL,
CONSTRAINT "catalog_artifact_version_key_pk" PRIMARY KEY("version","key")
);
--> statement-breakpoint
CREATE TABLE "catalog_character" (
"version" varchar(32) NOT NULL,
"key" varchar(64) NOT NULL,
"name" text NOT NULL,
"element" varchar(48),
"rarity" integer,
"weapon_type" varchar(64),
"image_key" text NOT NULL,
CONSTRAINT "catalog_character_version_key_pk" PRIMARY KEY("version","key")
);
--> statement-breakpoint
CREATE TABLE "catalog_sync_job" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"status" varchar(24) DEFAULT 'pending' NOT NULL,
"version" varchar(32),
"progress" integer DEFAULT 0 NOT NULL,
"total" integer DEFAULT 0 NOT NULL,
"error" text,
"triggered_by_id" text,
"started_at" timestamp with time zone,
"completed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "catalog_version" (
"version" varchar(32) PRIMARY KEY NOT NULL,
"active" boolean DEFAULT false NOT NULL,
"source_timestamp" bigint,
"synced_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "catalog_weapon" (
"version" varchar(32) NOT NULL,
"key" varchar(64) NOT NULL,
"name" text NOT NULL,
"rarity" integer,
"weapon_type" varchar(64),
"image_key" text NOT NULL,
CONSTRAINT "catalog_weapon_version_key_pk" PRIMARY KEY("version","key")
);
--> statement-breakpoint
CREATE TABLE "constellation_recommendation" (
"guide_id" uuid NOT NULL,
"level" integer NOT NULL,
"detail" text NOT NULL,
"cumulative_percent" numeric(8, 2) NOT NULL,
CONSTRAINT "constellation_recommendation_guide_id_level_pk" PRIMARY KEY("guide_id","level")
);
--> statement-breakpoint
CREATE TABLE "extra_section_row" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"section_id" uuid NOT NULL,
"media_id" uuid,
"heading" text NOT NULL,
"body" text NOT NULL,
"percent" numeric(8, 2),
"sort_order" integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
CREATE TABLE "extra_section" (
"section_id" uuid PRIMARY KEY NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "guide_section" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"guide_id" uuid NOT NULL,
"kind" varchar(32) NOT NULL,
"slug" varchar(120) NOT NULL,
"title" text NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
CREATE TABLE "guide" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"character_key" varchar(64) NOT NULL,
"catalog_version" varchar(32) NOT NULL,
"slug" varchar(120) NOT NULL,
"name" text NOT NULL,
"overview" text NOT NULL,
"cover_media_id" uuid NOT NULL,
"version" integer DEFAULT 1 NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"trashed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "team_composition" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"guide_id" uuid NOT NULL,
"title" text NOT NULL,
"rotation_seconds" numeric(8, 2) NOT NULL,
"combo" text NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
CREATE TABLE "team_member" (
"team_id" uuid NOT NULL,
"position" integer NOT NULL,
"catalog_version" varchar(32) NOT NULL,
"character_key" varchar(64) NOT NULL,
"damage" numeric(18, 2) NOT NULL,
CONSTRAINT "team_member_team_id_position_pk" PRIMARY KEY("team_id","position")
);
--> statement-breakpoint
CREATE TABLE "weapon_recommendation" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"guide_id" uuid NOT NULL,
"catalog_version" varchar(32) NOT NULL,
"weapon_key" varchar(64) NOT NULL,
"refinement" integer NOT NULL,
"overall_percent" numeric(8, 2),
"personal_percent" numeric(8, 2),
"sort_order" integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
ALTER TABLE "artifact_group" ADD CONSTRAINT "artifact_group_guide_id_guide_id_fk" FOREIGN KEY ("guide_id") REFERENCES "public"."guide"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "artifact_profile" ADD CONSTRAINT "artifact_profile_guide_id_guide_id_fk" FOREIGN KEY ("guide_id") REFERENCES "public"."guide"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "artifact_recommendation" ADD CONSTRAINT "artifact_recommendation_group_id_artifact_group_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."artifact_group"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "catalog_artifact" ADD CONSTRAINT "catalog_artifact_version_catalog_version_version_fk" FOREIGN KEY ("version") REFERENCES "public"."catalog_version"("version") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "catalog_character" ADD CONSTRAINT "catalog_character_version_catalog_version_version_fk" FOREIGN KEY ("version") REFERENCES "public"."catalog_version"("version") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "catalog_sync_job" ADD CONSTRAINT "catalog_sync_job_triggered_by_id_user_id_fk" FOREIGN KEY ("triggered_by_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "catalog_weapon" ADD CONSTRAINT "catalog_weapon_version_catalog_version_version_fk" FOREIGN KEY ("version") REFERENCES "public"."catalog_version"("version") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "constellation_recommendation" ADD CONSTRAINT "constellation_recommendation_guide_id_guide_id_fk" FOREIGN KEY ("guide_id") REFERENCES "public"."guide"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "extra_section_row" ADD CONSTRAINT "extra_section_row_section_id_extra_section_section_id_fk" FOREIGN KEY ("section_id") REFERENCES "public"."extra_section"("section_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "extra_section_row" ADD CONSTRAINT "extra_section_row_media_id_media_id_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "extra_section" ADD CONSTRAINT "extra_section_section_id_guide_section_id_fk" FOREIGN KEY ("section_id") REFERENCES "public"."guide_section"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "guide_section" ADD CONSTRAINT "guide_section_guide_id_guide_id_fk" FOREIGN KEY ("guide_id") REFERENCES "public"."guide"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "guide" ADD CONSTRAINT "guide_catalog_version_catalog_version_version_fk" FOREIGN KEY ("catalog_version") REFERENCES "public"."catalog_version"("version") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "guide" ADD CONSTRAINT "guide_cover_media_id_media_id_fk" FOREIGN KEY ("cover_media_id") REFERENCES "public"."media"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "team_composition" ADD CONSTRAINT "team_composition_guide_id_guide_id_fk" FOREIGN KEY ("guide_id") REFERENCES "public"."guide"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "team_member" ADD CONSTRAINT "team_member_team_id_team_composition_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team_composition"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "weapon_recommendation" ADD CONSTRAINT "weapon_recommendation_guide_id_guide_id_fk" FOREIGN KEY ("guide_id") REFERENCES "public"."guide"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "artifact_group_order_idx" ON "artifact_group" USING btree ("guide_id","sort_order");--> statement-breakpoint
CREATE INDEX "artifact_recommendation_order_idx" ON "artifact_recommendation" USING btree ("group_id","sort_order");--> statement-breakpoint
CREATE INDEX "catalog_artifact_name_idx" ON "catalog_artifact" USING btree ("version","name");--> statement-breakpoint
CREATE INDEX "catalog_character_name_idx" ON "catalog_character" USING btree ("version","name");--> statement-breakpoint
CREATE INDEX "catalog_sync_status_idx" ON "catalog_sync_job" USING btree ("status","created_at");--> statement-breakpoint
CREATE UNIQUE INDEX "catalog_active_unique" ON "catalog_version" USING btree ("active") WHERE "catalog_version"."active" = true;--> statement-breakpoint
CREATE INDEX "catalog_weapon_name_idx" ON "catalog_weapon" USING btree ("version","name");--> statement-breakpoint
CREATE INDEX "extra_section_row_order_idx" ON "extra_section_row" USING btree ("section_id","sort_order");--> statement-breakpoint
CREATE UNIQUE INDEX "guide_section_slug_unique" ON "guide_section" USING btree ("guide_id","slug");--> statement-breakpoint
CREATE INDEX "guide_section_order_idx" ON "guide_section" USING btree ("guide_id","sort_order");--> statement-breakpoint
CREATE UNIQUE INDEX "guide_character_key_unique" ON "guide" USING btree ("character_key");--> statement-breakpoint
CREATE UNIQUE INDEX "guide_slug_unique" ON "guide" USING btree ("slug");--> statement-breakpoint
CREATE INDEX "guide_directory_idx" ON "guide" USING btree ("trashed_at","sort_order");--> statement-breakpoint
CREATE INDEX "team_composition_order_idx" ON "team_composition" USING btree ("guide_id","sort_order");--> statement-breakpoint
CREATE INDEX "weapon_recommendation_order_idx" ON "weapon_recommendation" USING btree ("guide_id","sort_order");
-- The structured guide launch intentionally resets legacy guide content while
-- preserving Better Auth users, accounts, sessions, and verification records.
DELETE FROM "character";
DELETE FROM "template";
DELETE FROM "data_source";
DELETE FROM "media_reference";
DELETE FROM "media";
DELETE FROM "outbox_event";
File diff suppressed because it is too large Load Diff
+7
View File
@@ -15,6 +15,13 @@
"when": 1787989072634,
"tag": "0001_lumpy_marauders",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1788008368705,
"tag": "0002_absent_ben_parker",
"breakpoints": true
}
]
}
+4
View File
@@ -70,3 +70,7 @@ and `redis` set to `"ok"`.
is not sufficient.
- If `BETTER_AUTH_URL` or the public hostname changes, update the Kubernetes
ingress and Cloudflare/DNS configuration as well.
- `S3_ENDPOINT` is the private Garage API used for writes. `S3_PUBLIC_URL`
is the public read-only CDN base (production uses
`https://buzz-cdn.astrxl.dev`). Browser uploads go through the authenticated
application route; do not point `S3_ENDPOINT` at the CDN hostname.
+147
View File
@@ -0,0 +1,147 @@
import { asc, eq } from "drizzle-orm";
import * as z from "zod";
import { getDb } from "@/db/client";
import {
catalogArtifacts,
catalogCharacters,
catalogSyncJobs,
catalogVersions,
catalogWeapons,
} from "@/db/schema";
import { getMediaStorage } from "@/lib/media/storage";
const VERSION_URL = "https://api.lunaris.moe/data/version.json";
const API_ROOT = "https://api.lunaris.moe/data";
const versionSchema = z.object({
version: z.string().regex(/^\d+(?:\.\d+)+$/u),
timestamp: z.number().int().optional(),
});
const characterSchema = z.record(z.string(), z.object({
enName: z.string(),
CardImg: z.string(),
element: z.string().optional(),
qualityType: z.string().optional(),
weaponType: z.string().optional(),
}));
const weaponSchema = z.record(z.string(), z.object({
enName: z.string(),
weaponIcon: z.string(),
qualityType: z.string().optional(),
weaponType: z.string().optional(),
}));
const artifactSchema = z.record(z.string(), z.object({
enName: z.string(),
setIcon: z.string(),
}));
async function getJson(url: string): Promise<unknown> {
const response = await fetch(url, { signal: AbortSignal.timeout(30_000), cache: "no-store" });
if (!response.ok) throw new Error(`Lunaris returned ${response.status} for ${url}`);
return response.json();
}
function webpName(value: string): string {
return value.endsWith(".webp") ? value : `${value}.webp`;
}
function rarity(value: string | undefined): number | null {
if (!value) return null;
return { QUALITY_WHITE: 1, QUALITY_GREEN: 2, QUALITY_BLUE: 3, QUALITY_PURPLE: 4, QUALITY_ORANGE: 5 }[value] ?? null;
}
async function uploadAssets(
jobId: string,
assets: Array<{ source: string; target: string }>,
): Promise<Set<string>> {
const storage = await getMediaStorage();
let next = 0;
let completed = 0;
const missing = new Set<string>();
await getDb().update(catalogSyncJobs).set({ total: assets.length, progress: 0 }).where(eq(catalogSyncJobs.id, jobId));
async function worker() {
while (next < assets.length) {
const asset = assets[next++];
const response = await fetch(asset.source, { signal: AbortSignal.timeout(30_000), cache: "no-store" });
if (response.status === 404) missing.add(asset.target);
else if (!response.ok) throw new Error(`Asset returned ${response.status}: ${asset.source}`);
else await storage.write(asset.target, response, { type: "image/webp", acl: "public-read" });
completed += 1;
if (completed % 10 === 0 || completed === assets.length) {
await getDb().update(catalogSyncJobs).set({ progress: completed }).where(eq(catalogSyncJobs.id, jobId));
}
}
}
await Promise.all(Array.from({ length: Math.min(4, assets.length) }, () => worker()));
return missing;
}
export async function claimCatalogSyncJob() {
const [pending] = await getDb().select().from(catalogSyncJobs).where(eq(catalogSyncJobs.status, "pending")).orderBy(asc(catalogSyncJobs.createdAt)).limit(1);
if (!pending) return null;
const [claimed] = await getDb().update(catalogSyncJobs).set({ status: "processing", startedAt: new Date(), error: null }).where(eq(catalogSyncJobs.id, pending.id)).returning();
return claimed ?? null;
}
export async function processCatalogSyncJob(jobId: string): Promise<void> {
try {
const versionInfo = versionSchema.parse(await getJson(VERSION_URL));
const version = versionInfo.version;
await getDb().update(catalogSyncJobs).set({ version }).where(eq(catalogSyncJobs.id, jobId));
const [charactersRaw, weaponsRaw, artifactsRaw] = await Promise.all([
getJson(`${API_ROOT}/${version}/charlist.json`),
getJson(`${API_ROOT}/${version}/weaponlist.json`),
getJson(`${API_ROOT}/${version}/artifactlist.json`),
]);
const characterData = characterSchema.parse(charactersRaw);
const weaponData = weaponSchema.parse(weaponsRaw);
const artifactData = artifactSchema.parse(artifactsRaw);
let characters = Object.entries(characterData).filter(([, value]) => value.enName && value.enName !== "???" && value.CardImg).map(([key, value]) => ({
version, key, name: value.enName, element: value.element ?? null, rarity: rarity(value.qualityType), weaponType: value.weaponType ?? null,
imageKey: `catalog/${version}/characters/${webpName(value.CardImg)}`,
sourceImage: `${API_ROOT}/assets/gachaicon/${encodeURIComponent(webpName(value.CardImg))}`,
}));
let weapons = Object.entries(weaponData).filter(([, value]) => value.enName && value.enName !== "???" && value.weaponIcon).map(([key, value]) => ({
version, key, name: value.enName, rarity: rarity(value.qualityType), weaponType: value.weaponType ?? null,
imageKey: `catalog/${version}/weapons/${webpName(value.weaponIcon)}`,
sourceImage: `${API_ROOT}/assets/weaponicon/${encodeURIComponent(webpName(value.weaponIcon))}`,
}));
let artifacts = Object.entries(artifactData).filter(([, value]) => value.enName && value.enName !== "???" && value.setIcon).map(([key, value]) => ({
version, key, name: value.enName,
imageKey: `catalog/${version}/artifacts/${webpName(value.setIcon)}`,
sourceImage: `${API_ROOT}/assets/artifacts/${encodeURIComponent(webpName(value.setIcon))}`,
}));
const uniqueAssets = new Map<string, { source: string; target: string }>();
for (const item of [...characters, ...weapons, ...artifacts]) uniqueAssets.set(item.imageKey, { source: item.sourceImage, target: item.imageKey });
const missingAssets = await uploadAssets(jobId, [...uniqueAssets.values()]);
characters = characters.filter((item) => !missingAssets.has(item.imageKey));
weapons = weapons.filter((item) => !missingAssets.has(item.imageKey));
artifacts = artifacts.filter((item) => !missingAssets.has(item.imageKey));
const storage = await getMediaStorage();
await Promise.all([
storage.write(`catalog/${version}/characters.json`, JSON.stringify(characters.map((item) => ({ version: item.version, key: item.key, name: item.name, element: item.element, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey }))), { type: "application/json", acl: "public-read" }),
storage.write(`catalog/${version}/weapons.json`, JSON.stringify(weapons.map((item) => ({ version: item.version, key: item.key, name: item.name, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey }))), { type: "application/json", acl: "public-read" }),
storage.write(`catalog/${version}/artifacts.json`, JSON.stringify(artifacts.map((item) => ({ version: item.version, key: item.key, name: item.name, imageKey: item.imageKey }))), { type: "application/json", acl: "public-read" }),
]);
await getDb().transaction(async (tx) => {
await tx.insert(catalogVersions).values({ version, sourceTimestamp: versionInfo.timestamp ?? null }).onConflictDoUpdate({ target: catalogVersions.version, set: { sourceTimestamp: versionInfo.timestamp ?? null, syncedAt: new Date() } });
await tx.delete(catalogCharacters).where(eq(catalogCharacters.version, version));
await tx.delete(catalogWeapons).where(eq(catalogWeapons.version, version));
await tx.delete(catalogArtifacts).where(eq(catalogArtifacts.version, version));
if (characters.length) await tx.insert(catalogCharacters).values(characters.map((item) => ({ version: item.version, key: item.key, name: item.name, element: item.element, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey })));
if (weapons.length) await tx.insert(catalogWeapons).values(weapons.map((item) => ({ version: item.version, key: item.key, name: item.name, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey })));
if (artifacts.length) await tx.insert(catalogArtifacts).values(artifacts.map((item) => ({ version: item.version, key: item.key, name: item.name, imageKey: item.imageKey })));
await tx.update(catalogVersions).set({ active: false });
await tx.update(catalogVersions).set({ active: true, syncedAt: new Date() }).where(eq(catalogVersions.version, version));
await tx.update(catalogSyncJobs).set({ status: "completed", completedAt: new Date(), progress: uniqueAssets.size, total: uniqueAssets.size }).where(eq(catalogSyncJobs.id, jobId));
});
await storage.write("catalog/current.json", JSON.stringify({ version }), { type: "application/json", acl: "public-read" });
} catch (cause) {
await getDb().update(catalogSyncJobs).set({ status: "failed", completedAt: new Date(), error: cause instanceof Error ? cause.message.slice(0, 4_000) : "Unknown sync failure" }).where(eq(catalogSyncJobs.id, jobId));
throw cause;
}
}
+11
View File
@@ -0,0 +1,11 @@
export function calculateTeamMetrics(damage: number[], rotationSeconds: number) {
const totalDamage = damage.reduce((sum, value) => sum + value, 0);
if (!Number.isFinite(rotationSeconds) || rotationSeconds <= 0 || damage.some((value) => !Number.isFinite(value) || value < 0) || totalDamage <= 0) {
throw new Error("Team damage and rotation must be positive finite values.");
}
return {
totalDamage,
totalDps: totalDamage / rotationSeconds,
shares: damage.map((value) => value / totalDamage * 100),
};
}
+205
View File
@@ -0,0 +1,205 @@
import "server-only";
import { and, eq, sql } from "drizzle-orm";
import { getDb } from "@/db";
import {
artifactGroups,
artifactProfiles,
artifactRecommendations,
catalogCharacters,
catalogSyncJobs,
catalogVersions,
constellationRecommendations,
extraSectionRows,
extraSections,
guides,
guideSections,
media,
outboxEvents,
teamCompositions,
teamMembers,
weaponRecommendations,
} from "@/db/schema";
import {
artifactSectionSchema,
constellationSectionSchema,
createGuideSchema,
extraSectionSchema,
slugFromCharacterName,
teamSectionSchema,
updateGuideSchema,
weaponSectionSchema,
} from "@/lib/guides/schemas";
const DEFAULT_SECTIONS = [
{ kind: "weapon", slug: "weapon", title: "Weapons", sortOrder: 0 },
{ kind: "artifact", slug: "artifact", title: "Artifacts & Stats", sortOrder: 1 },
{ kind: "constellations", slug: "constellations", title: "Constellations", sortOrder: 2 },
{ kind: "team", slug: "team", title: "Team Calc", sortOrder: 3 },
] as const;
async function emitGuideEvents(tx: Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0], guideId: string, version: number) {
await tx.insert(outboxEvents).values([
{ topic: "directory", aggregateId: guideId, eventType: "directory.updated", payload: { id: guideId, version } },
{ topic: `page:${guideId}`, aggregateId: guideId, eventType: "page.updated", payload: { id: guideId, version } },
{ topic: "admin", aggregateId: guideId, eventType: "admin.updated", payload: { id: guideId, version } },
]);
}
export async function createGuide(input: unknown) {
const parsed = createGuideSchema.parse(input);
const db = getDb();
const [version] = await db.select().from(catalogVersions).where(eq(catalogVersions.active, true)).limit(1);
if (!version) throw new Error("กรุณา Sync ข้อมูล Lunaris ก่อนสร้างคู่มือ");
const [character] = await db.select().from(catalogCharacters).where(and(eq(catalogCharacters.version, version.version), eq(catalogCharacters.key, parsed.characterKey))).limit(1);
if (!character) throw new Error("ไม่พบตัวละครใน Catalog เวอร์ชันปัจจุบัน");
const [cover] = await db.select().from(media).where(and(eq(media.id, parsed.coverMediaId), eq(media.status, "ready"))).limit(1);
if (!cover) throw new Error("กรุณาอัปโหลดภาพ Guide ให้สำเร็จ");
const baseSlug = slugFromCharacterName(character.name, character.key);
const [slugCollision] = await db.select({ id: guides.id }).from(guides).where(eq(guides.slug, baseSlug)).limit(1);
const slug = slugCollision ? `${baseSlug}-${character.key.toLowerCase()}` : baseSlug;
return db.transaction(async (tx) => {
const [guide] = await tx.insert(guides).values({
characterKey: character.key,
catalogVersion: version.version,
slug,
name: character.name,
overview: parsed.overview,
coverMediaId: parsed.coverMediaId,
}).returning();
await tx.insert(guideSections).values(DEFAULT_SECTIONS.map((section) => ({ ...section, guideId: guide.id })));
await tx.insert(artifactProfiles).values({ guideId: guide.id });
await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, parsed.coverMediaId));
await emitGuideEvents(tx, guide.id, guide.version);
return guide;
});
}
async function advanceGuideVersion(tx: Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0], guideId: string, expectedVersion: number) {
const [guide] = await tx.update(guides).set({ version: expectedVersion + 1 }).where(and(eq(guides.id, guideId), eq(guides.version, expectedVersion), sql`${guides.trashedAt} is null`)).returning();
if (!guide) throw new Error("ข้อมูลถูกแก้ไขจากแท็บอื่น กรุณาโหลดหน้าใหม่");
await emitGuideEvents(tx, guide.id, guide.version);
return guide;
}
export async function saveWeapons(guideId: string, input: unknown) {
const data = weaponSectionSchema.parse(input);
const [guide] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1);
if (!guide) throw new Error("ไม่พบ Guide");
return getDb().transaction(async (tx) => {
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
await tx.delete(weaponRecommendations).where(eq(weaponRecommendations.guideId, guideId));
if (data.rows.length) await tx.insert(weaponRecommendations).values(data.rows.map((row, sortOrder) => ({ ...row, overallPercent: row.overallPercent?.toString() ?? null, personalPercent: row.personalPercent?.toString() ?? null, guideId, catalogVersion: guide.catalogVersion, sortOrder })));
return updated;
});
}
export async function updateGuideBasics(guideId: string, input: unknown) {
const data = updateGuideSchema.parse(input);
const [current] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1);
if (!current) throw new Error("ไม่พบ Guide");
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 ที่อัปโหลดแล้ว");
return getDb().transaction(async (tx) => {
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
await tx.update(guides).set({ overview: data.overview, coverMediaId: data.coverMediaId }).where(eq(guides.id, guideId));
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`${media.currentReferenceCount} + 1` }).where(eq(media.id, data.coverMediaId));
}
return updated;
});
}
export async function saveArtifacts(guideId: string, input: unknown) {
const data = artifactSectionSchema.parse(input);
const [guide] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1);
if (!guide) throw new Error("ไม่พบ Guide");
return getDb().transaction(async (tx) => {
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
await tx.delete(artifactGroups).where(eq(artifactGroups.guideId, guideId));
for (const [sortOrder, group] of data.groups.entries()) {
const [created] = await tx.insert(artifactGroups).values({ guideId, name: group.name, sortOrder }).returning();
if (group.rows.length) await tx.insert(artifactRecommendations).values(group.rows.map((row, rowOrder) => ({ ...row, percent: row.percent.toString(), groupId: created.id, catalogVersion: guide.catalogVersion, sortOrder: rowOrder })));
}
await tx.insert(artifactProfiles).values({ guideId, sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats, targets: data.targets, talents: data.talents, note: data.note }).onConflictDoUpdate({ target: artifactProfiles.guideId, set: { sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats, targets: data.targets, talents: data.talents, note: data.note } });
return updated;
});
}
export async function saveConstellations(guideId: string, input: unknown) {
const data = constellationSectionSchema.parse(input);
return getDb().transaction(async (tx) => {
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
await tx.delete(constellationRecommendations).where(eq(constellationRecommendations.guideId, guideId));
if (data.rows.length) await tx.insert(constellationRecommendations).values(data.rows.map((row) => ({ ...row, cumulativePercent: row.cumulativePercent.toString(), guideId })));
return updated;
});
}
export async function saveTeams(guideId: string, input: unknown) {
const data = teamSectionSchema.parse(input);
const [guide] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1);
if (!guide) throw new Error("ไม่พบ Guide");
return getDb().transaction(async (tx) => {
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
await tx.delete(teamCompositions).where(eq(teamCompositions.guideId, guideId));
for (const [sortOrder, team] of data.teams.entries()) {
const [created] = await tx.insert(teamCompositions).values({ guideId, title: team.title, rotationSeconds: team.rotationSeconds.toString(), combo: team.combo, sortOrder }).returning();
await tx.insert(teamMembers).values(team.members.map((member, position) => ({ teamId: created.id, position, catalogVersion: guide.catalogVersion, characterKey: member.characterKey, damage: member.damage.toString() })));
}
return updated;
});
}
export async function saveExtraSections(guideId: string, input: unknown) {
const data = extraSectionSchema.parse(input);
return getDb().transaction(async (tx) => {
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
await tx.delete(guideSections).where(and(eq(guideSections.guideId, guideId), eq(guideSections.kind, "extra")));
for (const [sortOrder, section] of data.sections.entries()) {
const [createdSection] = await tx.insert(guideSections).values({ guideId, kind: "extra", slug: `extra-${sortOrder + 1}`, title: section.title, enabled: true, sortOrder: 4 + sortOrder }).returning();
await tx.insert(extraSections).values({ sectionId: createdSection.id });
if (section.rows.length) await tx.insert(extraSectionRows).values(section.rows.map((row, rowOrder) => ({ ...row, percent: row.percent?.toString() ?? null, sectionId: createdSection.id, sortOrder: rowOrder })));
}
return updated;
});
}
export async function trashGuide(guideId: string) {
return getDb().transaction(async (tx) => {
const [guide] = await tx.update(guides).set({ trashedAt: new Date(), version: sql`${guides.version} + 1` }).where(eq(guides.id, guideId)).returning();
if (!guide) throw new Error("ไม่พบ Guide");
await emitGuideEvents(tx, guide.id, guide.version);
return guide;
});
}
export async function restoreGuide(guideId: string) {
return getDb().transaction(async (tx) => {
const [guide] = await tx.update(guides).set({ trashedAt: null, version: sql`${guides.version} + 1` }).where(eq(guides.id, guideId)).returning();
if (!guide) throw new Error("ไม่พบ Guide");
await emitGuideEvents(tx, guide.id, guide.version);
return guide;
});
}
export async function permanentlyDeleteGuide(guideId: string) {
return getDb().transaction(async (tx) => {
const [guide] = await tx.select().from(guides).where(eq(guides.id, guideId)).limit(1);
if (!guide?.trashedAt) throw new Error("Guide ต้องอยู่ในถังขยะก่อนลบถาวร");
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 emitGuideEvents(tx, guide.id, guide.version + 1);
return { version: guide.version + 1 };
});
}
export async function enqueueCatalogSync(userId: string) {
const [running] = await getDb().select().from(catalogSyncJobs).where(sql`${catalogSyncJobs.status} in ('pending', 'processing')`).limit(1);
if (running) return running;
const [job] = await getDb().insert(catalogSyncJobs).values({ triggeredById: userId }).returning();
return job;
}
+109
View File
@@ -0,0 +1,109 @@
import "server-only";
import { and, asc, desc, eq, inArray, isNotNull, isNull } from "drizzle-orm";
import { getDb } from "@/db";
import {
artifactGroups,
artifactProfiles,
artifactRecommendations,
catalogArtifacts,
catalogCharacters,
catalogSyncJobs,
catalogVersions,
catalogWeapons,
constellationRecommendations,
extraSectionRows,
guides,
guideSections,
media,
teamCompositions,
teamMembers,
weaponRecommendations,
} from "@/db/schema";
import { publicMediaUrl } from "@/lib/media/storage";
export async function getActiveCatalog() {
const db = getDb();
const [version] = await db.select().from(catalogVersions).where(eq(catalogVersions.active, true)).limit(1);
if (!version) return null;
const [characters, weapons, artifacts] = await Promise.all([
db.select().from(catalogCharacters).where(eq(catalogCharacters.version, version.version)).orderBy(asc(catalogCharacters.name)),
db.select().from(catalogWeapons).where(eq(catalogWeapons.version, version.version)).orderBy(asc(catalogWeapons.name)),
db.select().from(catalogArtifacts).where(eq(catalogArtifacts.version, version.version)).orderBy(asc(catalogArtifacts.name)),
]);
return {
version: version.version,
characters: characters.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey) })),
weapons: weapons.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey) })),
artifacts: artifacts.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey) })),
};
}
export async function listGuideCards(includeTrash = false) {
const rows = await getDb()
.select({ guide: guides, cover: media })
.from(guides)
.innerJoin(media, eq(media.id, guides.coverMediaId))
.where(includeTrash ? isNotNull(guides.trashedAt) : isNull(guides.trashedAt))
.orderBy(asc(guides.sortOrder), asc(guides.name));
return rows.map(({ guide, cover }) => ({ ...guide, coverUrl: publicMediaUrl(cover.objectKey) }));
}
export async function getGuideByCharacterKey(characterKey: string) {
const [row] = await getDb()
.select({ guide: guides, cover: media })
.from(guides)
.innerJoin(media, eq(media.id, guides.coverMediaId))
.where(and(eq(guides.characterKey, characterKey), isNull(guides.trashedAt)))
.limit(1);
if (!row) return null;
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 };
}
export async function getPublicGuide(slug: string) {
const [row] = await getDb()
.select({ guide: guides, cover: media, character: catalogCharacters })
.from(guides)
.innerJoin(media, eq(media.id, guides.coverMediaId))
.innerJoin(catalogCharacters, and(eq(catalogCharacters.version, guides.catalogVersion), eq(catalogCharacters.key, guides.characterKey)))
.where(and(eq(guides.slug, slug), isNull(guides.trashedAt)))
.limit(1);
if (!row) return null;
const [sections, weaponRows, groups, profile, constellations, teams] = 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({ recommendation: weaponRecommendations, weapon: catalogWeapons }).from(weaponRecommendations).innerJoin(catalogWeapons, and(eq(catalogWeapons.version, weaponRecommendations.catalogVersion), eq(catalogWeapons.key, weaponRecommendations.weaponKey))).where(eq(weaponRecommendations.guideId, row.guide.id)).orderBy(asc(weaponRecommendations.sortOrder)),
getDb().select().from(artifactGroups).where(eq(artifactGroups.guideId, row.guide.id)).orderBy(asc(artifactGroups.sortOrder)),
getDb().select().from(artifactProfiles).where(eq(artifactProfiles.guideId, row.guide.id)).limit(1),
getDb().select().from(constellationRecommendations).where(eq(constellationRecommendations.guideId, row.guide.id)).orderBy(asc(constellationRecommendations.level)),
getDb().select().from(teamCompositions).where(eq(teamCompositions.guideId, row.guide.id)).orderBy(asc(teamCompositions.sortOrder)),
]);
const artifactRows = groups.length ? await getDb().select({ recommendation: artifactRecommendations, artifact: catalogArtifacts }).from(artifactRecommendations).innerJoin(catalogArtifacts, and(eq(catalogArtifacts.version, artifactRecommendations.catalogVersion), eq(catalogArtifacts.key, artifactRecommendations.firstArtifactKey))).where(inArray(artifactRecommendations.groupId, groups.map((group) => group.id))).orderBy(asc(artifactRecommendations.sortOrder)) : [];
const secondKeys = artifactRows.map(({ recommendation }) => recommendation.secondArtifactKey).filter((key): key is string => Boolean(key));
const secondArtifacts = secondKeys.length ? await getDb().select().from(catalogArtifacts).where(and(eq(catalogArtifacts.version, row.guide.catalogVersion), inArray(catalogArtifacts.key, secondKeys))) : [];
const members = teams.length ? await getDb().select({ member: teamMembers, character: catalogCharacters }).from(teamMembers).innerJoin(catalogCharacters, and(eq(catalogCharacters.version, teamMembers.catalogVersion), eq(catalogCharacters.key, teamMembers.characterKey))).where(inArray(teamMembers.teamId, teams.map((team) => team.id))).orderBy(asc(teamMembers.position)) : [];
const extraIds = sections.filter((section) => section.kind === "extra").map((section) => section.id);
const extraRows = extraIds.length ? await getDb().select({ row: extraSectionRows, asset: media }).from(extraSectionRows).leftJoin(media, eq(media.id, extraSectionRows.mediaId)).where(inArray(extraSectionRows.sectionId, extraIds)).orderBy(asc(extraSectionRows.sortOrder)) : [];
return {
...row.guide,
coverUrl: publicMediaUrl(row.cover.objectKey),
character: { ...row.character, imageUrl: publicMediaUrl(row.character.imageKey) },
sections,
weapons: weaponRows.map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } })),
artifactGroups: groups.map((group) => ({ ...group, rows: artifactRows.filter(({ recommendation }) => recommendation.groupId === group.id).map(({ recommendation, artifact }) => { const second = secondArtifacts.find((item) => item.key === recommendation.secondArtifactKey); return { ...recommendation, first: { ...artifact, imageUrl: publicMediaUrl(artifact.imageKey) }, second: second ? { ...second, imageUrl: publicMediaUrl(second.imageKey) } : null }; }) })),
artifactProfile: profile[0] ?? null,
constellations,
teams: teams.map((team) => ({ ...team, members: members.filter(({ member }) => member.teamId === team.id).map(({ member, character }) => ({ ...member, character: { ...character, imageUrl: publicMediaUrl(character.imageKey) } })) })),
extras: sections.filter((section) => section.kind === "extra").map((section) => ({ ...section, rows: extraRows.filter(({ row: extraRow }) => extraRow.sectionId === section.id).map(({ row: extraRow, asset }) => ({ ...extraRow, imageUrl: asset ? publicMediaUrl(asset.objectKey) : null })) })),
};
}
export async function getLatestCatalogSyncJob() {
const [job] = await getDb().select().from(catalogSyncJobs).orderBy(desc(catalogSyncJobs.createdAt)).limit(1);
return job ?? null;
}
export type PublicGuide = NonNullable<Awaited<ReturnType<typeof getPublicGuide>>>;
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { calculateTeamMetrics } from "@/lib/guides/calculations";
import { artifactSectionSchema, teamSectionSchema, weaponSectionSchema } from "@/lib/guides/schemas";
describe("structured guide validation", () => {
it("requires at least one weapon percentage", () => {
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, rows: [{ weaponKey: "1", refinement: 5, overallPercent: null, personalPercent: null }] }).success).toBe(false);
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, rows: [{ weaponKey: "1", refinement: 5, overallPercent: 104, personalPercent: null }] }).success).toBe(true);
});
it("limits artifact main stats to two per slot", () => {
const result = artifactSectionSchema.safeParse({ expectedVersion: 1, groups: [], sands: ["EM", "ER", "ATK%"], goblet: [], circlet: [], substats: [], targets: [], talents: [], note: null });
expect(result.success).toBe(false);
});
it("requires four unique team members and non-zero damage", () => {
const base = { expectedVersion: 1, teams: [{ title: "Test", rotationSeconds: 20, combo: "E > Q", members: ["1", "2", "3", "4"].map((characterKey) => ({ characterKey, damage: 100 })) }] };
expect(teamSectionSchema.safeParse(base).success).toBe(true);
expect(teamSectionSchema.safeParse({ ...base, teams: [{ ...base.teams[0], members: ["1", "1", "3", "4"].map((characterKey) => ({ characterKey, damage: 100 })) }] }).success).toBe(false);
});
});
describe("Team Calc", () => {
it("calculates exact shares and total DPS", () => {
expect(calculateTeamMetrics([50, 25, 15, 10], 20)).toEqual({ totalDamage: 100, totalDps: 5, shares: [50, 25, 15, 10] });
});
it("rejects an empty damage rotation", () => {
expect(() => calculateTeamMetrics([0, 0, 0, 0], 20)).toThrow();
});
});
+114
View File
@@ -0,0 +1,114 @@
import * as z from "zod";
export const guideSectionKinds = ["weapon", "artifact", "constellations", "team"] as const;
export type GuideSectionKind = (typeof guideSectionKinds)[number];
const percent = z.coerce.number().finite().min(0).max(9999);
const catalogKey = z.string().trim().min(1).max(64);
export const createGuideSchema = z.strictObject({
characterKey: catalogKey,
coverMediaId: z.string().uuid(),
overview: z.string().trim().min(1).max(500),
});
export const updateGuideSchema = z.strictObject({
expectedVersion: z.number().int().positive(),
coverMediaId: z.string().uuid(),
overview: z.string().trim().min(1).max(500),
});
export const weaponSectionSchema = z.strictObject({
expectedVersion: z.number().int().positive(),
rows: z.array(z.strictObject({
weaponKey: catalogKey,
refinement: z.number().int().min(1).max(5),
overallPercent: percent.nullable(),
personalPercent: percent.nullable(),
}).refine((row) => row.overallPercent !== null || row.personalPercent !== null, {
message: "อาวุธต้องมีเปอร์เซ็นต์อย่างน้อยหนึ่งค่า",
})).max(100),
});
export const mainStatOptions = {
sands: ["EM", "ER", "ATK%", "HP%", "DEF%"],
goblet: ["EM", "ATK%", "DEF%", "HP%", "Pyro DMG%", "Hydro DMG%", "Cryo DMG%", "Electro DMG%", "Anemo DMG%", "Geo DMG%", "Dendro DMG%", "Physical DMG%"],
circlet: ["HP%", "ATK%", "DEF%", "EM", "CRIT Rate%/CRIT DMG%", "Healing Bonus%"],
} as const;
export const substatOptions = ["CRIT Rate%/CRIT DMG%", "HP%", "ATK%", "DEF%", "EM", "ER"] as const;
export const artifactSectionSchema = z.strictObject({
expectedVersion: z.number().int().positive(),
groups: z.array(z.strictObject({
name: z.string().trim().min(1).max(120),
rows: z.array(z.strictObject({
firstArtifactKey: catalogKey,
secondArtifactKey: catalogKey.nullable(),
percent,
})).max(50),
})).max(12),
sands: z.array(z.enum(mainStatOptions.sands)).max(2),
goblet: z.array(z.enum(mainStatOptions.goblet)).max(2),
circlet: z.array(z.enum(mainStatOptions.circlet)).max(2),
substats: z.array(z.enum(substatOptions)).max(substatOptions.length),
targets: z.array(z.strictObject({
stat: z.string().trim().min(1).max(80),
value: z.string().trim().min(1).max(80),
})).max(20),
talents: z.array(z.strictObject({
type: z.enum(["NA", "E", "Q"]),
priority: z.number().int().min(1).max(3),
level: z.string().trim().max(24).optional(),
})).max(3),
note: z.string().trim().max(2_000).nullable(),
});
export const constellationSectionSchema = z.strictObject({
expectedVersion: z.number().int().positive(),
rows: z.array(z.strictObject({
level: z.number().int().min(1).max(6),
detail: z.string().trim().min(1).max(2_000),
cumulativePercent: percent,
})).max(6),
});
export const teamSectionSchema = z.strictObject({
expectedVersion: z.number().int().positive(),
teams: z.array(z.strictObject({
title: z.string().trim().min(1).max(160),
rotationSeconds: z.coerce.number().finite().positive().max(9999),
combo: z.string().trim().min(1).max(2_000),
members: z.array(z.strictObject({
characterKey: catalogKey,
damage: z.coerce.number().finite().nonnegative().max(999_999_999_999),
})).length(4).refine((members) => new Set(members.map((member) => member.characterKey)).size === 4, {
message: "Team ต้องมีตัวละครไม่ซ้ำกัน 4 ตัว",
}).refine((members) => members.some((member) => member.damage > 0), {
message: "ความเสียหายรวมต้องมากกว่า 0",
}),
})).max(30),
});
export const extraSectionSchema = z.strictObject({
expectedVersion: z.number().int().positive(),
sections: z.array(z.strictObject({
title: z.string().trim().min(1).max(160),
rows: z.array(z.strictObject({
mediaId: z.string().uuid().nullable(),
heading: z.string().trim().min(1).max(200),
body: z.string().trim().max(2_000),
percent: percent.nullable(),
})).max(50),
})).max(20),
});
export type WeaponSectionInput = z.output<typeof weaponSectionSchema>;
export type ArtifactSectionInput = z.output<typeof artifactSectionSchema>;
export type ConstellationSectionInput = z.output<typeof constellationSectionSchema>;
export type TeamSectionInput = z.output<typeof teamSectionSchema>;
export function slugFromCharacterName(name: string, key: string): string {
const slug = name.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-+|-+$/gu, "");
return slug || `character-${key.toLowerCase()}`;
}
+46 -2
View File
@@ -1,13 +1,14 @@
import "server-only";
import { and, eq } from "drizzle-orm";
import { and, eq, sql } from "drizzle-orm";
import { getDb } from "@/db";
import { media } from "@/db/schema";
import { getMediaStorage } from "@/lib/media/storage";
import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage";
import {
hasValidImageSignature,
MAX_MEDIA_BYTES,
mediaUploadSchema,
safeObjectFileName,
type MediaUploadInput,
} from "@/lib/media/validation";
@@ -37,6 +38,49 @@ export async function createPendingMedia(
return { id, objectKey, uploadUrl, expiresIn };
}
export async function uploadPublicMedia(
file: File,
authorId: string,
): Promise<{ id: string; url: string; fileName: string }> {
const parsed = mediaUploadSchema.parse({
fileName: file.name,
mimeType: file.type,
byteSize: file.size,
});
const bytes = new Uint8Array(await file.arrayBuffer());
if (!hasValidImageSignature(bytes.subarray(0, 16), parsed.mimeType)) {
throw new Error("ไฟล์ไม่ตรงกับชนิดรูปภาพที่ระบุ");
}
const id = crypto.randomUUID();
const objectKey = `uploads/${id}/${safeObjectFileName(parsed.fileName)}`;
const storage = await getMediaStorage();
await storage.write(objectKey, bytes, { type: parsed.mimeType, acl: "public-read" });
try {
await getDb().insert(media).values({
id,
objectKey,
fileName: parsed.fileName,
mimeType: parsed.mimeType,
byteSize: parsed.byteSize,
status: "ready",
currentReferenceCount: 0,
createdById: authorId,
});
} catch (cause) {
await storage.delete(objectKey).catch(() => undefined);
throw cause;
}
return { id, url: publicMediaUrl(objectKey), fileName: parsed.fileName };
}
export async function addCurrentMediaReference(mediaId: string): Promise<void> {
await getDb()
.update(media)
.set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` })
.where(eq(media.id, mediaId));
}
export async function completePendingMedia(mediaId: string) {
const [record] = await getDb()
.select()
+5 -2
View File
@@ -1,5 +1,3 @@
import "server-only";
import type { S3Client } from "bun";
let client: S3Client | undefined;
@@ -26,3 +24,8 @@ export async function getMediaStorage(): Promise<S3Client> {
client = await loading;
return client;
}
export function publicMediaUrl(objectKey: string): string {
const base = required("S3_PUBLIC_URL").replace(/\/+$/u, "");
return `${base}/${objectKey.split("/").map(encodeURIComponent).join("/")}`;
}
+5
View File
@@ -8,6 +8,11 @@ const nextConfig: NextConfig = {
},
deploymentId: process.env.NEXT_DEPLOYMENT_ID,
output: "standalone",
images: {
remotePatterns: [
{ protocol: "https", hostname: "buzz-cdn.astrxl.dev", pathname: "/**" },
],
},
experimental: {
serverActions: {
bodySizeLimit: "5mb",
+10
View File
@@ -9,6 +9,7 @@ import {
processOutboxEvent,
} from "@/lib/outbox/processor";
import { closeRedisClient, getRedisClient } from "@/lib/redis/client";
import { claimCatalogSyncJob, processCatalogSyncJob } from "@/lib/catalog/sync";
let stopping = false;
const stop = () => {
@@ -40,6 +41,15 @@ async function run(): Promise<void> {
const leaseMs = positiveInteger("OUTBOX_LEASE_MS", 30_000, 300_000);
while (!stopping) {
const catalogJob = await claimCatalogSyncJob();
if (catalogJob) {
try {
await processCatalogSyncJob(catalogJob.id);
} catch (cause) {
console.error("Catalog sync failed", cause);
}
continue;
}
const events = await claimOutboxEvents(batchSize, leaseMs);
if (events.length === 0) {
await wait(pollMs);