BluePeriod Docs
開発

Reader System

BluePeriodのReader(読書)システムのアーキテクチャ、コンテンツ配信、多言語対応、読書UI機能を網羅的に解説

Reader System (24_reader_system.md)

1. 概要

Readerシステムは、BluePeriodプラットフォーム上で公開された作品を閲覧するための読書環境を提供します。5層構造のプロット(Part → Arc → Chapter → Story → Section)に基づく階層的レンダリング、多言語マニュスクリプトの切替、目次ドロワー、読書進捗の自動保存など、長時間の読書に最適化された没入感のあるUIを実現しています。

主な特徴:

  • 階層的レンダリング(Part → Story → Section)
  • マニュスクリプトの多言語切替
  • StructuredPlot に基づく目次ナビゲーション
  • 読書進捗の自動保存(localStorage)
  • セリフ体による没入感のあるタイポグラフィ
  • JSON-LD構造化データによるSEO最適化

2. 画面構成とレイアウト

2.1. ルーティング

パス役割
/read/[id]リダイレクトページ。最初のStoryを特定し /read/[id]/[storyId] にリダイレクト
/read/[id]/[storyId]メイン読書ページ
/read/[id]/loading.tsxローディングスケルトン

2.2. リダイレクトページ

/read/[id]/page.tsx はServer Componentとして実装:

  1. published_projects から content_path を取得
  2. Storage から ProjectContent をダウンロード
  3. StructuredPlot をトラバースして最初のStoryを特定
    • 新構造: parts → arcs → chapters → stories
    • 旧構造: chapters → stories(後方互換)
  4. 最初のStory IDにリダイレクト、または notFound()

2.3. メイン読書ページ

