• #Vue
  • #実装ドキュメント
  • #メメントモリ
  • #Canvas API
  • #Capacitor
  • #Electron
開発アクティブ

Memento Mori - 名言機能 & シェア機能 実装ドキュメント

概要

既存のメメントモリアプリに以下の機能を追加する:

  1. 名言カスタマイズ機能
  2. シェア画像生成機能
  3. マルチプラットフォーム対応(Web / PC / スマホ)

技術スタック

レイヤー技術
フロントエンドVue 3 + Vite
状態管理Pinia
スタイリングTailwind CSS
画像生成HTML Canvas API
PC版Electron
スマホ版Capacitor
バックエンドFirebase (Auth + Firestore)
ホスティングVercel or Firebase Hosting

ディレクトリ構成(案)

memento-mori/
├── src/
│   ├── components/
│   │   ├── LifeGrid.vue          # 人生グリッド表示
│   │   ├── QuoteDisplay.vue      # 名言表示
│   │   ├── QuoteSettings.vue     # 名言設定パネル
│   │   ├── ShareButton.vue       # シェアボタン
│   │   └── SharePreview.vue      # シェア画像プレビュー
│   ├── composables/
│   │   ├── useQuotes.ts          # 名言データ管理
│   │   ├── useShareImage.ts      # 画像生成ロジック
│   │   └── useUserSettings.ts    # ユーザー設定
│   ├── data/
│   │   ├── quotes/
│   │   │   ├── stoicism.json     # ストア派哲学
│   │   │   ├── zen.json          # 禅
│   │   │   ├── entrepreneurs.json # 起業家
│   │   │   └── index.ts
│   │   └── categories.ts
│   ├── stores/
│   │   ├── quoteStore.ts
│   │   └── userStore.ts
│   ├── views/
│   │   ├── HomeView.vue
│   │   └── SettingsView.vue
│   └── utils/
│       ├── canvasRenderer.ts     # Canvas描画
│       └── shareUtils.ts         # シェア機能
├── electron/                      # Electron設定
├── android/                       # Capacitor (Android)
├── ios/                          # Capacitor (iOS)
└── capacitor.config.ts

機能詳細

1. 名言カスタマイズ機能

1.1 プリセットカテゴリ

// src/data/categories.ts
export const QUOTE_CATEGORIES = [
  { id: 'stoicism', name: 'ストア派哲学', icon: '🏛️' },
  { id: 'zen', name: '', icon: '🧘' },
  { id: 'entrepreneurs', name: '起業家', icon: '🚀' },
  { id: 'literature', name: '文学', icon: '📚' },
  { id: 'custom', name: '自分で追加', icon: '✏️' },
] as const;

1.2 名言データ構造

// types/quote.ts
interface Quote {
  id: string;
  text: string;
  author: string;
  source?: string;        // 出典(本のタイトルなど)
  category: string;
  language: 'ja' | 'en';
  isFavorite?: boolean;   // お気に入りフラグ
  isCustom?: boolean;     // ユーザー追加フラグ
}

1.3 名言表示コンポーネント

<!-- src/components/QuoteDisplay.vue -->
<template>
  <div class="quote-container">
    <transition name="fade" mode="out-in">
      <div :key="currentQuote.id" class="quote-content">
        <p class="quote-text">{{ currentQuote.text }}</p>
        <p class="quote-author">── {{ currentQuote.author }}</p>
      </div>
    </transition>
    
    <div class="quote-actions">
      <button @click="toggleFavorite" :class="{ active: currentQuote.isFavorite }">
      </button>
      <button @click="nextQuote"></button>
      <button @click="openShare">
        <ShareIcon />
      </button>
    </div>
  </div>
</template>

1.4 表示設定オプション

// types/settings.ts
interface QuoteSettings {
  enabledCategories: string[];     // 有効なカテゴリ
  displayMode: 'random' | 'sequential';
  rotationInterval: number;        // 秒(0 = 手動のみ)
  showAuthor: boolean;
  fontSize: 'small' | 'medium' | 'large';
}

