diff --git a/src/i18n/messages/en-US.ts b/src/i18n/messages/en-US.ts
index b6b9aabb..aac168b5 100644
--- a/src/i18n/messages/en-US.ts
+++ b/src/i18n/messages/en-US.ts
@@ -2165,6 +2165,7 @@ export const enUS = {
episodeCount: "{count} episodes",
mixedBand: "Mixed",
band: "Band",
+ characters: "Characters",
unlockTitle: "Unlock conditions",
rewardsTitle: "Clear rewards",
},
diff --git a/src/i18n/messages/ja-JP.ts b/src/i18n/messages/ja-JP.ts
index 31e8ba84..827d8a9f 100644
--- a/src/i18n/messages/ja-JP.ts
+++ b/src/i18n/messages/ja-JP.ts
@@ -2200,6 +2200,7 @@ export const jaJP = {
episodeCount: "全 {count} 話",
mixedBand: "混成",
band: "バンド",
+ characters: "キャラクター",
unlockTitle: "解放条件",
rewardsTitle: "クリア報酬",
},
diff --git a/src/i18n/messages/ko-KR.ts b/src/i18n/messages/ko-KR.ts
index 4389d1fe..9ffd6202 100644
--- a/src/i18n/messages/ko-KR.ts
+++ b/src/i18n/messages/ko-KR.ts
@@ -2154,6 +2154,7 @@ export const koKR = {
episodeCount: "총 {count}화",
mixedBand: "믹스",
band: "밴드",
+ characters: "캐릭터",
unlockTitle: "해금 조건",
rewardsTitle: "클리어 보상",
},
diff --git a/src/i18n/messages/zh-CN.ts b/src/i18n/messages/zh-CN.ts
index d6f6647e..23587447 100644
--- a/src/i18n/messages/zh-CN.ts
+++ b/src/i18n/messages/zh-CN.ts
@@ -2210,6 +2210,7 @@ export const zhCN = {
episodeCount: "共 {count} 话",
mixedBand: "混",
band: "乐队",
+ characters: "角色",
unlockTitle: "解锁条件",
rewardsTitle: "通关奖励",
},
diff --git a/src/i18n/messages/zh-TW.ts b/src/i18n/messages/zh-TW.ts
index e07b0f04..885d5060 100644
--- a/src/i18n/messages/zh-TW.ts
+++ b/src/i18n/messages/zh-TW.ts
@@ -2209,6 +2209,7 @@ export const zhTW = {
episodeCount: "共 {count} 話",
mixedBand: "混",
band: "樂隊",
+ characters: "角色",
unlockTitle: "解鎖條件",
rewardsTitle: "通關獎勵",
},
From 45239b181c1bfc91e63515bc21b43ea784005524 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:48:51 +0800
Subject: [PATCH 11/14] feat(story): group the event character filter by band
The character section lists every band of the build. Picking one shows that band's characters among the event chapters; no band picked means no character row. Selecting another band drops characters that no longer fit.
---
src/components/story/StoryExplorer.tsx | 76 +++++++++++++++++++++++---
1 file changed, 67 insertions(+), 9 deletions(-)
diff --git a/src/components/story/StoryExplorer.tsx b/src/components/story/StoryExplorer.tsx
index bee525b7..1a1310f6 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, { BandFilter, CharacterFilter, FilterButton, FilterSection } from "@/components/shared/BaseFilters";
+import BaseFilters, { BandFilter, CharacterFilter, FilterButton, FilterSection, toggleArrayItem } 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";
@@ -23,6 +23,7 @@ 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 { getBandSmallIconUrl, getCharacterFaceIconUrl } from "@/lib/cards/assets";
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";
@@ -48,8 +49,9 @@ export default function StoryExplorer({ locale, servers, bands, initialCategory,
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([]);
- // And by the characters the event features.
+ // And by the characters the event features; a band chip picks whose faces the character row shows.
const [eventCharactersFilter, setEventCharactersFilter] = useState([]);
+ const [eventCharacterUnit, setEventCharacterUnit] = useState(null);
// 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);
@@ -111,11 +113,22 @@ export default function StoryExplorer({ locale, servers, bands, initialCategory,
if (!names.has(id)) names.set(id, chapter.characterNames[index] ?? "");
});
}
+ const bandOf = new Map(initialCharacters.map((character) => [character.id, character.bandID]));
const order = new Map(initialCharacters.map((character) => [character.id, character.displayOrder]));
return [...names]
- .map(([id, name]) => ({ id, name }))
+ .map(([id, name]) => ({ id, name, bandId: bandOf.get(id) ?? 0 }))
.sort((a, b) => (order.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (order.get(b.id) ?? Number.MAX_SAFE_INTEGER) || a.id - b.id);
}, [chapters, initialCharacters]);
+ // The band chips list every band of the build; no band picked means no character row yet.
+ const shownCharacters = useMemo(
+ () => (eventCharacterUnit === null ? [] : eventCharacters.filter((character) => character.bandId === eventCharacterUnit)),
+ [eventCharacters, eventCharacterUnit],
+ );
+ const pickCharacterUnit = (bandId: number | null) => {
+ setEventCharacterUnit(bandId);
+ // A character of another band leaves the selection when its band is picked.
+ if (bandId !== null) setEventCharactersFilter((ids) => ids.filter((id) => eventCharacters.some((character) => character.id === id && character.bandId === bandId)));
+ };
const filteredChapters = useMemo(() => {
const needle = query.trim().toLocaleLowerCase();
return chapters.filter((chapter) => {
@@ -129,7 +142,7 @@ export default function StoryExplorer({ locale, servers, bands, initialCategory,
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([]); setEventBandsFilter([]); setEventCharactersFilter([]); setLead(null); setPartner(null); setLevels([]); };
+ const reset = () => { sort.onChange("default"); setQuery(""); setOtherCategories([]); setEventBandsFilter([]); setEventCharactersFilter([]); setEventCharacterUnit(null); 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 || eventBandsFilter.length || eventCharactersFilter.length || lead !== null || partner !== null || levels.length);
@@ -213,10 +226,13 @@ export default function StoryExplorer({ locale, servers, bands, initialCategory,
extraOption={hasMixedEvents ? { id: 0, label: t(locale, "story.ui.mixedBand") } : undefined}
/>
)}
-
>
@@ -229,7 +245,7 @@ export default function StoryExplorer({ locale, servers, bands, initialCategory,
useQuickFilter(
t(locale, storyCategoryKey(initialCategory)),
quickFilterContent,
- [initialCategory, query, otherCategories, eventBandsFilter, bands, hasMixedEvents, eventCharactersFilter, eventCharacters, lead, partner, levels, pairCharacters, partners, friendshipLevels, resultCount, categoryTotal, locale, sort.value],
+ [initialCategory, query, otherCategories, eventBandsFilter, bands, hasMixedEvents, eventCharactersFilter, eventCharacterUnit, shownCharacters, lead, partner, levels, pairCharacters, partners, friendshipLevels, resultCount, categoryTotal, locale, sort.value],
);
if (initialCategory === "main") {
@@ -295,6 +311,48 @@ function ChapterGrid({ chapters, locale, section }: { chapters: StoryChapterSumm
);
}
+/**
+ * The event filter's characters, split by band as the reference site's filter is: the band chip row picks whose faces
+ * show below it, and ALL clears the character selection.
+ */
+function EventCharacterFilter({ locale, units, characters, unit, onPickUnit, selected, onChange }: {
+ locale: AppLocale;
+ units: Array<[number, string]>;
+ characters: Array<{ id: number; name: string }>;
+ unit: number | null;
+ onPickUnit: (bandId: number | null) => void;
+ selected: number[];
+ onChange: (characterIds: number[]) => void;
+}) {
+ return (
+
+
+
+ {units.map(([bandId, name]) => (
+
onPickUnit(unit === bandId ? null : bandId)}>
+
+
+
+
+ ))}
+
+ {characters.length > 0 && (
+
+
onChange([])}>ALL
+ {characters.map((character) => (
+
onChange(toggleArrayItem(selected, character.id))}>
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
+
function StoryCard({ story, locale, onOpen }: { story: StoryViewModel; locale: AppLocale; onOpen: () => void }) {
const assetUrl = useAssetUrl();
const hasCover = !OTHER_CATEGORIES.includes(story.category);
From 0487a0b783453ab2ab083bf7ac6faa8b5bbb5a06 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:53:48 +0800
Subject: [PATCH 12/14] fix(story): show every band member in the event
character filter
The face row only held characters that appear in the loaded event chapters, so a band with no event in the build (MyGO, Ave Mujica) looked empty. The row now comes from the build's character table, so every band lists its members.
---
src/components/story/StoryExplorer.tsx | 28 +++++++------------------
src/server-pages/CatalogServerPages.tsx | 6 ++++++
2 files changed, 13 insertions(+), 21 deletions(-)
diff --git a/src/components/story/StoryExplorer.tsx b/src/components/story/StoryExplorer.tsx
index 1a1310f6..dc1b51af 100644
--- a/src/components/story/StoryExplorer.tsx
+++ b/src/components/story/StoryExplorer.tsx
@@ -42,7 +42,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, bands, initialCategory, initialStories, initialCharacters, initialTexts }: { locale: AppLocale; servers: GameServer[]; bands: Array<[number, string]>; initialCategory: StorySection; initialStories: ServerFaceted[]; initialCharacters: RawStoryCharacter[]; initialTexts: RawText[] }) {
+export default function StoryExplorer({ locale, servers, bands, characters, initialCategory, initialStories, initialCharacters, initialTexts }: { locale: AppLocale; servers: GameServer[]; bands: Array<[number, string]>; characters: Array<{ id: number; name: string; bandId: number }>; 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("");
@@ -61,7 +61,7 @@ export default function StoryExplorer({ locale, servers, bands, initialCategory,
const error = false;
const [, setReload] = useState(0);
const [active, setActive] = useState(null);
- const characters = initialCharacters;
+ const rawCharacters = initialCharacters;
const texts = initialTexts;
const now = useNow();
@@ -105,29 +105,15 @@ export default function StoryExplorer({ locale, servers, bands, initialCategory,
const chapters = useMemo(() => groupStoryChapters(sectionStories), [sectionStories]);
// 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]);
- // The characters the event chapters feature, in the story table's own order, for the face filter.
- const eventCharacters = useMemo(() => {
- const names = new Map();
- for (const chapter of chapters) {
- chapter.characterIds.forEach((id, index) => {
- if (!names.has(id)) names.set(id, chapter.characterNames[index] ?? "");
- });
- }
- const bandOf = new Map(initialCharacters.map((character) => [character.id, character.bandID]));
- const order = new Map(initialCharacters.map((character) => [character.id, character.displayOrder]));
- return [...names]
- .map(([id, name]) => ({ id, name, bandId: bandOf.get(id) ?? 0 }))
- .sort((a, b) => (order.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (order.get(b.id) ?? Number.MAX_SAFE_INTEGER) || a.id - b.id);
- }, [chapters, initialCharacters]);
- // The band chips list every band of the build; no band picked means no character row yet.
+ // The band chips list every band of the build; picking one shows its characters (none until then).
const shownCharacters = useMemo(
- () => (eventCharacterUnit === null ? [] : eventCharacters.filter((character) => character.bandId === eventCharacterUnit)),
- [eventCharacters, eventCharacterUnit],
+ () => (eventCharacterUnit === null ? [] : characters.filter((character) => character.bandId === eventCharacterUnit)),
+ [characters, eventCharacterUnit],
);
const pickCharacterUnit = (bandId: number | null) => {
setEventCharacterUnit(bandId);
// A character of another band leaves the selection when its band is picked.
- if (bandId !== null) setEventCharactersFilter((ids) => ids.filter((id) => eventCharacters.some((character) => character.id === id && character.bandId === bandId)));
+ if (bandId !== null) setEventCharactersFilter((ids) => ids.filter((id) => characters.some((character) => character.id === id && character.bandId === bandId)));
};
const filteredChapters = useMemo(() => {
const needle = query.trim().toLocaleLowerCase();
@@ -294,7 +280,7 @@ export default function StoryExplorer({ locale, servers, bands, initialCategory,
)}
-