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
4 changes: 4 additions & 0 deletions openapi/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
},
Expand Down
54 changes: 54 additions & 0 deletions src/app/anime-store/lobby/[roomId]/layout.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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}</>;
}
21 changes: 21 additions & 0 deletions src/app/anime-store/lobby/[roomId]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -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<ImageResponse> {
const { roomId } = await params;
return renderRoomImage(roomId);
}
22 changes: 22 additions & 0 deletions src/app/anime-store/lobby/[roomId]/twitter-image.tsx
Original file line number Diff line number Diff line change
@@ -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<ImageResponse> {
const { roomId } = await params;
return renderRoomImage(roomId);
}
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
64 changes: 64 additions & 0 deletions src/components/og/RoomOgImage.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof RoomOgImage> = {
title: "OGP/RoomOgImage",
component: RoomOgImage,
parameters: {
layout: "centered",
backgrounds: { default: "light" },
},
decorators: [
(Story) => (
<div
style={{
width: OG_SIZE.width,
height: OG_SIZE.height,
transform: "scale(0.5)",
transformOrigin: "top left",
// Keep the surrounding layout sized to the scaled box.
marginBottom: -OG_SIZE.height / 2,
marginRight: -OG_SIZE.width / 2,
boxShadow: "0 8px 40px rgba(0,0,0,0.4)",
overflow: "hidden",
}}
>
<Story />
</div>
),
],
argTypes: {
title: { control: "text" },
},
};

export default meta;
type Story = StoryObj<typeof RoomOgImage>;

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: "" },
};
197 changes: 197 additions & 0 deletions src/components/og/RoomOgImage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: 72,
backgroundColor: COLORS.bgFrom,
backgroundImage: `radial-gradient(1100px 540px at 88% -10%, rgba(${COLORS.primaryRgb},0.28), rgba(${COLORS.primaryRgb},0) 60%), linear-gradient(135deg, ${COLORS.bgFrom} 0%, ${COLORS.bgTo} 100%)`,
color: COLORS.text,
fontFamily: "Noto Sans JP",
position: "relative",
}}
>
{/* Header: brand wordmark + watch-party badge */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div style={{ display: "flex", alignItems: "center" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 72,
height: 72,
borderRadius: 20,
backgroundColor: "#000000",
border: `1px solid rgba(${COLORS.primaryRgb}, 0.30)`,
marginRight: 24,
}}
>
{/* eslint-disable-next-line @next/next/no-img-element -- Satori (next/og) only supports <img>, not next/image */}
<img src={LOGO_RED} width={44} height={44} alt="" />
</div>
<div
style={{
display: "flex",
fontSize: 44,
fontWeight: 700,
letterSpacing: -1,
}}
>
d-party
</div>
</div>

<div
style={{
display: "flex",
alignItems: "center",
padding: "14px 28px",
borderRadius: 999,
border: `2px solid rgba(${COLORS.primaryRgb}, 0.35)`,
backgroundColor: `rgba(${COLORS.primaryRgb}, 0.12)`,
color: COLORS.primary,
fontSize: 30,
fontWeight: 700,
}}
>
WATCH PARTY
</div>
</div>

{/* Center: headline + title */}
<div style={{ display: "flex", flexDirection: "column" }}>
<div
style={{
display: "flex",
color: COLORS.primary,
fontSize: 32,
fontWeight: 700,
marginBottom: 20,
}}
>
{hasTitle ? "この作品を一緒に視聴中" : "dアニメストアで同時視聴"}
</div>

<div
style={{
display: "flex",
fontSize: hasTitle ? 76 : 64,
fontWeight: 700,
lineHeight: 1.18,
letterSpacing: -1,
// Clamp very long titles to keep the layout balanced.
overflow: "hidden",
maxHeight: 76 * 1.18 * 3,
}}
>
{hasTitle ? work : "友だちと、同じ瞬間を。"}
</div>

{hasTitle && sub.length > 0 ? (
<div
style={{
display: "flex",
marginTop: 22,
fontSize: 38,
fontWeight: 400,
color: COLORS.muted,
overflow: "hidden",
maxHeight: 38 * 1.3 * 2,
}}
>
{sub}
</div>
) : null}
</div>

{/* Footer: tagline + domain */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
fontSize: 30,
color: COLORS.muted,
}}
>
<div style={{ display: "flex" }}>dアニメストアを友だちと同時視聴</div>
<div style={{ display: "flex", fontWeight: 700, color: COLORS.text }}>
d-party.net
</div>
</div>
</div>
);
}
2 changes: 2 additions & 0 deletions src/infrastructure/api/generated/model/lobbyRedirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading