はじめに
自作のmacOS/Windowsデスクトップアプリ「IMEIndicatorClock」の設定をブラウザ上でプレビュー・カスタマイズできるWebアプリを作りました。
Live Demo: https://obott9.github.io/ime-simulator/
元々機械設計技術者で、制御系・組み込みからキャリアをスタートし、携帯(Brew)→ iOS/Android → Javaサーバーサイドと36年間ソフトウェア開発に携わってきました。業務ではC、C++、Java、Objective-C、PHPが中心で、モダンフロントエンド(React等)は経験が浅い状態から、ポートフォリオとしてフルスタックWebに挑戦しました。
この記事では、GitHub Pages・Render・Supabase・GitHub Actionsの各サービスの設定内容をコード付きで紹介するので、同じ構成で何かを作りたい方の参考になれば幸いです。
Claude AIとの共同開発プロセスについては別記事に書きました。
何を作ったか
IMEIndicatorClockは、現在のIME言語(日本語/英語/韓国語/中国語)に応じて色が変わる時計アプリです。このWebシミュレーターでは:
- 時計プレビュー(アナログ / デジタル)をリアルタイムでカスタマイズ
- IMEインジケーターの色・サイズ・文字を言語ごとに設定
- プリセットの保存・共有・いいね(要ログイン)
- UI自体も5言語対応(日/英/韓/繁体字/簡体字)
WebブラウザではシステムのIME状態を取得できないため、言語ボタンによる手動切替でシミュレーションしています。
アーキテクチャ全体像
ブラウザ ─── GitHub Pages (React SPA)
├── Supabase Auth (認証: 直接接続)
└── Render (Express API) ─── Supabase (PostgreSQL)
| 層 | 技術 | ホスティング | 費用 |
|---|---|---|---|
| フロントエンド | React 19 + Vite 7 + Tailwind CSS 4 | GitHub Pages | 無料 |
| バックエンド | Express 5 + Node.js 22 | Render (Docker) | 無料枠 |
| データベース | PostgreSQL | Supabase Cloud | 無料枠 |
| 認証 | Supabase Auth (GitHub OAuth) | Supabase Cloud | 無料枠 |
| CI/CD | GitHub Actions | GitHub | 無料 |
全部無料で運用しています。
なぜ Express API を挟んだか
フロントエンドから直接 Supabase を叩く構成も可能ですが、以下の理由で3層にしました:
-
セキュリティ:
service_roleキー(管理者権限)をフロントエンドに露出しない - 柔軟性: RLS に依存せず、サーバーサイドで認可ロジックを制御できる
- 拡張性: 将来的に外部API連携やバッチ処理を追加しやすい
認証だけは直接接続
OAuthリダイレクトフローは Supabase Auth SDK が直接処理する必要があるため、認証のみフロントエンド → Supabase 直接接続としています。取得したJWTをAPI呼び出し時に Authorization: Bearer ヘッダーで送信し、バックエンドで検証します。
プロジェクト構成
ime-simulator/
├── frontend/ # React + Vite
│ ├── src/
│ │ ├── components/ # UIコンポーネント (14ファイル)
│ │ ├── contexts/ # AuthContext
│ │ ├── hooks/ # useSettings, useAuth, usePresets
│ │ ├── lib/ # api, dateFormat, supabase
│ │ └── i18n.js # 5言語対応設定
│ ├── public/locales/ # 翻訳ファイル (5言語)
│ └── vite.config.js
├── backend/ # Node.js + Express
│ ├── src/
│ │ ├── routes/presets.js # プリセットAPI (11エンドポイント)
│ │ ├── middleware/auth.js # JWT検証
│ │ └── lib/supabase.js # Supabase Admin初期化
│ └── Dockerfile # Renderデプロイ用
├── .github/workflows/
│ └── deploy.yml # GitHub Pages自動デプロイ
└── supabase-setup.sql # DBスキーマ + RLS + シードデータ
1. Supabase の設定
テーブル設計
設定内容はJSONBカラムに丸ごと格納する設計にしました。設定項目が多いのでカラムごとに分けると管理が大変になるためです。
-- プリセットテーブル
CREATE TABLE presets (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
settings JSONB NOT NULL, -- 設定をまるごとJSON格納
share_code TEXT UNIQUE, -- 共有URL用の8文字コード
likes_count INTEGER DEFAULT 0, -- 非正規化: パフォーマンス用
is_default BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- いいねテーブル
CREATE TABLE likes (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,
preset_id UUID REFERENCES presets(id) ON DELETE CASCADE NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(user_id, preset_id) -- 1ユーザー1プリセットに1いいね
);
likes_countをpresetsテーブルに持たせているのは非正規化ですが、人気ランキングのSELECTで毎回JOINするコストを避けるためです。
インデックス
CREATE INDEX idx_presets_user_id ON presets(user_id);
CREATE INDEX idx_presets_share_code ON presets(share_code);
CREATE INDEX idx_presets_is_default ON presets(is_default);
CREATE INDEX idx_presets_likes_count ON presets(likes_count DESC); -- 人気順
CREATE INDEX idx_likes_user_id ON likes(user_id);
CREATE INDEX idx_likes_preset_id ON likes(preset_id);
Row Level Security (RLS)
バックエンドは service_role キーでRLSをバイパスしますが、万一フロントエンドから直接アクセスされた場合の防御としてRLSも設定しています。
ALTER TABLE presets ENABLE ROW LEVEL SECURITY;
ALTER TABLE likes ENABLE ROW LEVEL SECURITY;
-- 誰でもデフォルトプリセットを読める
CREATE POLICY "Default presets are readable by everyone"
ON presets FOR SELECT
USING (is_default = TRUE);
-- 共有コード付きプリセットは誰でも読める
CREATE POLICY "Shared presets are readable by everyone"
ON presets FOR SELECT
USING (share_code IS NOT NULL);
-- 自分のプリセットだけ読み書きできる
CREATE POLICY "Users can read own presets"
ON presets FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Users can create own presets"
ON presets FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can update own presets"
ON presets FOR UPDATE
USING (auth.uid() = user_id AND is_default = FALSE)
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can delete own presets"
ON presets FOR DELETE
USING (auth.uid() = user_id AND is_default = FALSE);
デフォルトプリセット(is_default = TRUE)は更新・削除不可にしているのがポイントです。
updated_atの自動更新トリガー
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
CREATE TRIGGER presets_updated_at
BEFORE UPDATE ON presets
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
シードデータ(デフォルトプリセット)
Dark / Light / Classic の3プリセットをシードとして挿入しています。settingsカラムにはJSONBで時計やインジケーターの全設定が入ります。
2. バックエンド(Express + Render)の設定
Expressサーバー
// backend/src/index.js
import express from 'express';
import cors from 'cors';
import presetsRouter from './routes/presets.js';
const app = express();
const PORT = process.env.PORT || 3001;
const CORS_ORIGIN = process.env.CORS_ORIGIN || 'http://localhost:5174';
app.use(cors({ origin: CORS_ORIGIN.split(','), credentials: true }));
app.use(express.json());
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok' });
});
app.use('/api/presets', presetsRouter);
app.listen(PORT, () => {
console.log(`Backend running on port ${PORT}`);
});
CORS_ORIGINをカンマ区切りで複数オリジン対応にしています。開発時は localhost:5174、本番は obott9.github.io を指定します。
JWT検証ミドルウェア
2種類用意しました:
// backend/src/middleware/auth.js
import { supabaseAdmin } from '../lib/supabase.js';
// 認証必須
export async function requireAuth(req, res, next) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authorization header required' });
}
try {
const token = header.slice(7);
const { data, error } = await supabaseAdmin.auth.getUser(token);
if (error || !data.user) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
req.user = data.user;
next();
} catch {
res.status(500).json({ error: 'Auth service error' });
}
}
// 認証任意(ログインしてればユーザー情報をセット)
export async function optionalAuth(req, res, next) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
req.user = null;
return next();
}
try {
const token = header.slice(7);
const { data, error } = await supabaseAdmin.auth.getUser(token);
req.user = error ? null : data.user;
next();
} catch {
req.user = null;
next();
}
}
requireAuthは認証失敗時に401を返しますが、optionalAuthはエラーでもreq.user = nullで次へ進みます。未ログインでもデフォルト/人気プリセットは閲覧可能にするためです。
APIエンドポイント(抜粋)
11エンドポイントの中から、いいねトグルの実装を紹介します:
// POST /api/presets/:id/like — いいね切替
router.post('/:id/like', requireAuth, async (req, res) => {
const presetId = req.params.id;
const userId = req.user.id;
// 既存いいねチェック
const { data: existing } = await supabaseAdmin
.from('likes')
.select('id')
.eq('user_id', userId)
.eq('preset_id', presetId)
.single();
let liked;
if (existing) {
// いいね済み → 取り消し
await supabaseAdmin.from('likes').delete().eq('id', existing.id);
liked = false;
} else {
// 未いいね → 追加
const { error } = await supabaseAdmin
.from('likes')
.insert({ user_id: userId, preset_id: presetId });
if (error) return res.status(500).json({ error: error.message });
liked = true;
}
// likes_countを実カウントで同期(非正規化カウントの整合性保証)
const { count } = await supabaseAdmin
.from('likes')
.select('*', { count: 'exact', head: true })
.eq('preset_id', presetId);
await supabaseAdmin
.from('presets')
.update({ likes_count: count })
.eq('id', presetId);
res.json({ liked });
});
likes_countの更新は、インクリメント/デクリメントではなく毎回実カウントで同期しています。多少のオーバーヘッドはありますが、整合性を確実に保てます。
共有コード生成
import { randomBytes } from 'node:crypto';
// POST /api/presets/:id/share
router.post('/:id/share', requireAuth, async (req, res) => {
// ...所有者チェック省略...
// 既に共有コードがあればそのまま返す
if (existing.share_code) {
return res.json({ share_code: existing.share_code });
}
// 8文字の暗号学的に安全なランダムコード
const code = randomBytes(4).toString('hex');
const { error } = await supabaseAdmin
.from('presets')
.update({ share_code: code })
.eq('id', req.params.id);
if (error) return res.status(500).json({ error: error.message });
res.json({ share_code: code });
});
randomBytes(4)で4バイト → 16進数で8文字のコードを生成。DB側でUNIQUE制約があるので衝突時はリトライが必要ですが、2^32通りあるので現実的には問題なしです。
Dockerfile(Render用)
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
COPY src/ src/
EXPOSE 3001
CMD ["node", "src/index.js"]
Alpine ベースで軽量に。--omit=devで本番不要な依存を除外しています。
Render の設定
- Service Type: Web Service
-
Root Directory:
backend/ - Docker: 上記Dockerfileを使用
-
環境変数:
-
SUPABASE_URL: Supabase プロジェクトURL -
SUPABASE_SERVICE_KEY: service_role キー -
CORS_ORIGIN:https://obott9.github.io -
PORT:3001
-
コールドスタート問題: Render無料枠は一定時間アクセスがないとサーバーがスリープします。復帰に最大30秒かかるため、フロントエンドに「サーバーに接続中...」のバナーやスピナーを表示して、ユーザーに正常動作であることを伝えるUIにしています。
3. フロントエンド(React + Vite)の設定
Vite設定
// frontend/vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
base: '/ime-simulator/', // GitHub Pagesのサブパス
server: {
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
})
ポイント:
-
base: '/ime-simulator/'— GitHub Pages はhttps://username.github.io/repo-name/のサブパスになるため必須 - 開発時は
/apiへのリクエストをローカルバックエンドにプロキシ
APIクライアント
// frontend/src/lib/api.js
import { supabase } from './supabase';
const API_BASE = import.meta.env.VITE_API_URL || '/api';
async function getAuthHeaders() {
if (!supabase) return {};
const { data } = await supabase.auth.getSession();
const token = data?.session?.access_token;
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function request(path, options = {}) {
const authHeaders = await getAuthHeaders();
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...authHeaders,
...options.headers,
},
});
if (res.status === 204) return null;
const data = await res.json();
if (!res.ok) throw new Error(data.error || `Request failed: ${res.status}`);
return data;
}
export const api = {
getDefaultPresets: () => request('/presets/default'),
getPopularPresets: () => request('/presets/popular'),
getMyPresets: () => request('/presets/mine'),
// ...他のエンドポイント
};
VITE_API_URLが設定されていれば本番APIを、なければ/api(Viteプロキシ経由でローカルバックエンド)を使用します。APIコール時に自動でSupabaseセッションからJWTを取得してヘッダーに付与しています。
アナログ時計の描画 — Canvasを使わないアプローチ
当初はCanvasで描画する予定でしたが、DOM要素 + CSS transformで実装しました。Canvasより宣言的に書けて、Reactとの相性が良いためです。
// frontend/src/components/AnalogClock.jsx
import { useState, useEffect } from 'react';
export default function AnalogClock({ size, analogColor, showSeconds }) {
const [now, setNow] = useState(new Date());
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(timer);
}, []);
const hours = now.getHours() % 12;
const minutes = now.getMinutes();
const seconds = now.getSeconds();
// 角度計算(三角関数)
const hourAngle = (hours + minutes / 60) * 30; // 12時間で360°
const minuteAngle = (minutes + seconds / 60) * 6; // 60分で360°
const secondAngle = seconds * 6; // 60秒で360°
return (
<div style={{ position: 'relative', width: size, height: size }}>
{/* 文字盤の目盛り: 12個の矩形をrotateで配置 */}
{Array.from({ length: 12 }, (_, i) => (
<div key={i} style={{
position: 'absolute', inset: 0,
transform: `rotate(${i * 30}deg)`,
}}>
<div style={{
position: 'absolute', left: '50%', top: size * 0.05,
width: size * 0.02, height: size * 0.1,
transform: 'translateX(-50%)',
backgroundColor: analogColor,
}} />
</div>
))}
{/* 時針: 外側divでrotate → 内側divで「下から上」に伸びる棒 */}
<div style={{ position: 'absolute', inset: 0, transform: `rotate(${hourAngle}deg)` }}>
<div style={{
position: 'absolute', left: '50%', bottom: '50%',
width: size * 0.04, height: size * 0.25,
transform: 'translateX(-50%)',
backgroundColor: analogColor,
}} />
</div>
{/* 分針・秒針も同じパターン(高さが異なる) */}
{/* ...省略... */}
</div>
);
}
ポイント:
- 外側の
div(inset: 0で親と同サイズ)にtransform: rotate()で角度を指定 - 内側の
divはbottom: 50%で「中心から上に伸びる棒」を作成 -
sizeprop に対する比率で全サイズを計算するのでレスポンシブ対応
IMEインジケーター — 3層構造のグラデーション
// frontend/src/components/IMEIndicator.jsx
function hexToRgba(hex, alpha) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
export default function IMEIndicator({ label, backgroundColor, size, fontSizeRatio = 0.5, opacity }) {
const color = backgroundColor;
return (
<div style={{ position: 'relative', width: size, height: size, opacity: opacity / 100 }}>
{/* Layer 1: 外側グロー (radial-gradient) */}
<div style={{
position: 'absolute', inset: 0, borderRadius: '50%',
background: `radial-gradient(circle, ${hexToRgba(color, 0.6)}, ${hexToRgba(color, 0.3)}, transparent)`,
}} />
{/* Layer 2: 内側サークル (80%サイズ、linear-gradient + 半透明ボーダー) */}
<div style={{
position: 'absolute', left: '10%', top: '10%', width: '80%', height: '80%',
borderRadius: '50%',
background: `linear-gradient(to bottom right, ${color}, ${hexToRgba(color, 0.7)})`,
border: '2px solid rgba(255, 255, 255, 0.3)',
}} />
{/* Layer 3: テキスト */}
<div style={{
position: 'absolute', inset: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#ffffff', fontWeight: 'bold',
fontSize: size * fontSizeRatio,
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
}}>
{label}
</div>
</div>
);
}
hex色からRGBAへの変換関数を用意し、グラデーションの透明度を動的に制御しています。3層のレイヤーで奥行き感のあるインジケーターを表現しています。
設定状態の管理 — useSettingsフック
// frontend/src/hooks/useSettings.js(抜粋)
export default function useSettings() {
const [settings, setSettings] = useState(DEFAULT_SETTINGS);
const [currentLang, setCurrentLang] = useState('ja');
const [activePresetId, setActivePresetId] = useState(null);
const [isDirty, setIsDirty] = useState(false);
const snapshotRef = useRef(null);
// 時計の背景色: IMEインジケーターの色を使うか、固定色か
const clockBackgroundColor = settings.clock.useIndicatorColors
? settings.indicator.colors[currentLang]
: currentLang === 'en'
? settings.clock.bgColorOff
: settings.clock.bgColorOn;
// プリセット読み込み時にスナップショットを保存
const loadPreset = useCallback((presetId, presetSettings) => {
setSettings(presetSettings);
setActivePresetId(presetId);
setIsDirty(false);
snapshotRef.current = JSON.stringify(presetSettings);
}, []);
// 設定変更時にdirtyフラグを立てる
const markDirty = useCallback(() => {
if (snapshotRef.current !== null) {
setIsDirty(true);
}
}, []);
// ...各設定の更新関数(すべてmarkDirty付き)
}
プリセットを読み込んだ時点のスナップショットを useRef で保持し、その後の変更を検知して「未保存の変更あり」を表示するダーティフラグを管理しています。
5言語対応(i18n)
// frontend/src/i18n.js
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: en },
ja: { translation: ja },
ko: { translation: ko },
'zh-Hant': { translation: zhHant },
'zh-Hans': { translation: zhHans },
},
fallbackLng: 'en',
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
convertDetectedLanguage: (lng) => {
// ブラウザの言語コードを適切にマッピング
if (lng.startsWith('zh-Hant') || lng === 'zh-TW' || lng === 'zh-HK') return 'zh-Hant';
if (lng.startsWith('zh')) return 'zh-Hans';
if (lng.startsWith('ko')) return 'ko';
if (lng.startsWith('ja')) return 'ja';
return 'en';
},
},
});
convertDetectedLanguageで、ブラウザが返すzh-TWやzh-HKをzh-Hant(繁体字)に、zh-CNをzh-Hans(簡体字)に正しくマッピングしています。中国語の繁体字/簡体字の振り分けは見落としがちなポイントです。
4. GitHub Actions(CI/CD)の設定
# .github/workflows/deploy.yml
name: Deploy Frontend to GitHub Pages
on:
push:
branches: [main]
workflow_dispatch: # 手動トリガーも可能
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true # 同時デプロイ防止
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: frontend
run: npm ci
- name: Build
working-directory: frontend
env:
VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
VITE_API_URL: ${{ secrets.VITE_API_URL }}
run: npm run build
- uses: actions/upload-pages-artifact@v3
with:
path: frontend/dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
ポイント:
-
環境変数はGitHub Secretsで管理:
VITE_プレフィックスでViteのビルド時に注入 -
concurrencyで同時デプロイ防止: 短時間に複数pushしても最後のビルドだけがデプロイされる -
npmキャッシュ:
cache-dependency-pathでfrontendのpackage-lock.jsonを指定 - mainブランチへのpushで自動デプロイ
GitHub側の設定
リポジトリの Settings → Secrets and variables → Actions に以下を登録:
| Secret名 | 値 |
|---|---|
VITE_SUPABASE_URL |
https://xxxxx.supabase.co |
VITE_SUPABASE_ANON_KEY |
Supabaseのanon(公開)キー |
VITE_API_URL |
https://api-ime-simulator.onrender.com/api |
Settings → Pages で Source を GitHub Actions に設定します。
おわりに
GitHub Pages + Render + Supabase の無料枠だけで、認証・DB・API・CI/CDを備えたフルスタックアプリを運用できています。個人開発やポートフォリオ用途には十分な構成です。
同じ構成でアプリを作る際に、この記事が参考になれば幸いです。Claude AIとの共同開発プロセスについては別記事で詳しく書いています。
ソースコード: https://github.com/obott9/ime-simulator
Live Demo: https://obott9.github.io/ime-simulator/
関連デスクトップアプリ: macOS版 / Windows版
🙏 Support / 開発者をサポート
このアプリ(記事)が役に立ちましたら、応援いただけると大変励みになります。
IMEIndicatorClock をはじめ、多言語ユーザー向けツールの開発を続けるために、皆さまのサポートをお待ちしています。
Developed in collaboration with Claude AI