diff --git a/openapi/openapi.json b/openapi/openapi.json index f7db243..70897c6 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -242,6 +242,10 @@ "type": "string", "format": "uuid", "description": "Echoed room UUID." + }, + "title": { + "type": "string", + "description": "Anime title captured by the extension at room creation. May be empty for rooms created by older extension versions." } } }, diff --git a/src/app/anime-store/lobby/[roomId]/layout.tsx b/src/app/anime-store/lobby/[roomId]/layout.tsx new file mode 100644 index 0000000..edfd435 --- /dev/null +++ b/src/app/anime-store/lobby/[roomId]/layout.tsx @@ -0,0 +1,54 @@ +import type { Metadata } from "next"; + +import { resolveRoom } from "@/infrastructure/lobby"; + +/** + * Per-room metadata for the lobby link (title, description, Open Graph and + * Twitter card). The `opengraph-image.tsx` / `twitter-image.tsx` files in this + * segment are picked up automatically by Next and injected as the og/twitter + * images, so only the textual meta is set here. + * + * The lobby page itself is a client component, so this server layout is what + * carries the metadata for the route. + */ +export async function generateMetadata({ + params, +}: { + params: Promise<{ roomId: string }>; +}): Promise { + const { roomId } = await params; + const { title } = await resolveRoom(roomId); + + const pageTitle = title + ? `${title} を一緒に視聴` + : "ルームに参加して同時視聴"; + const description = title + ? `「${title}」をdアニメストアで友だちと同時視聴。リンクを開いてルームに参加しよう。` + : "dアニメストアで友だちと同時視聴。リンクを開いてルームに参加しよう。"; + + return { + title: pageTitle, + description, + // ルーム URL は短命・共有限定なので検索エンジンには載せない。 + robots: { index: false, follow: false }, + openGraph: { + title: pageTitle, + description, + type: "website", + siteName: "d-party", + locale: "ja_JP", + url: `/anime-store/lobby/${roomId}`, + }, + twitter: { + card: "summary_large_image", + title: pageTitle, + description, + }, + }; +} + +export default function LobbyLayout({ + children, +}: Readonly<{ children: React.ReactNode }>): React.JSX.Element { + return <>{children}; +} diff --git a/src/app/anime-store/lobby/[roomId]/opengraph-image.tsx b/src/app/anime-store/lobby/[roomId]/opengraph-image.tsx new file mode 100644 index 0000000..52e2d75 --- /dev/null +++ b/src/app/anime-store/lobby/[roomId]/opengraph-image.tsx @@ -0,0 +1,21 @@ +import type { ImageResponse } from "next/og"; + +import { OG_SIZE } from "@/components/og/RoomOgImage"; +import { OG_ALT, renderRoomImage } from "@/lib/og/renderRoomImage"; + +// Needs Node's runtime: we fetch the room title and a Japanese font at request +// time, then rasterize with Satori. +export const runtime = "nodejs"; + +export const alt = OG_ALT; +export const size = OG_SIZE; +export const contentType = "image/png"; + +export default async function Image({ + params, +}: { + params: Promise<{ roomId: string }>; +}): Promise { + const { roomId } = await params; + return renderRoomImage(roomId); +} diff --git a/src/app/anime-store/lobby/[roomId]/twitter-image.tsx b/src/app/anime-store/lobby/[roomId]/twitter-image.tsx new file mode 100644 index 0000000..39e5799 --- /dev/null +++ b/src/app/anime-store/lobby/[roomId]/twitter-image.tsx @@ -0,0 +1,22 @@ +import type { ImageResponse } from "next/og"; + +import { OG_SIZE } from "@/components/og/RoomOgImage"; +import { OG_ALT, renderRoomImage } from "@/lib/og/renderRoomImage"; + +// Reuses the same card as the Open Graph image for the Twitter/X +// summary_large_image card. Kept as its own file because Next requires the +// route config (`runtime`/`size`/...) to be static literals per file. +export const runtime = "nodejs"; + +export const alt = OG_ALT; +export const size = OG_SIZE; +export const contentType = "image/png"; + +export default async function Image({ + params, +}: { + params: Promise<{ roomId: string }>; +}): Promise { + const { roomId } = await params; + return renderRoomImage(roomId); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ab07c76..e4d8124 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,10 +2,12 @@ import type { Metadata } from "next"; import { Footer } from "@/components/layout/Footer"; import { Header } from "@/components/layout/Header"; +import { SITE_URL } from "@/infrastructure/env"; import "./globals.css"; export const metadata: Metadata = { + metadataBase: new URL(SITE_URL), title: { default: "d-party", template: "%s | d-party", diff --git a/src/components/og/RoomOgImage.stories.tsx b/src/components/og/RoomOgImage.stories.tsx new file mode 100644 index 0000000..b5ea013 --- /dev/null +++ b/src/components/og/RoomOgImage.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import { OG_SIZE, RoomOgImage } from "./RoomOgImage"; + +/** + * Preview of the room Open Graph image (1200×630). The exact same component is + * rasterized to PNG by `app/anime-store/lobby/[roomId]/opengraph-image.tsx` + * via `next/og` (`@vercel/og`), so this story shows what the shared card looks + * like. Rendered at half scale to fit the canvas; toggle the `title` control to + * try different anime titles (empty = older-extension fallback). + */ +const meta: Meta = { + title: "OGP/RoomOgImage", + component: RoomOgImage, + parameters: { + layout: "centered", + backgrounds: { default: "light" }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + argTypes: { + title: { control: "text" }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const WithTitle: Story = { + args: { title: "葬送のフリーレン - 第1話 - 冒険の終わり" }, +}; + +export const WorkTitleOnly: Story = { + args: { title: "ぼっち・ざ・ろっく!" }, +}; + +export const LongTitle: Story = { + args: { + title: + "公爵令嬢の嗜み - 第12話 - とても長いサブタイトルが入った場合のレイアウト確認用エピソード", + }, +}; + +/** Rooms created by older extension versions send no title → generic fallback. */ +export const NoTitleFallback: Story = { + args: { title: "" }, +}; diff --git a/src/components/og/RoomOgImage.tsx b/src/components/og/RoomOgImage.tsx new file mode 100644 index 0000000..afd2d64 --- /dev/null +++ b/src/components/og/RoomOgImage.tsx @@ -0,0 +1,197 @@ +/** + * Presentational layout for a room's Open Graph image (1200×630). + * + * Written with inline styles only, using the flexbox subset that Satori + * (the engine behind `next/og`'s `ImageResponse`, i.e. `@vercel/og`) supports. + * This lets the very same component be: + * - rendered to a PNG on the server by `opengraph-image.tsx` / `twitter-image.tsx` + * - previewed as plain HTML in Storybook (`RoomOgImage.stories.tsx`). + * + * Keep it free of Tailwind classes, external CSS and unsupported CSS features. + */ + +import logoUrl from "@/components/logo-data"; + +export const OG_SIZE = { width: 1200, height: 630 } as const; + +/** + * Brand palette converted from the globals.css theme tokens (oklch) to hex so + * Satori renders them reliably. `primary` is the d-party theme red + * (`--primary: oklch(0.637 0.237 25.331)`), background/foreground/muted mirror + * the dark theme — intentionally neutral (no magenta tint) with a red accent. + */ +const COLORS = { + bgFrom: "#0d0e13", // --background + bgTo: "#16171d", // slightly lifted neutral for subtle depth + primary: "#fb2c36", // --primary (theme red) + primaryRgb: "251, 44, 54", // primary as rgb for translucent glows/fills + text: "#fafafa", // --foreground + muted: "#a1a1a1", // --muted-foreground +} as const; + +/** + * The brand mark recolored to the theme red. The app icon is a red logo on a + * black tile, so we keep that treatment here. The source SVG (logo-data.ts) + * bakes its fill as `#cc0033`; swap it for the brighter theme red. + */ +const LOGO_RED = logoUrl.replace("%23cc0033", "%23fb2c36"); + +export interface RoomOgImageProps { + /** + * Raw title as captured by the extension, e.g. + * `"作品名 - 第1話 - サブタイトル"`. May be empty for rooms created by older + * extension versions; a generic headline is shown in that case. + */ + title?: string; +} + +/** Split the extension's `"work - episode - subtitle"` title into work + the rest. */ +function splitTitle(title: string): { work: string; sub: string } { + const parts = title + .split(" - ") + .map((p) => p.trim()) + .filter(Boolean); + if (parts.length === 0) return { work: "", sub: "" }; + return { work: parts[0], sub: parts.slice(1).join(" ") }; +} + +export function RoomOgImage({ + title = "", +}: RoomOgImageProps): React.JSX.Element { + const { work, sub } = splitTitle(title.trim()); + const hasTitle = work.length > 0; + + return ( +
+ {/* Header: brand wordmark + watch-party badge */} +
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element -- Satori (next/og) only supports , not next/image */} + +
+
+ d-party +
+
+ +
+ WATCH PARTY +
+
+ + {/* Center: headline + title */} +
+
+ {hasTitle ? "この作品を一緒に視聴中" : "dアニメストアで同時視聴"} +
+ +
+ {hasTitle ? work : "友だちと、同じ瞬間を。"} +
+ + {hasTitle && sub.length > 0 ? ( +
+ {sub} +
+ ) : null} +
+ + {/* Footer: tagline + domain */} +
+
dアニメストアを友だちと同時視聴
+
+ d-party.net +
+
+
+ ); +} diff --git a/src/infrastructure/api/generated/model/lobbyRedirect.ts b/src/infrastructure/api/generated/model/lobbyRedirect.ts index 956f3ac..09e4580 100644 --- a/src/infrastructure/api/generated/model/lobbyRedirect.ts +++ b/src/infrastructure/api/generated/model/lobbyRedirect.ts @@ -13,4 +13,6 @@ export interface LobbyRedirect { part_id: string; /** Echoed room UUID. */ room_id: string; + /** Anime title captured by the extension at room creation. May be empty for rooms created by older extension versions. */ + title?: string; } diff --git a/src/infrastructure/env.ts b/src/infrastructure/env.ts index ed63bcb..b8ea31a 100644 --- a/src/infrastructure/env.ts +++ b/src/infrastructure/env.ts @@ -29,6 +29,13 @@ export const API_BASE_URL = `${BACKEND_PROTOCOL}${BACKEND_HOST}`.replace( "", ); +/** + * 公開サイトの絶対 URL。OGP / Twitter カードの画像 URL を絶対化するために + * `metadataBase` で使う。デプロイ環境ごとに `NEXT_PUBLIC_SITE_URL` で上書きする。 + */ +export const SITE_URL = + process.env.NEXT_PUBLIC_SITE_URL ?? "https://d-party.net"; + /** Chrome ウェブストアの d-party 拡張機能ページ(インストール導線)。 */ export const CHROME_WEBSTORE_URL = "https://chrome.google.com/webstore/detail/d-party/ibmlcfpijglpfbfgaleaeooebgdgcbpc?hl=ja"; diff --git a/src/infrastructure/lobby.ts b/src/infrastructure/lobby.ts new file mode 100644 index 0000000..08d8970 --- /dev/null +++ b/src/infrastructure/lobby.ts @@ -0,0 +1,28 @@ +import { lobbyResolve } from "@/infrastructure/api/generated/d-party"; + +export interface ResolvedRoom { + /** Anime title saved at room creation. Empty when unknown or room not found. */ + title: string; + /** Whether the room exists (and is still alive) on the backend. */ + found: boolean; +} + +/** + * Server-side room lookup used by the lobby route's metadata and OG image. + * + * Reuses the generated `lobbyResolve` REST client. Never throws: an unknown or + * missing room (and any network error) resolves to an empty title so OGP/meta + * fall back to generic copy. `title` may be empty for rooms created by older + * extension versions that did not send a title. + */ +export async function resolveRoom(roomId: string): Promise { + try { + const res = await lobbyResolve(roomId, { next: { revalidate: 600 } }); + if (res.status === 200) { + return { title: (res.data.title ?? "").trim(), found: true }; + } + return { title: "", found: false }; + } catch { + return { title: "", found: false }; + } +} diff --git a/src/lib/og/loadGoogleFont.ts b/src/lib/og/loadGoogleFont.ts new file mode 100644 index 0000000..e1ea25c --- /dev/null +++ b/src/lib/og/loadGoogleFont.ts @@ -0,0 +1,28 @@ +/** + * Fetch a (subsetted) Google Font as an ArrayBuffer for use with `next/og`'s + * `ImageResponse` (`@vercel/og` / Satori). + * + * Satori has no built-in CJK glyphs, so Japanese anime titles would render as + * tofu (□) without an embedded font. We request only the glyphs actually used + * (`text`) to keep the download tiny. Omitting a modern User-Agent makes Google + * return a TrueType file, which Satori can parse (woff2 cannot be used). + */ +export async function loadGoogleFont( + family: string, + weight: number, + text: string, +): Promise { + const params = new URLSearchParams({ + family: `${family}:wght@${weight}`, + text, + }); + const cssUrl = `https://fonts.googleapis.com/css2?${params.toString()}`; + const css = await fetch(cssUrl).then((res) => res.text()); + const url = css.match( + /src: url\((.+?)\) format\('(?:opentype|truetype)'\)/, + )?.[1]; + if (!url) { + throw new Error(`Failed to resolve font URL for ${family} (${weight})`); + } + return fetch(url).then((res) => res.arrayBuffer()); +} diff --git a/src/lib/og/renderRoomImage.tsx b/src/lib/og/renderRoomImage.tsx new file mode 100644 index 0000000..091bde2 --- /dev/null +++ b/src/lib/og/renderRoomImage.tsx @@ -0,0 +1,61 @@ +import { ImageResponse } from "next/og"; + +import { OG_SIZE, RoomOgImage } from "@/components/og/RoomOgImage"; +import { resolveRoom } from "@/infrastructure/lobby"; +import { loadGoogleFont } from "@/lib/og/loadGoogleFont"; + +/** Shared `alt` text for the room OG / Twitter images. */ +export const OG_ALT = "d-party — dアニメストアで同時視聴"; + +// Static copy baked into the image; included in the font subset request so all +// glyphs (kana/kanji) are available to Satori. +const STATIC_GLYPHS = + "d-party WATCH PARTY この作品を一緒に視聴中 dアニメストアで同時視聴 友だちと、同じ瞬間を。 dアニメストアを友だちと同時視聴 d-party.net ▶"; + +const FONT_FAMILY = "Noto Sans JP"; + +/** + * Load the Noto Sans JP weights needed for the image, subsetted to the glyphs + * actually rendered. Returns `[]` on failure so the image still renders (Latin + * text via the default font) instead of 500-ing the route. + */ +async function loadFonts(text: string) { + try { + const [regular, bold] = await Promise.all([ + loadGoogleFont(FONT_FAMILY, 400, text), + loadGoogleFont(FONT_FAMILY, 700, text), + ]); + return [ + { + name: FONT_FAMILY, + data: regular, + weight: 400 as const, + style: "normal" as const, + }, + { + name: FONT_FAMILY, + data: bold, + weight: 700 as const, + style: "normal" as const, + }, + ]; + } catch { + return []; + } +} + +/** + * Render a room's social card to a PNG. Shared by `opengraph-image.tsx` and + * `twitter-image.tsx` (each keeps its own literal route config; Next requires + * `runtime`/`size`/etc. to be static literals per file, so only the render + * logic is shared here). + */ +export async function renderRoomImage(roomId: string): Promise { + const { title } = await resolveRoom(roomId); + const fonts = await loadFonts(`${STATIC_GLYPHS} ${title}`); + + return new ImageResponse(, { + ...OG_SIZE, + fonts, + }); +}