2. シェア画像生成機能

2.1 Canvas描画ロジック

// src/utils/canvasRenderer.ts
interface ShareImageOptions {
  quote: Quote;
  lifeGrid: LifeGridData;
  theme: 'dark' | 'light';
  size: { width: number; height: number };
}

export async function generateShareImage(options: ShareImageOptions): Promise<Blob> {
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d')!;
  
  canvas.width = options.size.width;   // 1200
  canvas.height = options.size.height; // 630 (OGP推奨サイズ)
  
  // 背景
  ctx.fillStyle = options.theme === 'dark' ? '#1a1a1a' : '#ffffff';
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  
  // ミニグリッド描画(簡略版)
  drawMiniLifeGrid(ctx, options.lifeGrid, {
    x: 50,
    y: 50,
    width: 400,
    height: 300
  });
  
  // 名言テキスト
  ctx.font = '32px "Noto Sans JP"';
  ctx.fillStyle = options.theme === 'dark' ? '#ffffff' : '#333333';
  wrapText(ctx, options.quote.text, 500, 100, 650, 40);
  
  // 著者名
  ctx.font = '24px "Noto Sans JP"';
  ctx.fillStyle = '#888888';
  ctx.fillText(`── ${options.quote.author}`, 500, 350);
  
  // ブランドロゴ
  ctx.font = '18px monospace';
  ctx.fillText('MEMENTO MORI', 50, canvas.height - 30);
  
  return new Promise((resolve) => {
    canvas.toBlob((blob) => resolve(blob!), 'image/png');
  });
}

2.2 シェア機能(Web Share API + フォールバック)