/read/[id]/[storyId]/page.tsx:

  • generateMetadata() でSEO向けメタデータを動的生成
  • getPublishedProjectContent() でコンテンツ取得
  • extractAvailableLanguages() で利用可能言語を検出
  • JSON-LD構造化データを出力(<script type="application/ld+json">

ページ構成:

┌──────────────────────────────────────┐
│ ReaderHeader                         │
│  [←戻る] Title    [🌐言語] [🔖] [≡TOC]│
├──────────────────────────────────────┤
│                                      │
│ ReaderContent                        │
│  ┌──────────────────────────────┐    │
│  │ Story Title                  │    │
│  │                              │    │
│  │ Section 1                    │    │
│  │  Manuscript content...       │    │
│  │                              │    │
│  │ Section 2                    │    │
│  │  Manuscript content...       │    │
│  └──────────────────────────────┘    │
│                                      │
├──────────────────────────────────────┤
│ Navigation (Prev/Next Story)         │
└──────────────────────────────────────┘

3. コンテンツ配信アーキテクチャ

3.1. コンテンツ取得フロー

1. Supabase Database: published_projects.content_path を取得
2. Supabase Storage: contents バケットから JSON ファイルをダウンロード
3. JSON.parse → ProjectContent として型付きオブジェクトに変換

downloadProjectContent() の実装:

async function downloadProjectContent(supabase, contentPath: string) {
  const { data: fileBlob, error } = await supabase.storage
    .from("contents")
    .download(contentPath);

  const contentText = await fileBlob.text();
  return JSON.parse(contentText) as ProjectContent;
}

コンテンツパスの形式: {userId}/{projectId}.json

3.2. Storage アクセス制御

  • contents バケット: 公開プロジェクトのコンテンツはパスベースでアクセス可能
  • RLS ポリシーにより、公開済み作品のコンテンツのみ読み取り可能

4. StructuredPlot と Manuscript の対応関係

4.1. 型構造

interface StructuredPlot {
  language: string;        // BCP 47 言語タグ
  parts?: Part[];          // 新構造(Part → Arc → Chapter → Story → Section)
  chapters?: Chapter[];    // 旧構造(後方互換)
}

interface Part {
  id: string;
  part_number: number;
  part_title: string;
  arcs: Arc[];
}

interface Arc {
  id: string;
  arc_number: number;
  arc_title: string;
  chapters: Chapter[];
}

interface Chapter {
  id: string;
  chapter_number: number;
  chapter_title: string;
  theme: string;
  expected_word_count?: string;
  stories: Story[];
}

interface Story {
  id: string;
  story_number: number;
  story_title: string;
  story_purpose: string;
  sections: Section[];
}

interface Section {
  id: string;
  section_number: number;
  section_label?: string;
  content: string;
  target_word_count?: number;
}

4.2. Manuscript の構造

interface ManuscriptVersion {
  isOriginal: boolean;
  content: string;
  createdAt: string;
  updatedAt: string;
  sourceLang?: string;
  debugInfo?: {
    prompt?: string;
    model?: string;
    temperature?: number;
    topP?: number;
    maxTokens?: number;
    inputTokens?: number;
    outputTokens?: number;
    timestamp?: string;
    contextSummary?: {
      writingPrompts?: number;
      structurePlot?: number;
      manuscripts?: number;
      total?: number;
    };
  };
}

interface Manuscript {
  [sectionId: string]: {
    versions: {
      [lang: string]: ManuscriptVersion;
    };
  };
}

4.3. セクションとマニュスクリプトの紐付け

  • StructuredPlot の各 Section.idManuscript のキーに対応
  • 各セクションは複数言語バージョンを保持(versions[lang]
  • Readerではストーリー内のセクションを順次表示

4.4. reorderManuscriptsByPlot

lib/publishUtils.ts に定義。Publish時にマニュスクリプトをプロットの物語順に並び替える関数:

  • parts → arcs → chapters → stories → sections の順でトラバース
  • section.id を出現順に収集
  • 収集したID順に Manuscript オブジェクトを再構築

これにより、Publish後のコンテンツJSONは常に物語の進行順で保存されます。

5. 言語切替

5.1. 利用可能言語の検出

extractAvailableLanguages() が全マニュスクリプトから言語コードを収集:

export function extractAvailableLanguages(manuscripts: Manuscript): string[] {
  const availableLanguages = new Set<string>();
  Object.values(manuscripts).forEach((m) => {
    if (m.versions) {
      Object.keys(m.versions).forEach((lang) => availableLanguages.add(lang));
    }
  });
  return Array.from(availableLanguages);
}

5.2. 言語選択の永続化

export const selectedLanguageAtom = atomWithStorage<string>(
  "reading-language", "ja"
);

読書中の言語設定は localStorage に永続化され、次回以降も同じ言語で表示されます。

ReaderHeader はマウント時に selectedLanguage がマニュスクリプト存在言語(availableLanguages)に含まれるかを検証し、含まれない場合は originalLanguage または先頭のマニュスクリプト言語にフォールバックします。これによりメタデータ言語と原稿言語の乖離を防ぎます。

5.2.1. メタデータ言語の制約

displayTitle の解決はマニュスクリプト存在言語に制限されます:

1. availableLanguages に selectedLanguage が含まれる → localizations[selectedLanguage].title
2. 含まれない → localizations[originalLanguage].title
3. 最終フォールバック → project.title

この制約により「日本語タイトル + 英語原稿」のミスマッチが防止されます。

5.3. マニュスクリプトの言語フォールバック

各セクションの表示テキスト決定:

1. manuscript[section.id].versions[selectedLanguage]
2. manuscript[section.id].versions[structuredPlot.language](原語)

選択言語のバージョンが存在しない場合は、原語バージョンにフォールバックします。

5.4. プロット翻訳(plotTranslations)

ProjectContent.plotTranslations は目次や見出しの多言語表示に使用:

plotTranslations?: {
  [lang: string]: {      // 言語コード
    [id: string]: PlotTranslation;  // 要素ID(Part/Chapter/Story等)
  };
}

目次(TableOfContents)での翻訳取得は getTranslatedTitle() により、以下のフォールバックチェーンで処理:

  1. 完全一致(例: zh-Hans
  2. ベース言語フォールバック(例: zhzh-Hans
  3. バリアント確認(例: zh-Hanszh
  4. 最終フォールバック: 原語の story_title / chapter_title

6. 読書UI機能

6.1. ReaderHeader

画面上部の固定ヘッダー:

要素機能
戻るボタン内部/外部ナビゲーションを判定して適切な遷移先へ
タイトルプロジェクト名 + 現在のストーリー名を動的表示
言語切替ドロップダウンでマニュスクリプト言語を変更
ブックマークライブラリへの追加/削除
TOC切替目次ドロワーの開閉

6.2. TableOfContents(目次)

components/reader/TableOfContents.tsx:

  • Sheet コンポーネントによる右側ドロワー
  • 階層表示:
    • Parts(最高レベル)
    • Arcs(Part内)
    • Chapters(Arc内)
    • Stories(Chapter内)
  • 旧構造(parts/arcsなし)にも対応
  • 現在のストーリーを primary カラーでハイライト + BookIcon
  • 翻訳フォールバックチェーンによる多言語タイトル表示

6.3. タイポグラフィ

読書画面専用のCSS(markdown-reader.css):

  • line-height: 1.625(読みやすい行間)
  • letter-spacing: 0.025em(日本語可読性向上)
  • text-align: justify(両端揃え)

フォント設定:

Atomデフォルト説明
manuscriptFontAtom'Noto Serif', serif原稿用フォント
manuscriptFontSizeAtom16pxフォントサイズ
manuscriptFontWeightAtom400フォントウェイト

デザインシステムでは、読書画面のタイポグラフィに var(--font-manuscript) を使用し、没入感を高める設計としています(09_design_system 参照)。

6.4. MarkdownRenderer

components/MarkdownRenderer.tsx:

  • Unified パイプライン(remark / rehype)
  • カスタムコンポーネント:
    • コードブロック(コピー機能付き)
    • 特殊ブロック(thought, progress, call, plan)
    • セクション内ローカルナビゲーション
  • KaTeX 数式レンダリング
  • シンタックスハイライト
  • ID接頭辞によるメッセージ間コンフリクト防止

6.5. 読書進捗

export const readingProgressAtom = atomWithStorage<Record<string, string>>(
  "reading-progress", {}
);
  • プロジェクトIDをキーに、最後に読んだStory IDを保存
  • ページ読み込み時に useReadingProgress(projectId) で更新
  • ユーザーが再度アクセスした際に続きから読める仕組み

6.6. 統計トラッキング

  • updateDailyStats() がページ読み込み時に呼び出し
  • readPublishId を IndexedDB の daily_stats テーブルに記録
  • 読書アクティビティの分析に利用

6.7. ブックマーク

  • クライアントサイドコンポーネントによる楽観的更新
  • Library API エンドポイントと連携:
    • POST /api/library - 追加
    • DELETE /api/library?projectId={id} - 削除
  • トースト通知によるフィードバック
  • 削除時の確認ダイアログ

7. コンポーネント一覧

Reader画面構成

コンポーネント場所責務
ReaderRedirectPageapp/read/[id]/page.tsxServer Component。最初のStoryにリダイレクト
ReaderPageapp/read/[id]/[storyId]/page.tsxServer Component。コンテンツ取得、メタデータ生成
ReaderHeadercomponents/reader/ReaderHeader.tsx固定ヘッダー(ナビゲーション、言語切替、ブックマーク、TOC)
ReaderContentcomponents/reader/ReaderContent.tsxストーリーコンテンツのレンダリング
TableOfContentscomponents/reader/TableOfContents.tsx目次ドロワー
MarkdownRenderercomponents/MarkdownRenderer.tsxMarkdown描画エンジン

ユーティリティ

モジュール場所責務
publishUtilslib/publishUtils.tsreorderManuscriptsByPlot, extractAvailableLanguages, fileToBase64
typeslib/types.tsProjectContent, StructuredPlot, Manuscript, Section 等の型定義
projects/servicelib/features/projects/service.tsgetPublishedProjectContent

状態管理

Atomストレージキー説明
selectedLanguageAtomreading-language読書言語設定(デフォルト: "ja")
readingProgressAtomreading-progressプロジェクト別の読書進捗
manuscriptFontAtom-原稿フォント設定
manuscriptFontSizeAtom-フォントサイズ
manuscriptFontWeightAtom-フォントウェイト

8. データフロー図

┌──────────────────────────────────────────────────────────────┐
│              /read/[id] (Redirect)                            │
│  1. SELECT content_path FROM published_projects WHERE id=?   │
│  2. Storage download: contents/{userId}/{projectId}.json     │
│  3. Traverse StructuredPlot → find first story               │
│  4. redirect(/read/{id}/{firstStoryId})                      │
└──────────────────────┬───────────────────────────────────────┘
                       │ redirect

┌──────────────────────────────────────────────────────────────┐
│           /read/[id]/[storyId] (Reader Page)                  │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ Server-side                                             │  │
│  │  1. SELECT * FROM published_projects WHERE id=?         │  │
│  │  2. Storage download → ProjectContent                   │  │
│  │  3. extractAvailableLanguages(manuscripts)               │  │
│  │  4. Check library bookmark status                       │  │
│  │  5. Generate JSON-LD structured data                     │  │
│  └────────────────────┬───────────────────────────────────┘  │
└───────────────────────┼──────────────────────────────────────┘
                        │ props

┌──────────────────────────────────────────────────────────────┐
│                   Reader UI (Client)                          │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ ReaderHeader                                            │  │
│  │  [←] Title      [🌐 Language] [🔖 Bookmark] [≡ TOC]   │  │
│  └────────────────────────────────────────────────────────┘  │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ ReaderContent                                           │  │
│  │  For each Story:                                        │  │
│  │  ┌──────────────────────────────────────────────────┐  │  │
│  │  │ Story Title                                      │  │  │
│  │  │  For each Section:                               │  │  │
│  │  │  ┌────────────────────────────────────────────┐  │  │  │
│  │  │  │ manuscript[sectionId].versions[lang].content│  │  │  │
│  │  │  │ → MarkdownRenderer → HTML                   │  │  │  │
│  │  │  └────────────────────────────────────────────┘  │  │  │
│  │  └──────────────────────────────────────────────────┘  │  │
│  └────────────────────────────────────────────────────────┘  │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ TableOfContents (Sheet/Drawer)                          │  │
│  │  Part I                                                │  │
│  │    Arc 1                                               │  │
│  │      Chapter 1: Theme                                  │  │
│  │        ● Story 1 (current) ← primary highlight        │  │
│  │        ○ Story 2                                       │  │
│  │      Chapter 2: Theme                                  │  │
│  └────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────┘

9. Supabase連携

9.1. データベースアクセス

Readerでは以下のデータを取得:

取得対象テーブルカラム
コンテンツパスpublished_projectscontent_path
メタデータpublished_projectstitle, headline, synopsis, cover_image_url, localizations, original_language 等
ブックマーク状態library_itemsuser_id, project_id

9.2. Storage アクセス

  • バケット名: contents
  • ファイルパス: {userId}/{projectId}.json
  • アクセス制御: 公開プロジェクトのコンテンツは誰でも読み取り可能

9.3. RLS ポリシー

  • published_projects: 全ユーザー SELECT 可能(true
  • library_items: ユーザー自身のアイテムのみアクセス可能

10. 関連ドキュメント

On this page