เลือกส่วนที่ต้องการแก้ไข
diff --git a/.env.example b/.env.example
index 7177f8c..e7a7452 100644
--- a/.env.example
+++ b/.env.example
@@ -16,6 +16,7 @@ BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000
REDIS_URL=rediss://default:replace-me@redis.example.internal:6379
REDIS_CACHE_PREFIX=buzz:next-cache
REDIS_EVENT_PREFIX=buzz:events
+REDIS_PRESENCE_PREFIX=buzz:presence
REDIS_CACHE_MAX_ENTRY_BYTES=5242880
OUTBOX_BATCH_SIZE=20
OUTBOX_POLL_MS=1000
diff --git a/app/admin/[character]/[page]/page.tsx b/app/admin/[character]/[page]/page.tsx
index 8d8f841..5b58451 100644
--- a/app/admin/[character]/[page]/page.tsx
+++ b/app/admin/[character]/[page]/page.tsx
@@ -1,4 +1,5 @@
import Link from "next/link";
+import Image from "next/image";
import { ArrowLeftIcon } from "lucide-react";
import { notFound, redirect } from "next/navigation";
import { connection } from "next/server";
@@ -6,6 +7,7 @@ import { connection } from "next/server";
import { GuideSectionEditor } from "@/components/admin/guide-section-editor";
import { GuideSectionNav } from "@/components/admin/guide-section-nav";
import { AdminHeader } from "@/components/admin/admin-header";
+import { EditorPresence } from "@/components/admin/editor-presence";
import { Button } from "@/components/ui/button";
import { getAdminSession } from "@/lib/auth/server";
import {
@@ -55,12 +57,16 @@ export default async function StructuredEditorPage({
>
{guide.name}
เลือกส่วนที่ต้องการแก้ไข
เลือกส่วนที่ต้องการแก้ไข
Search shortcuts
+legacy
text
safe
"); + expect(normalizeExtraSheetRichText(unsafe).rows[0].cells[columnId]).toBe( + "safe
", + ); + }); }); diff --git a/lib/guides/extra-sheet.ts b/lib/guides/extra-sheet.ts index f782a27..2f06559 100644 --- a/lib/guides/extra-sheet.ts +++ b/lib/guides/extra-sheet.ts @@ -1,5 +1,7 @@ import * as z from "zod"; +import { normalizeExtraRichText } from "@/lib/rich-text"; + const extraSheetId = z.string().min(1).max(128); export const extraSheetSchema = z.strictObject({ @@ -158,3 +160,33 @@ export function deleteExtraSheetRow(sheet: ExtraSheet, index: number): ExtraShee if (sheet.rows.length === 1) return sheet; return { ...sheet, rows: sheet.rows.filter((_, rowIndex) => rowIndex !== index) }; } + +export function updateExtraSheetCell( + sheet: ExtraSheet, + rowIndex: number, + columnIndex: number, + value: string, +): ExtraSheet { + const row = sheet.rows[rowIndex]; + const column = sheet.columns[columnIndex]; + if (!row || !column) return sheet; + return { + ...sheet, + rows: sheet.rows.map((candidate, index) => index === rowIndex + ? { ...candidate, cells: { ...candidate.cells, [column.id]: value } } + : candidate), + }; +} + +export function normalizeExtraSheetRichText(sheet: ExtraSheet): ExtraSheet { + return { + ...sheet, + rows: sheet.rows.map((row) => ({ + ...row, + cells: Object.fromEntries(Object.entries(row.cells).map(([columnId, value]) => [ + columnId, + normalizeExtraRichText(value), + ])), + })), + }; +} diff --git a/lib/guides/mutations.ts b/lib/guides/mutations.ts index a71c6fc..ca3d2f7 100644 --- a/lib/guides/mutations.ts +++ b/lib/guides/mutations.ts @@ -110,7 +110,7 @@ export async function saveWeapons(guideId: string, input: unknown) { await tx.delete(weaponConditions).where(eq(weaponConditions.guideId, guideId)); for (const [sortOrder, group] of data.groups.entries()) { const [created] = await tx.insert(weaponConditions).values({ guideId, note: group.note, sortOrder }).returning(); - if (group.rows.length) await tx.insert(weaponRecommendations).values(group.rows.map((row, rowOrder) => ({ ...row, overallPercent: row.overallPercent?.toString() ?? null, personalPercent: row.personalPercent?.toString() ?? null, conditionId: created.id, sortOrder: rowOrder }))); + if (group.rows.length) await tx.insert(weaponRecommendations).values(group.rows.map((row, rowOrder) => ({ ...row, refinement: row.refinement, overallPercent: row.overallPercent?.toString() ?? null, personalPercent: row.personalPercent?.toString() ?? null, conditionId: created.id, sortOrder: rowOrder }))); } return updated; }); diff --git a/lib/guides/queries.ts b/lib/guides/queries.ts index 706d9eb..d9f593c 100644 --- a/lib/guides/queries.ts +++ b/lib/guides/queries.ts @@ -16,6 +16,7 @@ import { extraSectionRows, guides, guideSections, + glossaryAliases, media, teamCompositions, teamMembers, @@ -23,6 +24,7 @@ import { weaponConditions, } from "@/db/schema"; import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage"; +import { aliasesByItem } from "@/lib/glossary"; const catalogVersionSchema = z.object({ version: z.string() }); @@ -46,21 +48,23 @@ function catalogCharacterWithUrls(item: typeof catalogCharacters.$inferSelect) { export async function getActiveCatalog() { const db = getDb(); - const [characters, weapons, artifacts] = await Promise.all([ + const [characters, weapons, artifacts, aliases] = await Promise.all([ db.select().from(catalogCharacters).orderBy(asc(catalogCharacters.name)), db.select().from(catalogWeapons).orderBy(asc(catalogWeapons.name)), db.select().from(catalogArtifacts).orderBy(asc(catalogArtifacts.name)), + db.select().from(glossaryAliases), ]); if (!characters.length) return null; + const groupedAliases = aliasesByItem(aliases); const storage = await getMediaStorage(); const { version } = catalogVersionSchema.parse( await storage.file("catalog/current.json").json(), ); return { version, - characters: characters.map(catalogCharacterWithUrls), - weapons: weapons.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey) })), - artifacts: artifacts.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey) })), + characters: characters.map((item) => ({ ...catalogCharacterWithUrls(item), aliases: groupedAliases.character.get(item.key) ?? [] })), + weapons: weapons.map((item) => ({ ...item, aliases: groupedAliases.weapon.get(item.key) ?? [], imageUrl: publicMediaUrl(item.imageKey) })), + artifacts: artifacts.map((item) => ({ ...item, aliases: groupedAliases.artifact.get(item.key) ?? [], imageUrl: publicMediaUrl(item.imageKey) })), }; } @@ -75,13 +79,14 @@ export async function listGuideCards(includeTrash = false) { } export async function listPublicGuideCards() { - const rows = await getDb() + const [rows, aliases] = await Promise.all([getDb() .select({ guide: guides, cover: media }) .from(guides) .innerJoin(media, eq(media.id, guides.coverMediaId)) .where(and(eq(guides.isPublic, true), isNull(guides.trashedAt))) - .orderBy(asc(guides.sortOrder), asc(guides.name)); - return rows.map(({ guide, cover }) => ({ ...guide, coverUrl: publicMediaUrl(cover.objectKey) })); + .orderBy(asc(guides.sortOrder), asc(guides.name)), getDb().select().from(glossaryAliases)]); + const grouped = aliasesByItem(aliases); + return rows.map(({ guide, cover }) => ({ ...guide, aliases: grouped.character.get(guide.characterKey) ?? [], coverUrl: publicMediaUrl(cover.objectKey) })); } export async function getGuideByCharacterKey(characterKey: string) { @@ -109,14 +114,16 @@ async function getGuideContent(slug: string, publicOnly: boolean) { )) .limit(1); if (!row) return null; - const [sections, weaponConditionsRows, groups, profile, constellationGroups, teams] = await Promise.all([ + const [sections, weaponConditionsRows, groups, profile, constellationGroups, teams, aliases] = await Promise.all([ getDb().select().from(guideSections).where(and(eq(guideSections.guideId, row.guide.id), eq(guideSections.enabled, true))).orderBy(asc(guideSections.sortOrder)), getDb().select().from(weaponConditions).where(eq(weaponConditions.guideId, row.guide.id)).orderBy(asc(weaponConditions.sortOrder)), getDb().select().from(artifactGroups).where(eq(artifactGroups.guideId, row.guide.id)).orderBy(asc(artifactGroups.sortOrder)), getDb().select().from(artifactProfiles).where(eq(artifactProfiles.guideId, row.guide.id)).limit(1), getDb().select().from(constellationConditions).where(eq(constellationConditions.guideId, row.guide.id)).orderBy(asc(constellationConditions.sortOrder)), getDb().select().from(teamCompositions).where(eq(teamCompositions.guideId, row.guide.id)).orderBy(asc(teamCompositions.sortOrder)), + getDb().select().from(glossaryAliases), ]); + const groupedAliases = aliasesByItem(aliases); const elementIconUrls = Object.fromEntries( (await getDb() @@ -161,11 +168,11 @@ async function getGuideContent(slug: string, publicOnly: boolean) { return { ...row.guide, coverUrl: publicMediaUrl(row.cover.objectKey), - character: catalogCharacterWithUrls(row.character), + character: { ...catalogCharacterWithUrls(row.character), aliases: groupedAliases.character.get(row.character.key) ?? [] }, elementIconUrls, sections, - weapons: weaponRows.map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } })), - weaponGroups: weaponConditionsRows.map((condition) => ({ ...condition, rows: weaponRows.filter(({ recommendation }) => recommendation.conditionId === condition.id).map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } })) })), + weapons: weaponRows.map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } })), + weaponGroups: weaponConditionsRows.map((condition) => ({ ...condition, rows: weaponRows.filter(({ recommendation }) => recommendation.conditionId === condition.id).map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], 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, constellationGroups: constellationGroups.map((group) => ({ @@ -180,7 +187,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) { }; }), })), - teams: teams.map((team) => ({ ...team, members: members.filter(({ member }) => member.teamId === team.id).map(({ member, character }) => { const weapon = memberWeapons.find((item) => item.key === member.weaponKey); const artifact = memberArtifacts.find((item) => item.key === member.artifactKey); const secondArtifact = memberArtifacts.find((item) => item.key === member.secondArtifactKey); return { ...member, character: catalogCharacterWithUrls(character), weapon: weapon ? { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } : null, artifact: artifact ? { ...artifact, imageUrl: publicMediaUrl(artifact.imageKey) } : null, secondArtifact: secondArtifact ? { ...secondArtifact, imageUrl: publicMediaUrl(secondArtifact.imageKey) } : null }; }) })), + teams: teams.map((team) => ({ ...team, members: members.filter(({ member }) => member.teamId === team.id).map(({ member, character }) => { const weapon = memberWeapons.find((item) => item.key === member.weaponKey); const artifact = memberArtifacts.find((item) => item.key === member.artifactKey); const secondArtifact = memberArtifacts.find((item) => item.key === member.secondArtifactKey); return { ...member, character: { ...catalogCharacterWithUrls(character), aliases: groupedAliases.character.get(character.key) ?? [] }, weapon: weapon ? { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } : null, artifact: artifact ? { ...artifact, aliases: groupedAliases.artifact.get(artifact.key) ?? [], imageUrl: publicMediaUrl(artifact.imageKey) } : null, secondArtifact: secondArtifact ? { ...secondArtifact, aliases: groupedAliases.artifact.get(secondArtifact.key) ?? [], imageUrl: publicMediaUrl(secondArtifact.imageKey) } : null }; }) })), extras: sections.filter((section) => section.kind === "extra").map((section) => ({ ...section, rows: extraRows.filter(({ row: extraRow }) => extraRow.sectionId === section.id).map(({ row: extraRow, asset }) => ({ ...extraRow, catalogItems: extraRow.catalogItems.map((reference) => { diff --git a/lib/guides/schemas.test.ts b/lib/guides/schemas.test.ts index 8dbfb51..84f705c 100644 --- a/lib/guides/schemas.test.ts +++ b/lib/guides/schemas.test.ts @@ -35,6 +35,12 @@ describe("structured guide validation", () => { expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [{ weaponKey: "1", refinement: 5, overallPercent: 104, personalPercent: null, note: "Best free-to-play option" }] }] }).success).toBe(true); }); + it("supports an all-refinements weapon range", () => { + const result = weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [{ weaponKey: "1", refinement: "1-5", overallPercent: 100, personalPercent: null, note: null }] }] }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.groups[0].rows[0].refinement).toBe("1-5"); + }); + it("preserves negative weapon percentages", () => { const result = weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [{ weaponKey: "1", refinement: 5, overallPercent: -12.5, personalPercent: null, note: null }] }] }); expect(result.success).toBe(true); @@ -229,7 +235,7 @@ describe("structured guide validation", () => { }); expect(result.success).toBe(true); if (result.success) { - expect(result.data.sections[0].rows[0].sheet?.rows[0].cells[sheet.columns[0].id]).toBe("=1+1"); + expect(result.data.sections[0].rows[0].sheet?.rows[0].cells[sheet.columns[0].id]).toBe("=1+1
"); } const invalidSheet = structuredClone(sheet); diff --git a/lib/guides/schemas.ts b/lib/guides/schemas.ts index c01e76b..c0666a2 100644 --- a/lib/guides/schemas.ts +++ b/lib/guides/schemas.ts @@ -2,7 +2,7 @@ import * as z from "zod"; import { targetStatOptions } from "@/lib/guides/stats"; import { parseYouTubeUrl } from "@/lib/guides/youtube"; -import { extraSheetSchema } from "@/lib/guides/extra-sheet"; +import { extraSheetSchema, normalizeExtraSheetRichText } from "@/lib/guides/extra-sheet"; import { normalizeExtraRichText } from "@/lib/rich-text"; export const guideSectionKinds = ["weapon", "artifact", "constellations", "team"] as const; @@ -10,6 +10,11 @@ export type GuideSectionKind = (typeof guideSectionKinds)[number]; const percent = z.coerce.number().finite().min(-9999).max(9999); const catalogKey = z.string().trim().min(1).max(64); +export const weaponRefinementSchema = z.union([ + z.number().int().min(1).max(5).transform(String), + z.enum(["1", "2", "3", "4", "5", "1-5"]), +]); +export type WeaponRefinement = z.output