// src/utils/shareUtils.ts
export async function shareToX(imageBlob: Blob, quote: Quote): Promise<void> {
  const text = `${quote.text}」── ${quote.author}\n\n#MementoMori`;
  
  // Web Share API対応ブラウザ
  if (navigator.share && navigator.canShare) {
    const file = new File([imageBlob], 'memento-mori.png', { type: 'image/png' });
    
    if (navigator.canShare({ files: [file] })) {
      await navigator.share({
        text,
        files: [file],
      });
      return;
    }
  }
  
  // フォールバック: 画像をダウンロード + X投稿画面を開く
  downloadImage(imageBlob, 'memento-mori.png');
  const xUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}`;
  window.open(xUrl, '_blank');
}

export function downloadImage(blob: Blob, filename: string): void {
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  a.click();
  URL.revokeObjectURL(url);
}

2.3 シェアプレビューモーダル

<!-- src/components/SharePreview.vue -->
<template>
  <div v-if="isOpen" class="modal-overlay" @click.self="close">
    <div class="modal-content">
      <h3>シェア画像プレビュー</h3>
      
      <div class="preview-container">
        <img v-if="previewUrl" :src="previewUrl" alt="シェア画像" />
        <div v-else class="loading">生成中...</div>
      </div>
      
      <div class="theme-toggle">
        <button @click="theme = 'dark'" :class="{ active: theme === 'dark' }">
          ダーク
        </button>
        <button @click="theme = 'light'" :class="{ active: theme === 'light' }">
          ライト
        </button>
      </div>
      
      <div class="share-buttons">
        <button @click="shareX" class="btn-x">
          𝕏 でシェア
        </button>
        <button @click="download" class="btn-download">
          画像を保存
        </button>
      </div>
    </div>
  </div>
</template>

3. データ永続化

3.1 Firebase Firestore スキーマ

users/
  {userId}/
    settings/
      quote: QuoteSettings
      display: DisplaySettings
    customQuotes/
      {quoteId}: Quote
    favorites/
      {quoteId}: { addedAt: Timestamp }

3.2 オフライン対応(Capacitor)

// src/composables/useOfflineSync.ts
import { Preferences } from '@capacitor/preferences';

export function useOfflineSync() {
  // ローカルにキャッシュ
  async function cacheQuotes(quotes: Quote[]) {
    await Preferences.set({
      key: 'cached_quotes',
      value: JSON.stringify(quotes),
    });
  }
  
  // オフライン時はキャッシュから読み込み
  async function loadCachedQuotes(): Promise<Quote[]> {
    const { value } = await Preferences.get({ key: 'cached_quotes' });
    return value ? JSON.parse(value) : [];
  }
  
  return { cacheQuotes, loadCachedQuotes };
}

4. マルチプラットフォーム対応

4.1 Capacitor設定

// capacitor.config.ts
import { CapacitorConfig } from '@capacitor/cli';

const config: CapacitorConfig = {
  appId: 'com.example.mementomori',
  appName: 'Memento Mori',
  webDir: 'dist',
  server: {
    androidScheme: 'https'
  },
  plugins: {
    SplashScreen: {
      launchShowDuration: 2000,
      backgroundColor: '#1a1a1a',
    },
    Share: {
      // シェア機能用
    },
    Preferences: {
      // ローカルストレージ
    }
  }
};

export default config;

4.2 Electron設定

// electron/main.js
const { app, BrowserWindow } = require('electron');
const path = require('path');

function createWindow() {
  const win = new BrowserWindow({
    width: 1200,
    height: 800,
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: true,
    },
    // フレームレスでミニマルに
    // frame: false,
    // transparent: true,
  });

  // 本番
  win.loadFile('dist/index.html');
  
  // 開発時
  // win.loadURL('http://localhost:5173');
}

app.whenReady().then(createWindow);

4.3 プラットフォーム判定

// src/utils/platform.ts
import { Capacitor } from '@capacitor/core';

export function getPlatform(): 'web' | 'ios' | 'android' | 'electron' {
  if (typeof window !== 'undefined' && (window as any).electron) {
    return 'electron';
  }
  
  if (Capacitor.isNativePlatform()) {
    return Capacitor.getPlatform() as 'ios' | 'android';
  }
  
  return 'web';
}

export function isNative(): boolean {
  return ['ios', 'android'].includes(getPlatform());
}

開発フロー

Phase 1: コア機能(1-2週間)

  • Vue 3 + Viteでプロジェクトセットアップ
  • 既存のLife Grid機能を移植
  • 名言データ(JSON)の準備
  • 名言表示コンポーネント実装
  • カテゴリ選択UI

Phase 2: シェア機能(1週間)

  • Canvas画像生成
  • シェアプレビューモーダル
  • Web Share API + フォールバック
  • OGPメタタグ設定

Phase 3: ユーザー機能(1週間)

  • Firebase Auth導入
  • カスタム名言追加機能
  • お気に入り機能
  • 設定の永続化

Phase 4: マルチプラットフォーム(1-2週間)

  • Capacitorセットアップ
  • iOS/Androidビルド確認
  • Electronセットアップ
  • オフライン対応

名言データサンプル

// src/data/quotes/stoicism.json
{
  "quotes": [
    {
      "id": "seneca-001",
      "text": "私たちが生きる時間が短いのではない、それらその大部分を無駄にしてしまうのだ。",
      "author": "セネカ",
      "source": "人生の短さについて",
      "category": "stoicism",
      "language": "ja"
    },
    {
      "id": "marcus-001",
      "text": "今日という日を、最後の日のつもりで生きよ。",
      "author": "マルクス・アウレリウス",
      "source": "自省録",
      "category": "stoicism",
      "language": "ja"
    },
    {
      "id": "epictetus-001",
      "text": "我々を苦しめるのは物事そのものではなく、物事に対する我々の判断である。",
      "author": "エピクテトス",
      "source": "提要",
      "category": "stoicism",
      "language": "ja"
    }
  ]
}

参考リンク