From 4f3929ece6d5ba26310495be5831a662efed6253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=85=B5=E6=87=92=E5=8D=88=E7=9D=A1?= <2155762216@qq.com> Date: Thu, 5 Nov 2026 22:28:32 +0800 Subject: [PATCH 01/14] feat(story): browse main and event stories as chapter cards The main and event lists showed every chapter as a full-width banner with its episodes, so a long event list took many screens to scan. They now show one compact card per chapter, and the chapter's existing view moved to its own page. - add /story/:category/:id (category = main | event) with breadcrumbs, entity SEO, sitemap entries and a markdown representation - event cards show the event's end date: normalizeStories fills endAt from MasterEvent, so the status badge reads ongoing/ended instead of permanent - chapter summaries aggregate the earliest start, the main cast (another episodes no longer replace it), the episode count and the total length - the event filter searches and sorts chapters --- src/app/[[...segments]]/page.tsx | 1 + src/components/story/ChapterCard.tsx | 58 +++++++++++ src/components/story/ChapterDetail.tsx | 69 ++++++++++++ src/components/story/StoryChapterPage.tsx | 35 +++++++ src/components/story/StoryExplorer.tsx | 87 ++++++---------- src/config/routes.ts | 19 ++++ src/i18n/messages/en-US.ts | 5 + src/i18n/messages/ja-JP.ts | 5 + src/i18n/messages/ko-KR.ts | 5 + src/i18n/messages/zh-CN.ts | 5 + src/i18n/messages/zh-TW.ts | 5 + src/lib/markdown/render.ts | 37 +++++++ src/lib/masterdata/build-data.ts | 2 +- src/lib/route/masterdata-params.ts | 14 +++ src/lib/route/static-paths.ts | 11 ++ src/lib/schedule/index.ts | 10 ++ src/lib/seo/entity-seo.ts | 54 ++++++++++ src/lib/story/chapters.ts | 121 ++++++++++++++++++++++ src/lib/story/data.ts | 10 +- src/lib/story/paths.ts | 27 +++++ src/lib/story/sections.ts | 9 ++ src/server-pages/DetailServerPages.tsx | 22 ++++ src/server-pages/RouteDispatcher.tsx | 7 ++ src/types/route.ts | 2 +- tests/masterdata-params.test.ts | 4 +- tests/story-chapters.test.ts | 85 +++++++++++++++ tests/story-data.test.ts | 9 ++ 27 files changed, 652 insertions(+), 66 deletions(-) create mode 100644 src/components/story/ChapterCard.tsx create mode 100644 src/components/story/ChapterDetail.tsx create mode 100644 src/components/story/StoryChapterPage.tsx create mode 100644 src/lib/story/chapters.ts create mode 100644 tests/story-chapters.test.ts diff --git a/src/app/[[...segments]]/page.tsx b/src/app/[[...segments]]/page.tsx index 43d5fd10..c27564ee 100644 --- a/src/app/[[...segments]]/page.tsx +++ b/src/app/[[...segments]]/page.tsx @@ -29,6 +29,7 @@ const ENTITY_DETAIL_COMPONENTS = new Set([ "event-detail", "gacha-detail", "character-detail", + "story-chapter", "story-detail", "item-detail", "exchange-detail", diff --git a/src/components/story/ChapterCard.tsx b/src/components/story/ChapterCard.tsx new file mode 100644 index 00000000..4fba1781 --- /dev/null +++ b/src/components/story/ChapterCard.tsx @@ -0,0 +1,58 @@ +"use client"; + +import type { AppLocale } from "@/config/locales"; +import { t } from "@/i18n"; +import { localizePath } from "@/i18n/routing"; +import { getAssetUrl } from "@/lib/assets/url"; +import { formatScheduleDayRange } from "@/lib/schedule"; +import { useDisplayTimeZone } from "@/lib/schedule/use-display-time-zone"; +import { useNow } from "@/lib/schedule/use-now"; +import type { StoryChapterSummary } from "@/lib/story/chapters"; +import { storyChapterPath } from "@/lib/story/paths"; +import { storyCategoryKey, type StoryChapterSection } from "@/lib/story/sections"; +import BannerImage from "@/components/shared/BannerImage"; +import ScheduleBadge from "@/components/shared/ScheduleBadge"; + +/** + * A chapter of the main or event story as a browse card: its banner with the category and (for an event) the + * schedule's status, then the chapter's number, group, length and dates. + */ +export default function ChapterCard({ chapter, locale, section }: { chapter: StoryChapterSummary; locale: AppLocale; section: StoryChapterSection }) { + const now = useNow(); + const timeZone = useDisplayTimeZone(); + const art = chapter.banner || chapter.image + ? getAssetUrl({ path: `Story/${chapter.banner ? "Banner" : "Image"}/Chapter/${chapter.banner || chapter.image}.png`, type: "raw", locale }) + : ""; + const dates = section === "event" ? formatScheduleDayRange(chapter.startAt, chapter.endAt, locale, timeZone) : ""; + + return ( + +
+ +
+ + {t(locale, storyCategoryKey(chapter.category))} + +
+ {section === "event" && ( +
+ +
+ )} +
+
+
+ {chapter.eventId !== null && #{chapter.eventId}} + {chapter.bandName && {chapter.bandName}} + {t(locale, "story.ui.episodeCount", { count: chapter.episodeCount })} +
+

{chapter.name}

+ {dates &&

{dates}

} +
+
+ ); +} diff --git a/src/components/story/ChapterDetail.tsx b/src/components/story/ChapterDetail.tsx new file mode 100644 index 00000000..4b33723a --- /dev/null +++ b/src/components/story/ChapterDetail.tsx @@ -0,0 +1,69 @@ +"use client"; + +import type { AppLocale } from "@/config/locales"; +import { t } from "@/i18n"; +import { localizePath } from "@/i18n/routing"; +import { getAssetUrl } from "@/lib/assets/url"; +import { useAssetUrl } from "@/lib/servers/use-content-server"; +import type { StoryChapterSummary } from "@/lib/story/chapters"; +import { formatPlayTime, storyEpisodeLabel } from "@/lib/story/labels"; +import { storyPath } from "@/lib/story/paths"; +import BannerImage from "@/components/shared/BannerImage"; + +/** + * One chapter as its own page: the banner, the chapter's copy and its main/another/extra episode runs. This is the + * view the story list used to show inline, before its cards opened a chapter page. + */ +export default function ChapterDetail({ chapter, locale }: { chapter: StoryChapterSummary; locale: AppLocale }) { + const assetUrl = useAssetUrl(); + const chapterArt = chapter.banner || chapter.image + ? getAssetUrl({ path: `Story/${chapter.banner ? "Banner" : "Image"}/Chapter/${chapter.banner || chapter.image}.png`, type: "raw", locale }) + : ""; + + return ( +
+
+ {chapterArt && } +
+

{chapter.bandName}

+

{chapter.name}

+ {chapter.description &&

{chapter.description}

} + {chapter.characterNames.length > 0 &&

{chapter.characterNames.join(" · ")}

} +

{t(locale, "story.ui.episodeCount", { count: chapter.episodeCount })}

+
+
+
+ {chapter.runs.map((run) => ( +
+ {run.kind !== "main" &&

{t(locale, run.kind === "another" ? "story.ui.anotherStories" : "story.ui.extraStories")}

} + {/* Columns as wide as a banner and a title need, however narrow the content column is. */} +
+ {run.episodes.map((episode) => { + const banner = episode.assets.banner + ? getAssetUrl({ path: `Story/Banner/Episode/${episode.assets.banner}.png`, type: "raw", locale }) + : ""; + return ( + + {banner ? ( + + ) : ( + + ); + })} +
+
+ ))} +
+
+ ); +} diff --git a/src/components/story/StoryChapterPage.tsx b/src/components/story/StoryChapterPage.tsx new file mode 100644 index 00000000..d9beec9e --- /dev/null +++ b/src/components/story/StoryChapterPage.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useMemo } from "react"; +import type { AppLocale } from "@/config/locales"; +import type { GameServer } from "@/config/servers"; +import ChapterDetail from "@/components/story/ChapterDetail"; +import ServerScope from "@/components/shared/ServerScope"; +import { forServer, type ServerFaceted } from "@/lib/servers/facets"; +import { useContentServer } from "@/lib/servers/use-content-server"; +import { groupStoryChapters } from "@/lib/story/chapters"; +import type { StoryViewModel } from "@/lib/story/data"; + +/** A chapter page: the chapter's episodes as the reader's server has them, and the server switch above them. */ +export default function StoryChapterPage({ + locale, + servers, + initialStories, + chapterId, +}: { + locale: AppLocale; + servers: GameServer[]; + initialStories: ServerFaceted[]; + chapterId: number; +}) { + const [server, pickServer] = useContentServer(locale, servers); + const stories = useMemo(() => initialStories.map((story) => forServer(story, server)), [initialStories, server]); + const chapter = useMemo(() => groupStoryChapters(stories).find((entry) => entry.chapterId === chapterId) ?? null, [stories, chapterId]); + const entityServers = useMemo(() => [...new Set(initialStories.flatMap((story) => story.servers))], [initialStories]); + + return ( + +
{chapter && }
+
+ ); +} diff --git a/src/components/story/StoryExplorer.tsx b/src/components/story/StoryExplorer.tsx index ff3e4191..18b35d53 100644 --- a/src/components/story/StoryExplorer.tsx +++ b/src/components/story/StoryExplorer.tsx @@ -2,7 +2,7 @@ import SiriusLoader from "@/components/shared/SiriusLoader"; import { useListSort } from "@/lib/filter/use-list-sort"; -import { sortEntries, type ListSort } from "@/lib/filter/list-sort"; +import { sortEntries } from "@/lib/filter/list-sort"; import { useEffect, useMemo, useState } from "react"; import type { AppLocale } from "@/config/locales"; import type { GameServer } from "@/config/servers"; @@ -19,10 +19,12 @@ import { useDisplayTimeZone } from "@/lib/schedule/use-display-time-zone"; import { storyPath } from "@/lib/story/paths"; import { useQuickFilter } from "@/lib/filter/use-quick-filter"; import Modal from "@/components/shared/Modal"; +import ChapterCard from "@/components/story/ChapterCard"; import StoryScriptReader from "@/components/story/StoryScriptReader"; import StoryPlayerLink from "@/components/story/StoryPlayerLink"; import { getAssetUrl } from "@/lib/assets/url"; import { fetchAndParseStory, type ParsedStoryScript } from "@/lib/story/parser"; +import { groupStoryChapters, type StoryChapterSummary } from "@/lib/story/chapters"; import type { RawStoryCharacter, StoryCategory, StoryViewModel } from "@/lib/story/data"; import type { RawText } from "@/lib/cards/data"; import { t } from "@/i18n"; @@ -31,7 +33,7 @@ import { formatPlayTime, storyEpisodeLabel } from "@/lib/story/labels"; import { useNow } from "@/lib/schedule/use-now"; import { isSpoiler, useSpoiler } from "@/lib/settings/spoiler"; -import { inStorySection, OTHER_CATEGORIES, storyCategoryKey, type StorySection } from "@/lib/story/sections"; +import { inStorySection, OTHER_CATEGORIES, storyCategoryKey, type StoryChapterSection, type StorySection } from "@/lib/story/sections"; export { inStorySection, storyCategoryKey, type StorySection }; @@ -93,8 +95,19 @@ export default function StoryExplorer({ locale, servers, initialCategory, initia () => (sort.value === "default" && initialCategory !== "main" ? [...filtered].sort(compareByStartDesc) : sortEntries(filtered, sort.value, locale)), [filtered, sort.value, locale, initialCategory], ); + // Main and event stories are browsed by chapter: the list shows one card per chapter, the chapter's own page its episodes. + const chapters = useMemo(() => groupStoryChapters(sectionStories), [sectionStories]); + const filteredChapters = useMemo(() => { + const needle = query.trim().toLocaleLowerCase(); + return needle ? chapters.filter((chapter) => chapter.searchText.includes(needle)) : chapters; + }, [chapters, query]); + const sortedChapters = useMemo(() => { + const entries = filteredChapters.map((chapter) => ({ ...chapter, id: chapter.chapterId })); + return sort.value === "default" && initialCategory !== "main" ? [...entries].sort(compareByStartDesc) : sortEntries(entries, sort.value, locale); + }, [filteredChapters, sort.value, locale, initialCategory]); const reset = () => { sort.onChange("default"); setQuery(""); setOtherCategories([]); setLead(null); setPartner(null); setLevels([]); }; - const categoryTotal = sectionStories.length; + const resultCount = initialCategory === "event" ? filteredChapters.length : filtered.length; + const categoryTotal = initialCategory === "event" ? chapters.length : sectionStories.length; const hasActive = sort.value !== "default" || Boolean(query || otherCategories.length || lead !== null || partner !== null || levels.length); const quickFilterContent = hasFilters ? ( setReload((v) => v + 1)} />; return ( - + ); } - // Event stories are chapters too: listed by chapter, the filters and sort applying to the chapters. + // Event stories are chapters too: one card per event, the filters and sort applying to the chapters. if (initialCategory === "event") { return (
- {filtered.length === 0 + {filteredChapters.length === 0 ? - : } + : }
); @@ -228,55 +241,13 @@ export default function StoryExplorer({ locale, servers, initialCategory, initia ); } -/** Episodes grouped by chapter; `sort` orders the chapters, episodes keep their in-game order. */ -function ChapterGroups({ stories, locale, sort }: { stories: StoryViewModel[]; locale: AppLocale; sort: ListSort }) { - const assetUrl = useAssetUrl(); - const chapters = [...new Map(stories.map((story) => [story.chapterId, story])).values()] - .sort((a, b) => (a.chapterId ?? 0) - (b.chapterId ?? 0)); - - return
{sortEntries(chapters.map((chapter) => ({ ...chapter, id: chapter.chapterId ?? 0, title: chapter.chapterName })), sort, locale).map((chapter) => { - // Main, another and extra episodes each number from 1, so they are listed as separate runs. - const episodes = stories.filter((story) => story.chapterId === chapter.chapterId).sort((a, b) => a.sortOrder - b.sortOrder); - const runs = (["main", "another", "extra"] as const) - .map((kind) => ({ kind, episodes: episodes.filter((episode) => (episode.episodeKind ?? "main") === kind) })) - .filter((run) => run.episodes.length > 0); - const chapterAsset = chapter.chapterBanner || chapter.chapterImage; -const chapterImageUrl = chapterAsset ? assetUrl(getAssetUrl({ path: `Story/${chapter.chapterBanner ? "Banner" : "Image"}/Chapter/${chapterAsset}.png`, type: "raw", locale })) : ""; -return
-
- {chapterImageUrl && ( -
- -
- )} -
-

{chapter.bandName}

-

{chapter.chapterName}

-

{chapter.chapterDescription}

-

{chapter.characterNames.join(" · ")}

-
-
-
- {runs.map((run) =>
- {run.kind !== "main" &&

{t(locale, run.kind === "another" ? "story.ui.anotherStories" : "story.ui.extraStories")}

} - {/* Columns as wide as a banner and a title need, however narrow the content column is. */} - -
)} -
-
; - })}
; +/** The chapter cards of the main or event story list. */ +function ChapterGrid({ chapters, locale, section }: { chapters: StoryChapterSummary[]; locale: AppLocale; section: StoryChapterSection }) { + return ( +
+ {chapters.map((chapter) => )} +
+ ); } function StoryCard({ story, locale, onOpen }: { story: StoryViewModel; locale: AppLocale; onOpen: () => void }) { diff --git a/src/config/routes.ts b/src/config/routes.ts index 621db6f6..7aaad8ad 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -825,6 +825,25 @@ export const routeRegistry = [ searchable: true, keywords: ["post live", "home story", "tutorial story", "other story"], }, + { + // A chapter of the main or event story: the list page's cards open their episodes here. + id: "story-chapter", + kind: "dynamic", + path: "/story/:category/:id", + pattern: "/story/:category/:id", + parentId: "story", + labelKey: "nav.groups.story", + component: "story-chapter", + seo: { + titleKey: "seo.storyChapter.title", + descriptionKey: "seo.storyChapter.description", + keywords: ["story chapter", "chapter episodes", "Our Notes chapter", "story transcript", "character voices"], + sitemap: { priority: 0.5, changefreq: "weekly" }, + }, + nav: false, + searchable: false, + staticParams: async () => (await import("@/lib/route/masterdata-params")).getMasterdataStoryChapterParams(), + }, { id: "story-detail", kind: "dynamic", diff --git a/src/i18n/messages/en-US.ts b/src/i18n/messages/en-US.ts index 31e5f767..1c7b44b1 100644 --- a/src/i18n/messages/en-US.ts +++ b/src/i18n/messages/en-US.ts @@ -2162,6 +2162,7 @@ export const enUS = { birthdayDate: "Birthday {month}/{day}", columnTitle: "Title", columnChapter: "Chapter", + episodeCount: "{count} episodes", unlockTitle: "Unlock conditions", rewardsTitle: "Clear rewards", }, @@ -3260,6 +3261,10 @@ export const enUS = { title: "Story Detail", description: "Quietly savor this story scroll written with sincere devotion. Guided by expressive character voices and resonant chords, relive touching memories woven between the stage and daily life.", }, + storyChapter: { + title: "Story Chapter", + description: "Step through one chapter of the story, episode by episode. Its cast, release window and every main, another and extra episode wait here, with the voices that bring each scene to life.", + }, liveResultStory: { title: "Post-Live Talks", description: "As encore applause gently quiets down, the girls' breaths and heartbeats still burn bright backstage, letting the music's lingering warmth bloom into radiant echoes in each other's hearts.", diff --git a/src/i18n/messages/ja-JP.ts b/src/i18n/messages/ja-JP.ts index 8e8935ff..fec14dc2 100644 --- a/src/i18n/messages/ja-JP.ts +++ b/src/i18n/messages/ja-JP.ts @@ -2197,6 +2197,7 @@ export const jaJP = { birthdayDate: "誕生日 {month}月{day}日", columnTitle: "タイトル", columnChapter: "チャプター", + episodeCount: "全 {count} 話", unlockTitle: "解放条件", rewardsTitle: "クリア報酬", }, @@ -3295,6 +3296,10 @@ export const jaJP = { title: "ストーリー詳細", description: "真心を込めて綴られた時の絵巻を静かに味わって。胸を打つ声と響き合う調べに導かれ、ステージと日常が交わるかけがえのない記憶をもう一度心に抱きしめます。", }, + storyChapter: { + title: "ストーリーチャプター", + description: "物語の一章を、エピソードを追って読み進めましょう。登場人物、公開時期、そしてメイン・アナザー・エクストラの各エピソードがここに集まり、少女たちの声が場面を彩ります。", + }, liveResultStory: { title: "ライブ終了会話", description: "アンコールの拍手が静かに引いたあと、楽屋の少女たちの息遣いは今も熱く、ライブの余韻が互いの胸の中で眩しい調べへと変わっていく瞬間に耳を澄ませて。", diff --git a/src/i18n/messages/ko-KR.ts b/src/i18n/messages/ko-KR.ts index d8761b87..cd3695f4 100644 --- a/src/i18n/messages/ko-KR.ts +++ b/src/i18n/messages/ko-KR.ts @@ -2151,6 +2151,7 @@ export const koKR = { birthdayDate: "생일 {month}월 {day}일", columnTitle: "제목", columnChapter: "챕터", + episodeCount: "총 {count}화", unlockTitle: "해금 조건", rewardsTitle: "클리어 보상", }, @@ -3249,6 +3250,10 @@ export const koKR = { title: "스토리 상세", description: "진심을 다해 써 내려간 시간의 두루마리를 음미해 봅니다. 가슴을 울리는 목소리와 공명하는 선율에 이끌려, 무대와 일상이 맞닿은 귀한 추억을 가슴에 품어보세요.", }, + storyChapter: { + title: "스토리 챕터", + description: "이야기의 한 챕터를 에피소드 순서대로 읽어 보세요. 등장인물, 공개 시기, 메인·어나더·엑스트라 에피소드가 모두 모여 있고 소녀들의 목소리가 장면을 채웁니다.", + }, liveResultStory: { title: "라이브 종료 대화", description: "앙코르의 박수가 잦아든 뒤, 대기실 소녀들의 숨결은 여전히 뜨겁게 달아올라 무대의 벅찬 여운이 서로의 가슴속에서 찬란한 울림으로 번져가는 순간을 들어보세요.", diff --git a/src/i18n/messages/zh-CN.ts b/src/i18n/messages/zh-CN.ts index 59c6d1eb..55fd3566 100644 --- a/src/i18n/messages/zh-CN.ts +++ b/src/i18n/messages/zh-CN.ts @@ -2207,6 +2207,7 @@ export const zhCN = { birthdayDate: "生日 {month}月{day}日", columnTitle: "标题", columnChapter: "章节", + episodeCount: "共 {count} 话", unlockTitle: "解锁条件", rewardsTitle: "通关奖励", }, @@ -3305,6 +3306,10 @@ export const zhCN = { title: "剧情详情", description: "静静品读这段写满心意的时光篇章。伴随着动人原声与心弦的共鸣,在字里行间重温那些舞台与日常交织的珍贵感动。", }, + storyChapter: { + title: "剧情章节", + description: "翻开剧情中的这一章,按顺序读遍每一话。登场角色、开放时间与主线、Another、Extra 各段剧集都聚在这里,陪伴少女们的声音也一同等候。", + }, liveResultStory: { title: "演出结束会话", description: "当安可的掌声渐渐平息,后台少女们的呼吸与心跳依然滚烫,细细聆听演出的余韵如何在彼此心间化作璀璨的回响。", diff --git a/src/i18n/messages/zh-TW.ts b/src/i18n/messages/zh-TW.ts index a0fa32fb..3f34a7a7 100644 --- a/src/i18n/messages/zh-TW.ts +++ b/src/i18n/messages/zh-TW.ts @@ -2206,6 +2206,7 @@ export const zhTW = { birthdayDate: "生日 {month}月{day}日", columnTitle: "標題", columnChapter: "章節", + episodeCount: "共 {count} 話", unlockTitle: "解鎖條件", rewardsTitle: "通關獎勵", }, @@ -3304,6 +3305,10 @@ export const zhTW = { title: "劇情詳情", description: "靜靜品讀這段寫滿心意的時光篇章。伴隨著動人原聲與心弦的共鳴,在字裡行間重溫那些舞台與日常交織的珍貴感動。", }, + storyChapter: { + title: "劇情章節", + description: "翻開劇情中的這一章,按順序讀遍每一話。登場角色、開放時間與主線、Another、Extra 各段劇集都聚在這裡,陪伴少女們的聲音也一同等候。", + }, liveResultStory: { title: "演出結束會話", description: "當安可的掌聲漸漸平息,後台少女們的呼吸與心跳依然滾燙,細細聆聽演出的餘韻如何在彼此心間化作璀璨的回響。", diff --git a/src/lib/markdown/render.ts b/src/lib/markdown/render.ts index 7a972dfc..3153645c 100644 --- a/src/lib/markdown/render.ts +++ b/src/lib/markdown/render.ts @@ -54,6 +54,9 @@ import { buildDynamicPath, findRouteById, findRouteMatch, getNavChildren, getNav import { resolveEntitySeo } from "@/lib/seo/entity-seo"; import { absolutePageUrl, absoluteUrl, buildPageMetadata } from "@/lib/seo/metadata"; import { valueForServer, type ServerFacetedValue } from "@/lib/servers/facets"; +import { findStoryChapter } from "@/lib/story/chapters"; +import { storyEpisodeLabel } from "@/lib/story/labels"; +import { parseStoryChapterPath } from "@/lib/story/paths"; import { inStorySection, type StorySection } from "@/lib/story/sections"; export { @@ -675,6 +678,40 @@ async function renderComponentMarkdown( ].join("\n"); } + case "story-chapter": { + const chapterId = Number(paramId); + if (!Number.isSafeInteger(chapterId) || chapterId <= 0) return null; + const parsed = parseStoryChapterPath(pathname); + if (!parsed) return null; + const chapter = findStoryChapter(await getBuildStories(locale), chapterId); + if (!chapter || chapter.category !== parsed.section) return null; + return [ + `# ${chapter.name || `Chapter ${chapterId}`}`, + "", + ...(chapter.description ? [chapter.description, ""] : []), + mdTable( + ["Field", "Value"], + [ + ["Chapter ID", chapterId], + ["Category", parsed.section], + ...(chapter.bandName ? [["Group", chapter.bandName]] : []), + ...(chapter.characterNames.length > 0 ? [["Characters", chapter.characterNames.join(", ")]] : []), + ["Episodes", chapter.episodeCount], + ...(chapter.startAt ? [["Release", chapter.startAt]] : []), + ], + ), + "", + mdTable( + ["Episode", "Title", "ADV"], + chapter.episodes.map((episode) => [ + storyEpisodeLabel(locale, episode), + detailMdLink("story-detail", episode.advId, locale, episode.title || `ADV ${episode.advId}`), + episode.advId, + ]), + ), + ].join("\n"); + } + case "story-detail": { const advId = Number(paramId); if (!Number.isFinite(advId)) return null; diff --git a/src/lib/masterdata/build-data.ts b/src/lib/masterdata/build-data.ts index 267d832f..d985cb23 100644 --- a/src/lib/masterdata/build-data.ts +++ b/src/lib/masterdata/build-data.ts @@ -950,7 +950,7 @@ export function storiesOn(server: GameServer, locale: AppLocale): Promise ({ chapterId: event.storyChapterId, eventId: event.id, startAt: event.startAt })).filter((entry) => entry.chapterId > 0), + eventChapters: events._allData.map((event) => ({ chapterId: event.storyChapterId, eventId: event.id, startAt: event.startAt, endAt: event.endAt })).filter((entry) => entry.chapterId > 0), chapters: chapters._allData, episodes: episodes._allData, friendshipEpisodes: friendshipEpisodes._allData, diff --git a/src/lib/route/masterdata-params.ts b/src/lib/route/masterdata-params.ts index 1d36b457..a7143e0d 100644 --- a/src/lib/route/masterdata-params.ts +++ b/src/lib/route/masterdata-params.ts @@ -1,6 +1,8 @@ import { getBuildMasterData } from "@/lib/masterdata/build-snapshot"; import { getBuildServers } from "@/lib/masterdata/build-servers"; import { validateMasterTable } from "@/lib/cards/data"; +import { groupStoryChapters } from "@/lib/story/chapters"; +import { isChapterSection } from "@/lib/story/sections"; import type { RouteStaticParamConfig } from "@/types/route"; import { DEFAULT_LOCALE } from "@/config/locales"; @@ -55,6 +57,18 @@ export async function getMasterdataStoryParams(): Promise ({ params: { id: String(id) }, breadcrumbDetail: { label: `ADV ${id}` } })); } +/** Chapters of the main and event story: `/story/:category/:id` pages, one per chapter. */ +export async function getMasterdataStoryChapterParams(): Promise { + const { getBuildStories } = await import("@/lib/masterdata/build-data"); + const chapters = groupStoryChapters(await getBuildStories(DEFAULT_LOCALE)).filter((chapter) => isChapterSection(chapter.category)); + return chapters + .sort((a, b) => a.chapterId - b.chapterId) + .map((chapter) => ({ + params: { category: chapter.category, id: String(chapter.chapterId) }, + breadcrumbDetail: { label: chapter.name || `Chapter ${chapter.chapterId}` }, + })); +} + /** * Exchange shops any server lists: the normalizer's shops rather than MasterExchange's rows, since a shop without a * category or products (the arena's before its first season) is not shown and has no page. diff --git a/src/lib/route/static-paths.ts b/src/lib/route/static-paths.ts index ad430a29..4aa7d1b0 100644 --- a/src/lib/route/static-paths.ts +++ b/src/lib/route/static-paths.ts @@ -20,6 +20,8 @@ import { } from "@/lib/masterdata/build-data"; import { getBuildHelpTopics } from "@/lib/masterdata/build-help"; import { getBuildShops } from "@/lib/masterdata/build-shop"; +import { groupStoryChapters } from "@/lib/story/chapters"; +import { isChapterSection } from "@/lib/story/sections"; export interface StaticLocalizedPathProps { locale: AppLocale; @@ -84,6 +86,15 @@ const detailLabelLoaders: Record Promise byId(await getBuildItems(locale), (item) => item.id, (item) => label(item.name)), "shop-detail": async (locale) => byId(await getBuildShops(locale), (shop) => shop.id, (shop) => label(shop.name)), "help-detail": async (locale) => byId(await getBuildHelpTopics(locale), (topic) => topic.id, (topic) => label(topic.title)), + "story-chapter": async (locale) => { + const storyRoute = plainRoute(findRouteById("story")); + // Only main and event stories have chapter pages; birthday chapters are listed as episodes. + const chapters = groupStoryChapters(await getBuildStories(locale)).filter((chapter) => isChapterSection(chapter.category)); + return byId(chapters, (chapter) => chapter.chapterId, (chapter) => ({ + label: chapter.name || `Chapter ${chapter.chapterId}`, + ancestors: [storyRoute, plainRoute(findRouteById(chapter.category === "main" ? "main-story" : "event-story"))].filter((r): r is AppRoute => r !== null), + })); + }, "story-detail": async (locale) => { const storyRoute = plainRoute(findRouteById("story")); // An ADV in several lists takes its first one's title and category. diff --git a/src/lib/schedule/index.ts b/src/lib/schedule/index.ts index c0c2f531..b4c2c624 100644 --- a/src/lib/schedule/index.ts +++ b/src/lib/schedule/index.ts @@ -161,3 +161,13 @@ export function formatScheduleRange(startAt: string, endAt: string, locale: AppL if (end) return t(locale, "schedule.until", { end }); return ""; } + +/** A day-only schedule range, for a card that has no room for the time of day. */ +export function formatScheduleDayRange(startAt: string, endAt: string, locale: AppLocale, timeZone?: string | null): string { + const start = formatMasterDate(startAt, locale, false, timeZone); + const end = formatMasterDate(endAt, locale, false, timeZone); + if (start && end) return t(locale, "schedule.range", { start, end }); + if (start) return t(locale, "schedule.from", { start }); + if (end) return t(locale, "schedule.until", { end }); + return ""; +} diff --git a/src/lib/seo/entity-seo.ts b/src/lib/seo/entity-seo.ts index f22979ee..d266dd44 100644 --- a/src/lib/seo/entity-seo.ts +++ b/src/lib/seo/entity-seo.ts @@ -13,6 +13,7 @@ import { getBuildGachaDetail, getBuildMusicDetail, getBuildRewardEntryDetail, + getBuildStories, getBuildStoryDetail, getBuildSupportCardDetail, } from "@/lib/masterdata/build-data"; @@ -22,6 +23,10 @@ import { getBuildShopDetail } from "@/lib/masterdata/build-shop"; import { buildDynamicPath, findRouteById, getRoutePathById } from "@/lib/route/registry"; import { absolutePageUrl, type PageMetadata } from "@/lib/seo/metadata"; import { valueForServer, type ServerFacetedValue } from "@/lib/servers/facets"; +import { findStoryChapter } from "@/lib/story/chapters"; +import { storyEpisodeLabel } from "@/lib/story/labels"; +import { parseStoryChapterPath } from "@/lib/story/paths"; +import { storyCategoryKey } from "@/lib/story/sections"; export const CARD_RARITY_LABELS: Record = { 2: "R", @@ -417,6 +422,55 @@ export async function resolveEntitySeo( }; } + case "story-chapter": { + const chapterId = Number(paramId); + if (!Number.isSafeInteger(chapterId) || chapterId <= 0) return null; + const parsed = parseStoryChapterPath(pathname); + if (!parsed) return null; + const chapter = findStoryChapter(await getBuildStories(locale), chapterId); + // One chapter has one section: an event chapter under /story/main/... is not a second page for it. + if (!chapter || chapter.category !== parsed.section) return null; + + const heading = chapter.name || `Chapter ${chapterId}`; + const artwork = chapter.banner || chapter.image + ? getAssetUrl({ path: `Story/${chapter.banner ? "Banner" : "Image"}/Chapter/${chapter.banner || chapter.image}.png`, type: "raw", locale }) + : ""; + const summary = truncateText( + [heading, chapter.bandName, chapter.description, `${chapter.episodeCount} episodes`].filter(Boolean).join(" · "), + ); + const categoryPath = localizePath(getRoutePathById(chapter.category === "main" ? "main-story" : "event-story"), locale); + + return { + meta: { title: heading, description: summary || heading, ...(artwork ? { image: artwork } : {}) }, + semantic: { + heading, + summary: summary || heading, + ...(artwork ? { image: artwork } : {}), + facts: [ + { label: "Chapter ID", value: String(chapterId) }, + { label: "Category", value: t(locale, storyCategoryKey(chapter.category)) }, + ...(chapter.bandName ? [{ label: t(locale, "cards.band"), value: chapter.bandName }] : []), + ...(chapter.characterNames.length > 0 ? [{ label: "Characters", value: chapter.characterNames.join(", ") }] : []), + { label: "Episodes", value: String(chapter.episodeCount) }, + ...(chapter.startAt ? [{ label: "Release", value: chapter.startAt }] : []), + ], + sections: [ + { + title: "Episodes", + items: chapter.episodes.map((episode) => `${storyEpisodeLabel(locale, episode)} · ${episode.title}`), + }, + ], + links: [ + { label: t(locale, storyCategoryKey(chapter.category)), href: categoryPath }, + ...chapter.episodes.map((episode) => ({ + label: episode.title, + href: detailHref("story-detail", episode.advId, locale), + })), + ], + }, + }; + } + case "story-detail": { const advId = Number(paramId); if (!Number.isFinite(advId)) return null; diff --git a/src/lib/story/chapters.ts b/src/lib/story/chapters.ts new file mode 100644 index 00000000..670ebfd8 --- /dev/null +++ b/src/lib/story/chapters.ts @@ -0,0 +1,121 @@ +import type { StoryCategory, StoryEpisodeKind, StoryViewModel } from "@/lib/story/data"; +import { parseMasterDate } from "@/lib/schedule"; + +/** The episodes of one chapter, split the way the game lists them (main, then another, then extra runs). */ +export interface StoryChapterRun { + kind: StoryEpisodeKind; + episodes: StoryViewModel[]; +} + +/** + * One chapter of the main or event story, summarized for its card. Chapter fields repeat on every episode; the + * details that do not (the earliest start, the main cast, the length) are aggregated here so a card never shows + * whichever episode happened to be last in the list. + */ +export interface StoryChapterSummary { + chapterId: number; + category: StoryCategory; + name: string; + description: string; + banner: string; + image: string; + bandId: number; + bandName: string; + eventId: number | null; + startAt: string; + endAt: string; + /** Total playback length of the chapter's episodes, in seconds. */ + playTime: number; + episodeCount: number; + characterIds: number[]; + characterNames: string[]; + /** Every episode's search text, so a chapter matches a search for any of its episodes. */ + searchText: string; + episodes: StoryViewModel[]; + runs: StoryChapterRun[]; +} + +const RUN_ORDER: readonly StoryEpisodeKind[] = ["main", "another", "extra"]; + +function earliest(a: StoryViewModel, b: StoryViewModel): StoryViewModel { + const timeA = parseMasterDate(a.startAt); + const timeB = parseMasterDate(b.startAt); + if (timeA === null) return b; + if (timeB === null) return a; + return timeB < timeA ? b : a; +} + +function latestEnd(a: string, b: string): string { + if (!a) return b; + if (!b) return a; + const timeA = parseMasterDate(a); + const timeB = parseMasterDate(b); + if (timeA === null) return b; + if (timeB === null) return a; + return timeB > timeA ? b : a; +} + +/** The characters of the chapter's main run, in their in-game order; another/extra casts do not replace them. */ +function chapterCast(episodes: StoryViewModel[]): { ids: number[]; names: string[] } { + const source = episodes.filter((episode) => (episode.episodeKind ?? "main") === "main"); + const ids: number[] = []; + const names: string[] = []; + for (const episode of source.length > 0 ? source : episodes) { + episode.characterIds.forEach((id, index) => { + if (ids.includes(id)) return; + ids.push(id); + names.push(episode.characterNames[index] ?? ""); + }); + } + return { ids, names }; +} + +function summarize(chapterId: number, episodes: StoryViewModel[]): StoryChapterSummary { + const ordered = [...episodes].sort((a, b) => a.sortOrder - b.sortOrder); + const anchor = ordered.find((episode) => (episode.episodeKind ?? "main") === "main") ?? ordered[0]!; + const first = ordered.reduce(earliest); + const cast = chapterCast(ordered); + const searchText = [...new Set(ordered.map((episode) => episode.searchText).filter(Boolean))].join(" "); + + return { + chapterId, + category: anchor.category, + name: anchor.chapterName, + description: anchor.chapterDescription, + banner: anchor.chapterBanner, + image: anchor.chapterImage, + bandId: anchor.bandId, + bandName: anchor.bandName, + eventId: anchor.eventId, + startAt: first.startAt, + endAt: ordered.reduce((end, episode) => latestEnd(end, episode.endAt), ""), + playTime: ordered.reduce((total, episode) => total + (episode.playTime ?? 0), 0), + episodeCount: ordered.length, + characterIds: cast.ids, + characterNames: cast.names, + searchText, + episodes: ordered, + runs: RUN_ORDER + .map((kind) => ({ kind, episodes: ordered.filter((episode) => (episode.episodeKind ?? "main") === kind) })) + .filter((run) => run.episodes.length > 0), + }; +} + +/** The chapters among `stories`, in chapter order. Episodes without a chapter (bond stories) are skipped. */ +export function groupStoryChapters(stories: readonly StoryViewModel[]): StoryChapterSummary[] { + const byChapter = new Map(); + for (const story of stories) { + if (story.chapterId === null) continue; + const episodes = byChapter.get(story.chapterId); + if (episodes) episodes.push(story); + else byChapter.set(story.chapterId, [story]); + } + return [...byChapter.entries()] + .sort(([a], [b]) => a - b) + .map(([chapterId, episodes]) => summarize(chapterId, episodes)); +} + +/** The chapter with this id, or null. */ +export function findStoryChapter(stories: readonly StoryViewModel[], chapterId: number): StoryChapterSummary | null { + return groupStoryChapters(stories.filter((story) => story.chapterId === chapterId))[0] ?? null; +} diff --git a/src/lib/story/data.ts b/src/lib/story/data.ts index 3822dc30..71eb4662 100644 --- a/src/lib/story/data.ts +++ b/src/lib/story/data.ts @@ -186,10 +186,10 @@ export interface StoryMasterData { bands: RawBand[]; texts: RawText[]; /** - * Event story chapters (MasterEvent._storyChapterId), the event they belong to and its start; other chapters are main - * story. An event chapter opens with its event, before its own (permanent) start. + * Event story chapters (MasterEvent._storyChapterId), the event they belong to, its start and end; other chapters + * are main story. An event chapter opens with its event, before its own (permanent) start. */ - eventChapters?: Array<{ chapterId: number; eventId: number; startAt: string }>; + eventChapters?: Array<{ chapterId: number; eventId: number; startAt: string; endAt?: string }>; /** Login landings; the `Birthday/` ones mark birthday chapters (with isSpecialStory chapters). */ loginLandings?: RawStoryLoginLanding[]; playTimes?: RawAdvPlayTime[]; @@ -287,6 +287,7 @@ export function normalizeStories(data: StoryMasterData, locale: AppLocale): Stor const bandName = (id: number) => resolveText(bandMap.get(id)?.nameTextID ?? ""); const eventChapterMap = new Map((data.eventChapters ?? []).map((entry) => [entry.chapterId, entry.eventId])); const eventStartMap = new Map((data.eventChapters ?? []).map((entry) => [entry.chapterId, entry.startAt])); + const eventEndMap = new Map((data.eventChapters ?? []).filter((entry) => entry.endAt).map((entry) => [entry.chapterId, entry.endAt!])); const birthdayChapters = birthdayChapterCharacters(data.chapters, data.loginLandings ?? []); const birthdayEpisodes = birthdayEpisodeCharacters(data.loginLandings ?? []); const playTimeMap = new Map((data.playTimes ?? []).filter((entry) => entry.playTime > 0).map((entry) => [entry.id, entry.playTime])); @@ -349,7 +350,8 @@ export function normalizeStories(data: StoryMasterData, locale: AppLocale): Stor releaseEpisodeId: 0, }, startAt: earliestStart(episode.startAt || chapter?.startAt || "", eventStartMap.get(episode.chapterId) ?? ""), - endAt: chapter?.endAt ?? "", + // An event chapter closes with its event; a chapter without one (the main story) stays open. + endAt: eventEndMap.get(episode.chapterId) || chapter?.endAt || "", banner: episode.banner, image: episode.image, thumbnail: episode.thumbnail, diff --git a/src/lib/story/paths.ts b/src/lib/story/paths.ts index 5dc840d8..1ce7f395 100644 --- a/src/lib/story/paths.ts +++ b/src/lib/story/paths.ts @@ -1,7 +1,34 @@ +import { stripLocaleFromPathname } from "@/i18n/routing"; import { buildDynamicPath, findRouteById } from "@/lib/route/registry"; +import { isChapterSection, type StoryChapterSection } from "@/lib/story/sections"; /** Locale-free path of a story's reader page (`/story/:id`); pass it through localizePath before rendering. */ export function storyPath(advId: number): `/${string}` { const route = findRouteById("story-detail"); return buildDynamicPath(route?.pattern ?? route?.path ?? "", { id: String(advId) }); } + +/** Locale-free path of a chapter page (`/story/:category/:id`); pass it through localizePath before rendering. */ +export function storyChapterPath(section: StoryChapterSection, chapterId: number): `/${string}` { + const route = findRouteById("story-chapter"); + return buildDynamicPath(route?.pattern ?? route?.path ?? "", { category: section, id: String(chapterId) }); +} + +/** The section and chapter a chapter page's pathname addresses, or null when it is some other path. */ +export function parseStoryChapterPath(pathname: string): { section: StoryChapterSection; chapterId: number } | null { + const route = findRouteById("story-chapter"); + const pattern = (route?.pattern ?? route?.path ?? "").split("/").filter(Boolean); + const segments = stripLocaleFromPathname(pathname).split("/").filter(Boolean); + if (pattern.length === 0 || pattern.length !== segments.length) return null; + + const params: Record = {}; + for (const [index, part] of pattern.entries()) { + const segment = segments[index]!; + if (part.startsWith(":")) params[part.slice(1)] = decodeURIComponent(segment); + else if (part !== segment) return null; + } + + const chapterId = Number(params.id); + if (!isChapterSection(params.category) || !Number.isSafeInteger(chapterId) || chapterId <= 0) return null; + return { section: params.category, chapterId }; +} diff --git a/src/lib/story/sections.ts b/src/lib/story/sections.ts index a1a9265f..6c71b699 100644 --- a/src/lib/story/sections.ts +++ b/src/lib/story/sections.ts @@ -2,6 +2,15 @@ import type { StoryCategory } from "@/lib/story/data"; export type StorySection = "main" | "event" | "friendship" | "birthday" | "other"; +/** Sections browsed chapter by chapter; the others list their episodes directly. */ +export const CHAPTER_SECTIONS = ["main", "event"] as const; +export type StoryChapterSection = (typeof CHAPTER_SECTIONS)[number]; + +/** Whether a section has chapter pages (`/story/:category/:id`). */ +export function isChapterSection(value: string | undefined): value is StoryChapterSection { + return value !== undefined && (CHAPTER_SECTIONS as readonly string[]).includes(value); +} + export const OTHER_CATEGORIES: readonly StoryCategory[] = ["live-result", "home", "tutorial"]; /** Whether a story belongs to a story page's list. */ diff --git a/src/server-pages/DetailServerPages.tsx b/src/server-pages/DetailServerPages.tsx index 7cbb4f62..eececdb5 100644 --- a/src/server-pages/DetailServerPages.tsx +++ b/src/server-pages/DetailServerPages.tsx @@ -11,8 +11,10 @@ import DetailSemanticSeoShell from "@/components/seo/DetailSemanticSeoShell"; import BreadcrumbSync from "@/components/shell/BreadcrumbSync"; import ShopDetail from "@/components/shop/ShopDetail"; import StoryDetail from "@/components/story/StoryDetail"; +import StoryChapterPage from "@/components/story/StoryChapterPage"; import SupportCardDetail from "@/components/support-cards/SupportCardDetail"; import type { AppLocale } from "@/config/locales"; +import { notFound } from "next/navigation"; import { getBuildMemberCardMaterials, getBuildSupportCardMaterials } from "@/lib/masterdata/build-card-materials"; import { getBuildCharacterExtras } from "@/lib/masterdata/build-character-extras"; import { @@ -25,6 +27,7 @@ import { getBuildMusicDetail, getBuildMusicDetailData, getBuildRewardEntryDetail, + getBuildStories, getBuildStoryDetail, getBuildSupportCardDetail, } from "@/lib/masterdata/build-data"; @@ -35,6 +38,7 @@ import { getBuildServers } from "@/lib/masterdata/build-servers"; import { getBuildShopDetail, getBuildShopNeighbors } from "@/lib/masterdata/build-shop"; import { getDetailBreadcrumb } from "@/lib/route/static-paths"; import type { DetailSemanticSeoData } from "@/lib/seo/entity-seo"; +import { inStorySection, type StoryChapterSection } from "@/lib/story/sections"; interface DetailPageContext { locale: AppLocale; @@ -267,3 +271,21 @@ export async function StoryDetailServerPage({ locale, pathname, paramId, semanti ); } + +export async function StoryChapterServerPage({ locale, pathname, paramId, section, semantic }: DetailPageContext & { section: StoryChapterSection }) { + const chapterId = Number(paramId); + const [allStories, servers, breadcrumb] = await Promise.all([ + getBuildStories(locale), + getBuildServers(), + getDetailBreadcrumb("story-chapter", paramId, locale), + ]); + const stories = allStories.filter((story) => story.chapterId === chapterId && inStorySection(section, story)); + if (stories.length === 0) notFound(); + return ( + <> + + {semantic && } + + + ); +} diff --git a/src/server-pages/RouteDispatcher.tsx b/src/server-pages/RouteDispatcher.tsx index 91e394e2..872e374f 100644 --- a/src/server-pages/RouteDispatcher.tsx +++ b/src/server-pages/RouteDispatcher.tsx @@ -1,7 +1,9 @@ import BreadcrumbSync from "@/components/shell/BreadcrumbSync"; import type { AppLocale } from "@/config/locales"; +import { notFound } from "next/navigation"; import { localizePath } from "@/i18n/routing"; import type { DetailSemanticSeoData } from "@/lib/seo/entity-seo"; +import { isChapterSection } from "@/lib/story/sections"; import AboutServerPage from "@/server-pages/AboutServerPage"; import { AccountServerPage, OpenPlatformDocsServerPage, OpenPlatformServerPage, PlayerServerPage } from "@/server-pages/AccountServerPages"; import { @@ -41,6 +43,7 @@ import { MusicDetailServerPage, RewardDetailServerPage, ShopDetailServerPage, + StoryChapterServerPage, StoryDetailServerPage, SupportCardDetailServerPage, } from "@/server-pages/DetailServerPages"; @@ -197,6 +200,10 @@ export default async function RouteDispatcher({ ); + case "story-chapter": + return isChapterSection(match.params.category) + ? + : notFound(); case "story-detail": return ; case "items": diff --git a/src/types/route.ts b/src/types/route.ts index e91822c8..623f77c1 100644 --- a/src/types/route.ts +++ b/src/types/route.ts @@ -4,7 +4,7 @@ import type { PageMetadata } from "@/lib/seo/metadata"; export type RouteGroupId = "database" | "musics" | "events" | "story" | "tools" | "community"; export type RouteIcon = "home" | "database" | "music" | "users" | "calendar" | "newspaper" | "book" | "wrench" | "sparkles" | "info" | "palette" | "archive" | "folder-open"; export type RouteKind = "static" | "dynamic"; -export type RouteComponent = "home" | "page" | "cards" | "card-detail" | "design-system" | "characters" | "character-detail" | "support-cards" | "support-card-detail" | "skills" | "about" | "music" | "song-detail" | "stamps" | "comics" | "items" | "band-items" | "gacha" | "gacha-detail" | "rewards" | "reward-detail" | "event-list" | "event-detail" | "titles" | "backgrounds" | "story-main" | "story-event" | "story-friendship" | "story-other" | "story-detail" | "asset-viewer" | "chart-preview" | "live2d-viewer" | "story-player" | "account" | "player" | "news" | "news-detail" | "event-tracker" | "music-ranking" | "chart-data" | "search" | "exchange" | "exchange-detail" +export type RouteComponent = "home" | "page" | "cards" | "card-detail" | "design-system" | "characters" | "character-detail" | "support-cards" | "support-card-detail" | "skills" | "about" | "music" | "song-detail" | "stamps" | "comics" | "items" | "band-items" | "gacha" | "gacha-detail" | "rewards" | "reward-detail" | "event-list" | "event-detail" | "titles" | "backgrounds" | "story-main" | "story-event" | "story-friendship" | "story-other" | "story-chapter" | "story-detail" | "asset-viewer" | "chart-preview" | "live2d-viewer" | "story-player" | "account" | "player" | "news" | "news-detail" | "event-tracker" | "music-ranking" | "chart-data" | "search" | "exchange" | "exchange-detail" // haneoka feature parity (docs: .narrafork/plans/plan-parallel-weaving-hellman.md) | "item-detail" | "shop" | "shop-detail" | "missions" | "tgw-card" | "real-lives" | "story-birthday" | "help" | "help-detail" | "calendar" | "legal" | "open-platform" | "open-platform-docs" | "deck" | "deck-guide" | "card-box" | "guess-music"; diff --git a/tests/masterdata-params.test.ts b/tests/masterdata-params.test.ts index 0ccc9bfe..39665986 100644 --- a/tests/masterdata-params.test.ts +++ b/tests/masterdata-params.test.ts @@ -5,8 +5,8 @@ import { normalizeCards, validateMasterTable } from "../src/lib/cards/data"; test("all dynamic routes derive their params instead of using fixed inventories", () => { const routes = getAllRoutes().filter(isDynamicRoute); - // 9 existing detail routes + item-detail, shop-detail and help-detail (haneoka feature parity). - expect(routes).toHaveLength(12); + // 9 existing detail routes + item-detail, shop-detail, help-detail (haneoka feature parity) and story-chapter. + expect(routes).toHaveLength(13); for (const route of routes) expect(typeof route.staticParams).toBe("function"); }); diff --git a/tests/story-chapters.test.ts b/tests/story-chapters.test.ts new file mode 100644 index 00000000..a3d96742 --- /dev/null +++ b/tests/story-chapters.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import { findRouteMatch } from "../src/lib/route/registry"; +import { findStoryChapter, groupStoryChapters } from "../src/lib/story/chapters"; +import type { StoryViewModel } from "../src/lib/story/data"; +import { parseStoryChapterPath, storyChapterPath } from "../src/lib/story/paths"; + +function episode(overrides: Partial & { id: number }): StoryViewModel { + return { + advId: overrides.id, + category: "event", + chapterId: 10, + chapterName: "Colors of Pure Sense", + chapterDescription: "A chapter description.", + chapterBanner: "chapter_banner", + chapterImage: "", + bandId: 25, + bandName: "25時、ナイトコードで。", + eventId: 184, + characterIds: [1, 2], + characterNames: ["Mafuyu", "Kanade"], + playTime: 60, + title: `Episode ${overrides.id}`, + episodeKind: "main", + startAt: "2026/09/01 12:00:00", + endAt: "2026/09/10 12:00:00", + searchText: `episode ${overrides.id}`, + sortOrder: 100_000_000 + 10_000 + overrides.id, + ...overrides, + } as StoryViewModel; +} + +describe("story chapters", () => { + const stories = [ + episode({ id: 1, sortOrder: 100_010_001 }), + episode({ id: 2, sortOrder: 100_010_002, startAt: "2026/09/03 12:00:00", playTime: 90 }), + episode({ id: 3, sortOrder: 100_011_001, episodeKind: "another", characterIds: [3], characterNames: ["Ena"], startAt: "2026/08/20 12:00:00", playTime: 30 }), + episode({ id: 4, sortOrder: 100_012_001, episodeKind: "extra", playTime: 0 }), + ]; + + test("one summary per chapter aggregates its episodes", () => { + const chapters = groupStoryChapters(stories); + expect(chapters).toHaveLength(1); + const chapter = chapters[0]!; + expect(chapter.chapterId).toBe(10); + expect(chapter.episodeCount).toBe(4); + expect(chapter.playTime).toBe(180); + // The earliest episode starts the chapter, not whichever episode happens to be last. + expect(chapter.startAt).toBe("2026/08/20 12:00:00"); + // An another episode's single character does not replace the chapter's cast. + expect(chapter.characterIds).toEqual([1, 2]); + expect(chapter.characterNames).toEqual(["Mafuyu", "Kanade"]); + expect(chapter.runs.map((run) => [run.kind, run.episodes.length])).toEqual([["main", 2], ["another", 1], ["extra", 1]]); + expect(chapter.searchText).toContain("episode 1"); + expect(chapter.searchText).toContain("episode 3"); + }); + + test("chapters are found by id and unknown ids are null", () => { + expect(findStoryChapter(stories, 10)?.name).toBe("Colors of Pure Sense"); + expect(findStoryChapter(stories, 99)).toBeNull(); + expect(groupStoryChapters([episode({ id: 5, chapterId: null })])).toEqual([]); + }); +}); + +describe("story chapter paths", () => { + test("a chapter path carries its section and id", () => { + expect(storyChapterPath("event", 184)).toBe("/story/event/184"); + expect(storyChapterPath("main", 7)).toBe("/story/main/7"); + expect(parseStoryChapterPath("/story/event/184")).toEqual({ section: "event", chapterId: 184 }); + expect(parseStoryChapterPath("/ja/story/main/7")).toEqual({ section: "main", chapterId: 7 }); + }); + + test("other sections and invalid ids are not chapter pages", () => { + expect(parseStoryChapterPath("/story/friendship/184")).toBeNull(); + expect(parseStoryChapterPath("/story/main/0")).toBeNull(); + expect(parseStoryChapterPath("/story/184")).toBeNull(); + }); + + test("the chapter route matches beside the story list and detail routes", () => { + const chapter = findRouteMatch("/story/event/184"); + expect(chapter?.item.id).toBe("story-chapter"); + expect(chapter?.params).toEqual({ category: "event", id: "184" }); + expect(findRouteMatch("/story/event")?.item.id).toBe("event-story"); + expect(findRouteMatch("/story/184")?.item.id).toBe("story-detail"); + }); +}); diff --git a/tests/story-data.test.ts b/tests/story-data.test.ts index e1aa504e..4b615c8b 100644 --- a/tests/story-data.test.ts +++ b/tests/story-data.test.ts @@ -47,4 +47,13 @@ describe("story list", () => { // A main-story chapter keeps its own start. expect(normalizeStories({ ...data, chapters: [chapter] }, "en-US").find((story) => story.advId === 1)?.startAt).toBe("2026/10/26 15:00:00"); }); + + test("an event chapter closes with its event, and a chapter without one stays open", () => { + const withEvent = normalizeStories( + { ...data, eventChapters: [{ chapterId: 5, eventId: 3, startAt: "2026/10/18 15:00:00", endAt: "2026/10/26 15:00:00" }] }, + "en-US", + ); + expect(withEvent.find((story) => story.advId === 1)?.endAt).toBe("2026/10/26 15:00:00"); + expect(normalizeStories(data, "en-US").find((story) => story.advId === 1)?.endAt).toBe(""); + }); }); From 1ecb78cfee87ee7ed95ed4b6de21ca8770df1b3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=85=B5=E6=87=92=E5=8D=88=E7=9D=A1?= <2155762216@qq.com> Date: Thu, 5 Nov 2026 22:41:12 +0800 Subject: [PATCH 02/14] feat(story): filter event chapters by band The event story filter offered search and sort but not the band, which is how readers browse events. It now has the same band icon row the event list uses, plus a mixed option for chapters without a band. - event cards label a chapter without a band as mixed - reset clears the band selection with the other event filters --- src/components/story/ChapterCard.tsx | 4 ++- src/components/story/StoryExplorer.tsx | 35 +++++++++++++++++++++----- src/i18n/messages/en-US.ts | 2 ++ src/i18n/messages/ja-JP.ts | 2 ++ src/i18n/messages/ko-KR.ts | 2 ++ src/i18n/messages/zh-CN.ts | 2 ++ src/i18n/messages/zh-TW.ts | 2 ++ 7 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/components/story/ChapterCard.tsx b/src/components/story/ChapterCard.tsx index 4fba1781..86ccc163 100644 --- a/src/components/story/ChapterCard.tsx +++ b/src/components/story/ChapterCard.tsx @@ -24,6 +24,8 @@ export default function ChapterCard({ chapter, locale, section }: { chapter: Sto ? getAssetUrl({ path: `Story/${chapter.banner ? "Banner" : "Image"}/Chapter/${chapter.banner || chapter.image}.png`, type: "raw", locale }) : ""; const dates = section === "event" ? formatScheduleDayRange(chapter.startAt, chapter.endAt, locale, timeZone) : ""; + // A mixed event has no band of its own; the list labels it with the filter's "mixed" option. + const bandLabel = chapter.bandName || (section === "event" && chapter.bandId === 0 ? t(locale, "story.ui.mixedBand") : ""); return (
{chapter.eventId !== null && #{chapter.eventId}} - {chapter.bandName && {chapter.bandName}} + {bandLabel && {bandLabel}} {t(locale, "story.ui.episodeCount", { count: chapter.episodeCount })}

{chapter.name}

diff --git a/src/components/story/StoryExplorer.tsx b/src/components/story/StoryExplorer.tsx index 18b35d53..4d630ca7 100644 --- a/src/components/story/StoryExplorer.tsx +++ b/src/components/story/StoryExplorer.tsx @@ -10,7 +10,7 @@ import ServerScope from "@/components/shared/ServerScope"; import { serverReleaseFetcher } from "@/lib/assets/release"; import type { ServerFaceted } from "@/lib/servers/facets"; import { useAssetUrl, useContentServerScope, useServerList } from "@/lib/servers/use-content-server"; -import BaseFilters, { CharacterFilter, FilterButton, FilterSection } from "@/components/shared/BaseFilters"; +import BaseFilters, { BandFilter, CharacterFilter, FilterButton, FilterSection } from "@/components/shared/BaseFilters"; import CharacterAvatarStack from "@/components/shared/CharacterAvatarStack"; import CollectionViewSwitch, { useCollectionView } from "@/components/shared/CollectionViewSwitch"; import DataTable, { type DataTableColumn } from "@/components/shared/DataTable"; @@ -46,6 +46,8 @@ export default function StoryExplorer({ locale, servers, initialCategory, initia const { server, pickServer, items: stories } = useServerList(locale, servers, initialStories); const [query, setQuery] = useState(""); const [otherCategories, setOtherCategories] = useState([]); + // Event stories filter by their band; a chapter without one (a mixed event) takes the "mixed" option, id 0. + const [eventBandsFilter, setEventBandsFilter] = useState([]); // Bond stories: the pair's lead and partner (either order) and the bond level that unlocks the episode. const [lead, setLead] = useState(null); const [partner, setPartner] = useState(null); @@ -97,18 +99,29 @@ export default function StoryExplorer({ locale, servers, initialCategory, initia ); // Main and event stories are browsed by chapter: the list shows one card per chapter, the chapter's own page its episodes. const chapters = useMemo(() => groupStoryChapters(sectionStories), [sectionStories]); + // The bands the event chapters feature, for the filter; a chapter without a band belongs to a mixed event. + const eventBands = useMemo(() => { + const names = new Map(); + for (const chapter of chapters) if (chapter.bandId > 0 && chapter.bandName) names.set(chapter.bandId, chapter.bandName); + return [...names].sort(([a], [b]) => a - b); + }, [chapters]); + const hasMixedEvents = useMemo(() => chapters.some((chapter) => chapter.bandId === 0), [chapters]); const filteredChapters = useMemo(() => { const needle = query.trim().toLocaleLowerCase(); - return needle ? chapters.filter((chapter) => chapter.searchText.includes(needle)) : chapters; - }, [chapters, query]); + return chapters.filter((chapter) => { + if (needle && !chapter.searchText.includes(needle)) return false; + if (eventBandsFilter.length > 0 && !eventBandsFilter.includes(chapter.bandId)) return false; + return true; + }); + }, [chapters, query, eventBandsFilter]); const sortedChapters = useMemo(() => { const entries = filteredChapters.map((chapter) => ({ ...chapter, id: chapter.chapterId })); return sort.value === "default" && initialCategory !== "main" ? [...entries].sort(compareByStartDesc) : sortEntries(entries, sort.value, locale); }, [filteredChapters, sort.value, locale, initialCategory]); - const reset = () => { sort.onChange("default"); setQuery(""); setOtherCategories([]); setLead(null); setPartner(null); setLevels([]); }; + const reset = () => { sort.onChange("default"); setQuery(""); setOtherCategories([]); setEventBandsFilter([]); setLead(null); setPartner(null); setLevels([]); }; const resultCount = initialCategory === "event" ? filteredChapters.length : filtered.length; const categoryTotal = initialCategory === "event" ? chapters.length : sectionStories.length; - const hasActive = sort.value !== "default" || Boolean(query || otherCategories.length || lead !== null || partner !== null || levels.length); + const hasActive = sort.value !== "default" || Boolean(query || otherCategories.length || eventBandsFilter.length || lead !== null || partner !== null || levels.length); const quickFilterContent = hasFilters ? ( )} + ) : initialCategory === "event" ? ( + eventBands.length > 0 && ( + + ) ) : (
)} @@ -187,7 +210,7 @@ export default function StoryExplorer({ locale, servers, initialCategory, initia useQuickFilter( t(locale, storyCategoryKey(initialCategory)), quickFilterContent, - [initialCategory, query, otherCategories, lead, partner, levels, pairCharacters, partners, friendshipLevels, resultCount, categoryTotal, locale, sort.value], + [initialCategory, query, otherCategories, eventBandsFilter, eventBands, hasMixedEvents, lead, partner, levels, pairCharacters, partners, friendshipLevels, resultCount, categoryTotal, locale, sort.value], ); if (initialCategory === "main") { diff --git a/src/i18n/messages/en-US.ts b/src/i18n/messages/en-US.ts index 1c7b44b1..b6b9aabb 100644 --- a/src/i18n/messages/en-US.ts +++ b/src/i18n/messages/en-US.ts @@ -2163,6 +2163,8 @@ export const enUS = { columnTitle: "Title", columnChapter: "Chapter", episodeCount: "{count} episodes", + mixedBand: "Mixed", + band: "Band", unlockTitle: "Unlock conditions", rewardsTitle: "Clear rewards", }, diff --git a/src/i18n/messages/ja-JP.ts b/src/i18n/messages/ja-JP.ts index fec14dc2..31e8ba84 100644 --- a/src/i18n/messages/ja-JP.ts +++ b/src/i18n/messages/ja-JP.ts @@ -2198,6 +2198,8 @@ export const jaJP = { columnTitle: "タイトル", columnChapter: "チャプター", episodeCount: "全 {count} 話", + mixedBand: "混成", + band: "バンド", unlockTitle: "解放条件", rewardsTitle: "クリア報酬", }, diff --git a/src/i18n/messages/ko-KR.ts b/src/i18n/messages/ko-KR.ts index cd3695f4..4389d1fe 100644 --- a/src/i18n/messages/ko-KR.ts +++ b/src/i18n/messages/ko-KR.ts @@ -2152,6 +2152,8 @@ export const koKR = { columnTitle: "제목", columnChapter: "챕터", episodeCount: "총 {count}화", + mixedBand: "믹스", + band: "밴드", unlockTitle: "해금 조건", rewardsTitle: "클리어 보상", }, diff --git a/src/i18n/messages/zh-CN.ts b/src/i18n/messages/zh-CN.ts index 55fd3566..d6f6647e 100644 --- a/src/i18n/messages/zh-CN.ts +++ b/src/i18n/messages/zh-CN.ts @@ -2208,6 +2208,8 @@ export const zhCN = { columnTitle: "标题", columnChapter: "章节", episodeCount: "共 {count} 话", + mixedBand: "混", + band: "乐队", unlockTitle: "解锁条件", rewardsTitle: "通关奖励", }, diff --git a/src/i18n/messages/zh-TW.ts b/src/i18n/messages/zh-TW.ts index 3f34a7a7..e07b0f04 100644 --- a/src/i18n/messages/zh-TW.ts +++ b/src/i18n/messages/zh-TW.ts @@ -2207,6 +2207,8 @@ export const zhTW = { columnTitle: "標題", columnChapter: "章節", episodeCount: "共 {count} 話", + mixedBand: "混", + band: "樂隊", unlockTitle: "解鎖條件", rewardsTitle: "通關獎勵", }, From be32ff7534bc71b444b5b84b2614ed465fa56ee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=85=B5=E6=87=92=E5=8D=88=E7=9D=A1?= <2155762216@qq.com> Date: Thu, 5 Nov 2026 22:43:08 +0800 Subject: [PATCH 03/14] feat(story): list every band in the event filter The filter built its band row from the chapters on screen, so bands without an event in the build did not appear. The story page now hands the explorer the build's bands instead. - StoryServerPage loads the band list for the event section and passes it to the explorer - the mixed option stays, and event cards still label a chapter without a band as mixed --- src/components/story/StoryExplorer.tsx | 15 +++++---------- src/server-pages/CatalogServerPages.tsx | 6 +++++- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/components/story/StoryExplorer.tsx b/src/components/story/StoryExplorer.tsx index 4d630ca7..1c14c20a 100644 --- a/src/components/story/StoryExplorer.tsx +++ b/src/components/story/StoryExplorer.tsx @@ -41,7 +41,7 @@ const LIST_VIEWS = ["grid", "table"] as const; type ListView = typeof LIST_VIEWS[number]; const PLAY_TIME_SORT = [{ key: "playTime", labelKey: "story.ui.playTime", initialDirection: "desc" as const }]; -export default function StoryExplorer({ locale, servers, initialCategory, initialStories, initialCharacters, initialTexts }: { locale: AppLocale; servers: GameServer[]; initialCategory: StorySection; initialStories: ServerFaceted[]; initialCharacters: RawStoryCharacter[]; initialTexts: RawText[] }) { +export default function StoryExplorer({ locale, servers, bands, initialCategory, initialStories, initialCharacters, initialTexts }: { locale: AppLocale; servers: GameServer[]; bands: Array<[number, string]>; initialCategory: StorySection; initialStories: ServerFaceted[]; initialCharacters: RawStoryCharacter[]; initialTexts: RawText[] }) { const sort = useListSort(`story-${initialCategory}`, locale, "date", { numeric: PLAY_TIME_SORT }); const { server, pickServer, items: stories } = useServerList(locale, servers, initialStories); const [query, setQuery] = useState(""); @@ -99,12 +99,7 @@ export default function StoryExplorer({ locale, servers, initialCategory, initia ); // Main and event stories are browsed by chapter: the list shows one card per chapter, the chapter's own page its episodes. const chapters = useMemo(() => groupStoryChapters(sectionStories), [sectionStories]); - // The bands the event chapters feature, for the filter; a chapter without a band belongs to a mixed event. - const eventBands = useMemo(() => { - const names = new Map(); - for (const chapter of chapters) if (chapter.bandId > 0 && chapter.bandName) names.set(chapter.bandId, chapter.bandName); - return [...names].sort(([a], [b]) => a - b); - }, [chapters]); + // The event filter lists every band of the build; a chapter without one belongs to a mixed event. const hasMixedEvents = useMemo(() => chapters.some((chapter) => chapter.bandId === 0), [chapters]); const filteredChapters = useMemo(() => { const needle = query.trim().toLocaleLowerCase(); @@ -192,10 +187,10 @@ export default function StoryExplorer({ locale, servers, initialCategory, initia )} ) : initialCategory === "event" ? ( - eventBands.length > 0 && ( + bands.length > 0 && ( inStorySection(category, story)); + const bands: Array<[number, string]> = characterData ? characterData.bands.map((band) => [band.id, band.name]) : []; return ( Date: Thu, 5 Nov 2026 22:45:10 +0800 Subject: [PATCH 04/14] style(story): drop the category chip from chapter cards The list page already names the category, so the chip on every banner repeated it and covered the artwork. --- src/components/story/ChapterCard.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/components/story/ChapterCard.tsx b/src/components/story/ChapterCard.tsx index 86ccc163..c4bb742e 100644 --- a/src/components/story/ChapterCard.tsx +++ b/src/components/story/ChapterCard.tsx @@ -9,7 +9,7 @@ import { useDisplayTimeZone } from "@/lib/schedule/use-display-time-zone"; import { useNow } from "@/lib/schedule/use-now"; import type { StoryChapterSummary } from "@/lib/story/chapters"; import { storyChapterPath } from "@/lib/story/paths"; -import { storyCategoryKey, type StoryChapterSection } from "@/lib/story/sections"; +import type { StoryChapterSection } from "@/lib/story/sections"; import BannerImage from "@/components/shared/BannerImage"; import ScheduleBadge from "@/components/shared/ScheduleBadge"; @@ -35,11 +35,6 @@ export default function ChapterCard({ chapter, locale, section }: { chapter: Sto >
-
- - {t(locale, storyCategoryKey(chapter.category))} - -
{section === "event" && (
From 14f9760314c5c9bcea44e26f155b5d1c7bafd11c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=85=B5=E6=87=92=E5=8D=88=E7=9D=A1?= <2155762216@qq.com> Date: Thu, 5 Nov 2026 22:56:30 +0800 Subject: [PATCH 05/14] style(story): give chapter cards the reference layout - an event-number pill and the band's icon sit above the title - the title is larger and the chapter blurb follows it - the date row gets a calendar icon and long-form dates The day-only range helper is gone: cards show full dates now. --- src/components/shared/icons.tsx | 1 + src/components/story/ChapterCard.tsx | 49 +++++++++++++++++++++------- src/lib/schedule/index.ts | 10 ------ 3 files changed, 39 insertions(+), 21 deletions(-) diff --git a/src/components/shared/icons.tsx b/src/components/shared/icons.tsx index 990d2974..29551860 100644 --- a/src/components/shared/icons.tsx +++ b/src/components/shared/icons.tsx @@ -30,6 +30,7 @@ const paths = { pause: <>, info: <>, lock: <>, + calendar: <>, tune: <>, palette: <>, database: <>, diff --git a/src/components/story/ChapterCard.tsx b/src/components/story/ChapterCard.tsx index c4bb742e..288a037d 100644 --- a/src/components/story/ChapterCard.tsx +++ b/src/components/story/ChapterCard.tsx @@ -4,28 +4,39 @@ import type { AppLocale } from "@/config/locales"; import { t } from "@/i18n"; import { localizePath } from "@/i18n/routing"; import { getAssetUrl } from "@/lib/assets/url"; -import { formatScheduleDayRange } from "@/lib/schedule"; +import { getBandSmallIconUrl } from "@/lib/cards/assets"; +import { formatMasterDay } from "@/lib/schedule"; import { useDisplayTimeZone } from "@/lib/schedule/use-display-time-zone"; import { useNow } from "@/lib/schedule/use-now"; +import { useAssetUrl } from "@/lib/servers/use-content-server"; import type { StoryChapterSummary } from "@/lib/story/chapters"; import { storyChapterPath } from "@/lib/story/paths"; import type { StoryChapterSection } from "@/lib/story/sections"; import BannerImage from "@/components/shared/BannerImage"; import ScheduleBadge from "@/components/shared/ScheduleBadge"; +import { Icon } from "@/components/shared/icons"; /** - * A chapter of the main or event story as a browse card: its banner with the category and (for an event) the - * schedule's status, then the chapter's number, group, length and dates. + * A chapter of the main or event story as a browse card: its banner with (for an event) the schedule's status, then + * the event number and band, the chapter's title and blurb, and the event's dates. */ export default function ChapterCard({ chapter, locale, section }: { chapter: StoryChapterSummary; locale: AppLocale; section: StoryChapterSection }) { const now = useNow(); const timeZone = useDisplayTimeZone(); + const assetUrl = useAssetUrl(); const art = chapter.banner || chapter.image ? getAssetUrl({ path: `Story/${chapter.banner ? "Banner" : "Image"}/Chapter/${chapter.banner || chapter.image}.png`, type: "raw", locale }) : ""; - const dates = section === "event" ? formatScheduleDayRange(chapter.startAt, chapter.endAt, locale, timeZone) : ""; // A mixed event has no band of its own; the list labels it with the filter's "mixed" option. const bandLabel = chapter.bandName || (section === "event" && chapter.bandId === 0 ? t(locale, "story.ui.mixedBand") : ""); + const bandIcon = chapter.bandId > 0 ? assetUrl(getBandSmallIconUrl(chapter.bandId)) : ""; + const start = section === "event" ? formatMasterDay(chapter.startAt, locale, timeZone) : ""; + const end = section === "event" && chapter.endAt ? formatMasterDay(chapter.endAt, locale, timeZone) : ""; + const dates = start && end + ? t(locale, "schedule.range", { start, end }) + : start + ? t(locale, "schedule.from", { start }) + : ""; return ( )}
-
-
- {chapter.eventId !== null && #{chapter.eventId}} - {bandLabel && {bandLabel}} - {t(locale, "story.ui.episodeCount", { count: chapter.episodeCount })} +
+
+ {chapter.eventId !== null && ( + #{chapter.eventId} + )} + {bandIcon ? ( + + {bandLabel} + + ) : bandLabel ? ( + {bandLabel} + ) : null} + + {t(locale, "story.ui.episodeCount", { count: chapter.episodeCount })} +
-

{chapter.name}

- {dates &&

{dates}

} +

{chapter.name}

+ {chapter.description &&

{chapter.description}

} + {dates && ( +

+ + {dates} +

+ )}
); diff --git a/src/lib/schedule/index.ts b/src/lib/schedule/index.ts index b4c2c624..c0c2f531 100644 --- a/src/lib/schedule/index.ts +++ b/src/lib/schedule/index.ts @@ -161,13 +161,3 @@ export function formatScheduleRange(startAt: string, endAt: string, locale: AppL if (end) return t(locale, "schedule.until", { end }); return ""; } - -/** A day-only schedule range, for a card that has no room for the time of day. */ -export function formatScheduleDayRange(startAt: string, endAt: string, locale: AppLocale, timeZone?: string | null): string { - const start = formatMasterDate(startAt, locale, false, timeZone); - const end = formatMasterDate(endAt, locale, false, timeZone); - if (start && end) return t(locale, "schedule.range", { start, end }); - if (start) return t(locale, "schedule.from", { start }); - if (end) return t(locale, "schedule.until", { end }); - return ""; -} From 4a263a876b552e5587a9c3252e6d2b6670b4651c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=85=B5=E6=87=92=E5=8D=88=E7=9D=A1?= <2155762216@qq.com> Date: Thu, 5 Nov 2026 23:02:00 +0800 Subject: [PATCH 06/14] style(story): show the band name beside its icon The band chip is now a pill with the icon and the band name. The card grid follows the content column rather than the window, so the docked filter sheet switches to two columns and the name is not cut off. --- src/components/story/ChapterCard.tsx | 11 ++++++----- src/components/story/StoryExplorer.tsx | 8 +++++--- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/components/story/ChapterCard.tsx b/src/components/story/ChapterCard.tsx index 288a037d..75c4fa75 100644 --- a/src/components/story/ChapterCard.tsx +++ b/src/components/story/ChapterCard.tsx @@ -53,16 +53,17 @@ export default function ChapterCard({ chapter, locale, section }: { chapter: Sto )}
-
+
{chapter.eventId !== null && ( - #{chapter.eventId} + #{chapter.eventId} )} {bandIcon ? ( - - {bandLabel} + + + {bandLabel} ) : bandLabel ? ( - {bandLabel} + {bandLabel} ) : null} {t(locale, "story.ui.episodeCount", { count: chapter.episodeCount })} diff --git a/src/components/story/StoryExplorer.tsx b/src/components/story/StoryExplorer.tsx index 1c14c20a..8d0605c7 100644 --- a/src/components/story/StoryExplorer.tsx +++ b/src/components/story/StoryExplorer.tsx @@ -259,11 +259,13 @@ export default function StoryExplorer({ locale, servers, bands, initialCategory, ); } -/** The chapter cards of the main or event story list. */ +/** The chapter cards of the main or event story list; the grid follows the content column, not the window (the filter sheet narrows it). */ function ChapterGrid({ chapters, locale, section }: { chapters: StoryChapterSummary[]; locale: AppLocale; section: StoryChapterSection }) { return ( -
- {chapters.map((chapter) => )} +
+
+ {chapters.map((chapter) => )} +
); } From 0f0ac8fc161767e5b0466bb458b7f281b065522f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=85=B5=E6=87=92=E5=8D=88=E7=9D=A1?= <2155762216@qq.com> Date: Thu, 5 Nov 2026 23:03:17 +0800 Subject: [PATCH 07/14] revert(story): keep the chapter grid on viewport breakpoints The content-column grid goes back to the window breakpoints; the column behavior will be handled separately. --- src/components/story/StoryExplorer.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/components/story/StoryExplorer.tsx b/src/components/story/StoryExplorer.tsx index 8d0605c7..1c14c20a 100644 --- a/src/components/story/StoryExplorer.tsx +++ b/src/components/story/StoryExplorer.tsx @@ -259,13 +259,11 @@ export default function StoryExplorer({ locale, servers, bands, initialCategory, ); } -/** The chapter cards of the main or event story list; the grid follows the content column, not the window (the filter sheet narrows it). */ +/** The chapter cards of the main or event story list. */ function ChapterGrid({ chapters, locale, section }: { chapters: StoryChapterSummary[]; locale: AppLocale; section: StoryChapterSection }) { return ( -
-
- {chapters.map((chapter) => )} -
+
+ {chapters.map((chapter) => )}
); } From d22734fe0bcc83a922285b8e73a4345e08ec1950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=85=B5=E6=87=92=E5=8D=88=E7=9D=A1?= <2155762216@qq.com> Date: Thu, 5 Nov 2026 23:12:27 +0800 Subject: [PATCH 08/14] style(story): give the event list four columns at 1280px Follows the reference site's layout: the event page caps its content at 1280px and the card grid uses four columns from the xl breakpoint, three from md and two from sm. The event card is compacted for the narrower column: smaller padding, id and title, no episode count (the chapter page keeps it), and the band pill stays beside the number. --- src/components/story/ChapterCard.tsx | 19 ++++++++----------- src/components/story/StoryExplorer.tsx | 7 +++++-- src/server-pages/CatalogServerPages.tsx | 4 +++- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/components/story/ChapterCard.tsx b/src/components/story/ChapterCard.tsx index 75c4fa75..4e73f8c3 100644 --- a/src/components/story/ChapterCard.tsx +++ b/src/components/story/ChapterCard.tsx @@ -52,28 +52,25 @@ export default function ChapterCard({ chapter, locale, section }: { chapter: Sto
)}
-
-
+
+
{chapter.eventId !== null && ( - #{chapter.eventId} + #{chapter.eventId} )} {bandIcon ? ( - + {bandLabel} ) : bandLabel ? ( {bandLabel} ) : null} - - {t(locale, "story.ui.episodeCount", { count: chapter.episodeCount })} -
-

{chapter.name}

- {chapter.description &&

{chapter.description}

} +

{chapter.name}

+ {chapter.description &&

{chapter.description}

} {dates && ( -

- +

+ {dates}

)} diff --git a/src/components/story/StoryExplorer.tsx b/src/components/story/StoryExplorer.tsx index 1c14c20a..79798511 100644 --- a/src/components/story/StoryExplorer.tsx +++ b/src/components/story/StoryExplorer.tsx @@ -259,10 +259,13 @@ export default function StoryExplorer({ locale, servers, bands, initialCategory, ); } -/** The chapter cards of the main or event story list. */ +/** The chapter cards of the main or event story list; the event list is denser (four columns, like the reference site). */ function ChapterGrid({ chapters, locale, section }: { chapters: StoryChapterSummary[]; locale: AppLocale; section: StoryChapterSection }) { + const layout = section === "event" + ? "grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 md:grid-cols-3 xl:grid-cols-4" + : "gap-4 sm:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4"; return ( -
+
{chapters.map((chapter) => )}
); diff --git a/src/server-pages/CatalogServerPages.tsx b/src/server-pages/CatalogServerPages.tsx index f3e8b3f2..cd3f6df4 100644 --- a/src/server-pages/CatalogServerPages.tsx +++ b/src/server-pages/CatalogServerPages.tsx @@ -219,7 +219,7 @@ export async function StoryServerPage({ ]); const stories = allStories.filter((story) => inStorySection(category, story)); const bands: Array<[number, string]> = characterData ? characterData.bands.map((band) => [band.id, band.name]) : []; - return ( + const content = ( ); + // The event list follows the reference site's column: four cards, capped at 1280px. + return category === "event" ?
{content}
: content; } export async function ItemsServerPage({ locale }: { locale: AppLocale }) { From c41c885bcdc1820035806744aa1affaa9194df08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=85=B5=E6=87=92=E5=8D=88=E7=9D=A1?= <2155762216@qq.com> Date: Thu, 5 Nov 2026 23:28:39 +0800 Subject: [PATCH 09/14] style(shell): cap the page column at 1280px The shell keeps its full width (the navigation drawer stays where it is); the page content inside main is now centred in a max-w-7xl column, as the reference site's container does. The footer panel aligns to the same column. --- src/components/shell/AppShellClient.tsx | 3 ++- src/components/shell/Footer.tsx | 2 +- src/server-pages/CatalogServerPages.tsx | 4 +--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/shell/AppShellClient.tsx b/src/components/shell/AppShellClient.tsx index 28d87480..7e74fe45 100644 --- a/src/components/shell/AppShellClient.tsx +++ b/src/components/shell/AppShellClient.tsx @@ -75,7 +75,8 @@ export default function AppShellClient({ id="content" className="mn-shell-offset mx-auto min-h-screen max-w-[var(--mn-layout-max-width,120rem)] px-4 pb-16 pt-[calc(var(--md-app-bar-height,64px)+1rem)] transition-[padding] duration-300 ease-[var(--md-sys-motion-easing-emphasized)] md:pl-[calc(var(--mn-sidebar-offset,17rem)+var(--mn-filter-offset,0rem)+1.5rem)] md:pr-6 md:pt-[calc(var(--md-app-bar-height,64px)+1.5rem)]" > - {children} + {/* The page column keeps the reference site's max-w-7xl, centred beside the navigation drawer. */} +
{children}