Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/app/[[...segments]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const ENTITY_DETAIL_COMPONENTS = new Set([
"event-detail",
"gacha-detail",
"character-detail",
"story-chapter",
"story-detail",
"item-detail",
"exchange-detail",
Expand Down
1 change: 1 addition & 0 deletions src/components/shared/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const paths = {
pause: <><path d="M8 5v14" /><path d="M16 5v14" /></>,
info: <><circle cx="12" cy="12" r="9" /><path d="M12 11v6M12 7.5v.01" /></>,
lock: <><rect x="5" y="11" width="14" height="10" rx="2" /><path d="M8 11V7a4 4 0 0 1 8 0v4" /></>,
calendar: <><rect x="3.5" y="5" width="17" height="15" rx="2" /><path d="M3.5 10h17M8 3v4M16 3v4" /></>,
tune: <><path d="M4 7h9M17 7h3M4 17h3M11 17h9" /><circle cx="15" cy="7" r="2" /><circle cx="9" cy="17" r="2" /></>,
palette: <><path d="M12 3a9 9 0 1 0 0 18c1.1 0 1.8-.9 1.4-1.9l-.4-1c-.5-1.2.4-2.6 1.7-2.6H17a4 4 0 0 0 4-4c0-4.7-4-8.5-9-8.5Z" /><path d="M7.5 12h.01M9 7.5h.01M14 6.5h.01M17.5 10h.01" /></>,
database: <><ellipse cx="12" cy="6" rx="7" ry="3" /><path d="M5 6v12c0 1.66 3.13 3 7 3s7-1.34 7-3V6" /><path d="M5 12c0 1.66 3.13 3 7 3s7-1.34 7-3" /></>,
Expand Down
3 changes: 2 additions & 1 deletion src/components/shell/AppShellClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */}
<div className="mx-auto w-full max-w-7xl">{children}</div>
</main>
<Footer locale={locale} />
<CommandPalette locale={locale} />
Expand Down
2 changes: 1 addition & 1 deletion src/components/shell/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export default function Footer({ locale }: { locale: AppLocale }) {

return (
<footer className="mn-site-footer mn-shell-offset mx-auto max-w-[var(--mn-layout-max-width,120rem)] px-4 pb-20 text-on-surface transition-[padding] duration-300 ease-[var(--md-sys-motion-easing-emphasized)] md:pb-6 md:pl-[calc(var(--mn-sidebar-offset,17rem)+var(--mn-filter-offset,0rem)+1.5rem)] md:pr-6">
<div className="md3-surface rounded-shape-xl px-6 py-10 sm:px-8">
<div className="md3-surface mx-auto w-full max-w-7xl rounded-shape-xl px-6 py-10 sm:px-8">
<div className="grid grid-cols-1 gap-10 sm:grid-cols-2 xl:grid-cols-4 [&>*]:min-w-0">
<div className="flex flex-col gap-4">
<a
Expand Down
80 changes: 80 additions & 0 deletions src/components/story/ChapterCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"use client";

import type { AppLocale } from "@/config/locales";
import { t } from "@/i18n";
import { localizePath } from "@/i18n/routing";
import { getAssetUrl } from "@/lib/assets/url";
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 (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 })
: "";
// 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 (
<a
href={localizePath(storyChapterPath(section, chapter.chapterId), locale)}
className="md3-list-card group flex min-w-0 flex-col overflow-hidden text-left"
data-list-item-id={chapter.chapterId}
>
<div className="relative border-b border-outline-variant">
<BannerImage src={art} alt="" fallback={chapter.name} />
{section === "event" && (
<div className="pointer-events-none absolute right-2 top-2">
<ScheduleBadge locale={locale} startAt={chapter.startAt} endAt={chapter.endAt} now={now} countdown={false} />
</div>
)}
</div>
<div className="flex flex-1 flex-col p-3 sm:p-4">
<div className="mb-2 flex min-w-0 items-center gap-2">
{chapter.eventId !== null && (
<span className="shrink-0 rounded-full bg-surface-container px-2 py-0.5 text-label-small tabular-nums text-on-surface-variant">#{chapter.eventId}</span>
)}
{bandIcon ? (
<span className="inline-flex min-w-0 items-center gap-1.5 rounded-full bg-surface-container py-1 pl-1.5 pr-2.5" title={bandLabel}>
<img src={bandIcon} alt="" className="h-5 w-auto shrink-0 object-contain" loading="lazy" />
<span className="min-w-0 truncate text-label-medium text-on-surface-variant">{bandLabel}</span>
</span>
) : bandLabel ? (
<span className="min-w-0 truncate rounded-full bg-surface-container px-2.5 py-1 text-label-medium text-on-surface-variant">{bandLabel}</span>
) : null}
</div>
<h2 className="line-clamp-2 text-title-small text-on-surface transition-colors group-hover:text-primary sm:text-title-medium">{chapter.name}</h2>
{chapter.description && <p className="mt-1 line-clamp-1 text-body-small text-on-surface-variant">{chapter.description}</p>}
{dates && (
<p className="mt-2 flex items-center gap-1.5 text-label-medium tabular-nums text-on-surface-variant">
<Icon name="calendar" className="h-3.5 w-3.5 shrink-0" />
{dates}
</p>
)}
</div>
</a>
);
}
69 changes: 69 additions & 0 deletions src/components/story/ChapterDetail.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<article className="mn-list-group overflow-hidden rounded-shape-lg border border-outline-variant bg-surface-container-low shadow-elevation-1">
<div className="flex flex-col">
{chapterArt && <BannerImage src={chapterArt} alt="" fallback={chapter.name} eager />}
<div className="flex flex-col justify-center p-6 sm:p-8">
<p className="text-label-medium text-primary">{chapter.bandName}</p>
<h1 className="mt-2 text-balance text-headline-small text-on-surface">{chapter.name}</h1>
{chapter.description && <p className="mt-4 whitespace-pre-line leading-7 text-on-surface-variant">{chapter.description}</p>}
{chapter.characterNames.length > 0 && <p className="mt-4 text-body-small text-on-surface-variant">{chapter.characterNames.join(" · ")}</p>}
<p className="mt-3 text-label-small tabular-nums text-on-surface-variant">{t(locale, "story.ui.episodeCount", { count: chapter.episodeCount })}</p>
</div>
</div>
<div className="space-y-5 border-t-[1.5px] border-outline-variant bg-surface-container p-4 sm:p-6">
{chapter.runs.map((run) => (
<div key={run.kind}>
{run.kind !== "main" && <h2 className="mb-3 text-label-medium text-on-surface-variant">{t(locale, run.kind === "another" ? "story.ui.anotherStories" : "story.ui.extraStories")}</h2>}
{/* Columns as wide as a banner and a title need, however narrow the content column is. */}
<div className="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(min(100%,17rem),1fr))]">
{run.episodes.map((episode) => {
const banner = episode.assets.banner
? getAssetUrl({ path: `Story/Banner/Episode/${episode.assets.banner}.png`, type: "raw", locale })
: "";
return (
<a key={episode.id} href={localizePath(storyPath(episode.advId), locale)} className="md3-list-card group flex min-w-0 items-center gap-4 p-3">
{banner ? (
<img src={assetUrl(banner)} alt="" className="h-16 w-28 shrink-0 rounded-shape-md object-cover" loading="lazy" />
) : (
<span className="md3-placeholder h-16 w-28 shrink-0 rounded-shape-md" aria-hidden="true" />
)}
<div className="min-w-0">
<p className="text-label-small text-primary">
{storyEpisodeLabel(locale, episode)}
{episode.episodeKind === "another" && episode.characterNames[0] && <span className="ml-1.5 font-bold text-on-surface-variant">{episode.characterNames[0]}</span>}
</p>
<h3 className="mt-1 line-clamp-2 text-label-large text-on-surface">{episode.title}</h3>
{episode.playTime ? <p className="mt-1 text-label-small tabular-nums text-on-surface-variant">{formatPlayTime(episode.playTime)}</p> : null}
</div>
</a>
);
})}
</div>
</div>
))}
</div>
</article>
);
}
35 changes: 35 additions & 0 deletions src/components/story/StoryChapterPage.tsx
Original file line number Diff line number Diff line change
@@ -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<StoryViewModel>[];
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 (
<ServerScope locale={locale} servers={servers} server={server} onChange={pickServer} entityServers={entityServers}>
<div className="mx-auto max-w-4xl">{chapter && <ChapterDetail chapter={chapter} locale={locale} />}</div>
</ServerScope>
);
}
Loading
Loading