抽選・チケット争奪戦・開演前の座席確認——その席から実際に何が見えるのかを確かめよう。 リアル座席ビュー · 真实视角图集 —— 内部コードネーム
seatmap-real
SeatView は、日本(および一部海外)のライブ会場のリアルな座席ビュー写真を集約します。ユーザーは会場公式の座席表上に自分の席をマークし、その位置から撮った実写を投稿。他のユーザーは座席表のマーカーをクリックすると、その席のリアルな視界を Lightbox でプレビューでき、1 枚の視点写真への直接リンクも共有できます。抽選やチケット争奪、開演前の座席確認のときに、より賢い判断ができます。
閲覧、投稿、匿名評価はすべて SeatView への登録不要です。投稿は IP レート制限 + Cloudflare Turnstile で不正利用を防ぎ、評価は会場 + ソルト付き IP hash で重複排除とレート制限を行います。コメントは giscus で GitHub Discussions に接続します。スタック全体が Cloudflare のみで動作します:Workers(SSR + 静的アセット)+ D1 + KV + R2。
公開サイト 👉 seat.genchi.top
プレビュー · 機能 · 技術スタック · クイックスタート · デプロイ · 仕組み · プロジェクト構成 · コントリビュート
以下のスクリーンショットは公開サイト seat.genchi.top(簡体字中国語 · ライトテーマ)から取得したものです。

コア体験はたった二段階——座席表で席をタップ → その席のリアルな視界を見る:
| ① 座席表のマーカー | ② リアルビュー Lightbox |
|---|---|
![]() |
![]() |
| 会場公式の座席表(複数レイヤー / 複数エリアの切り替え対応)上で、他ユーザーがマークした座席ポイントを表示。隣接するポイントは自動で集約し件数を表示します。 | マーカーやサムネイルをクリックすると、その席の実写 + 座席番号 / テキスト説明を Lightbox で表示します。 |
| ③ 会場の全投稿(ウォーターフォール) | 見たい会場(クラウドソーシング一時置き場) | ダークテーマ |
|---|---|---|
![]() |
![]() |
![]() |
| 座席表の下に、その会場のリアルビュー投稿の全件を masonry ウォーターフォールで表示します。 | 見たい会場がない?名前を書けば、他のユーザーが +1 で賛同できます。 | ライト / ダーク / システム追従の三段階テーマ切り替え。 |
?photo= として URL に反映し、共有ボタンで会場 / エリア文脈つきの deep link をコピーできます。下部のウォーターフォールでその会場の全投稿を表示します。venue:<id> に厳密マッピングされた giscus コメントで、言語パスや座席表タブをまたいで同じ議論を共有します。/zh /ja /en /ko の 4 プレフィックスルーティング、ルート直下 / は Accept-Language で自動リダイレクト(zh / ja は対等な二軸、en / ko はアクセシビリティのための翻訳レイヤー)。/admin は Cloudflare Access のエッジ認証で保護、投稿のソフト削除に対応。x-default を含む)を出力。会場ページは MusicVenue(評価が十分にある場合は aggregateRating 付き)/ BreadcrumbList / 座席写真の ImageGallery 構造化データを注入し、トップページは WebSite / Organization を注入します。サイトルートでは /sitemap.xml(ロケール × パス + hreflang の代替)と /llms.txt(AI 向けのプレーンテキスト会場インデックス)を配信し、価値の低いページ(一時置き場 / 管理画面)は noindex,follow を付与します。| レイヤー | 採用 | 説明 |
|---|---|---|
| フロントエンドフレームワーク | Astro 6.4 + React 19.2 Islands | 大部分は静的化、インタラクティブなコンポーネントは React |
| デプロイアダプター | @astrojs/cloudflare v13.7 |
Astro 6 は Cloudflare Pages 非対応となり、全面的に Workers を使用(SSR + 静的アセットを同一 Worker で) |
| ランタイムバインディング | import { env } from "cloudflare:workers" |
Astro v6 は Astro.locals.runtime.env を削除。型は src/env.d.ts の Cloudflare.Env を参照 |
| スタイリング | Tailwind v4.3(Vite プラグイン @tailwindcss/vite) |
独立した tailwind.config なし。デザイントークンは src/styles/global.css に記述 |
| UI コンポーネント | すべて手書き(DESIGN.md のトークンに準拠) |
components.json は存在するが、UI は shadcn/ui 生成ではない |
| アイコン | lucide-react |
|
| 検索 | Fuse.js(クライアント側で全件) | 会場 ≤ 200、バンドル内の全件検索でレイテンシゼロ |
| データベース | Cloudflare D1 + Drizzle ORM | photos / staging / photo corrections / venue ratings。schema は src/server/db/schema.ts。マイグレーションは drizzle-kit generate |
| レート制限 | Cloudflare KV(RATE_LIMIT) |
投稿、一時置き場、座席番号修正、評価の日次カウントとクールダウン。TTL で自動失効 |
| 画像ストレージ | Cloudflare R2(BUCKET) |
バインディング直接書き込み、presigned URL ではない |
| ボット対策 | Cloudflare Turnstile | 二段階:フロントエンド token → バックエンド siteverify |
| コメント | giscus + @giscus/react |
GitHub Discussions を使用。コメントドロワーは初回オープン時だけ遅延ロードし、サイトのライト / ダークテーマに追従 |
| 匿名評価 | D1 集約テーブル + React island | 4項目の 1〜5 星評価。venue_id + ip_hash で重複排除し、venue_rating_agg から次元別集約を読み取り |
| 画像処理 | browser-image-compression |
長辺 1920px / WebP / EXIF 除去 / 約 500KB |
| Lightbox | yet-another-react-lightbox v3 |
|
| ウォーターフォール | react-photo-album(masonry) |
|
| 座席表のズーム | react-zoom-pan-pinch v4.0 |
setTransform / resetTransform でプログラム的にズーム |
| i18n | Astro 組み込み i18n ルーティング | /zh /ja /en /ko の 4 プレフィックス、ルート直下は 302 |
| SEO / 構造化データ | 手書き JSON-LD + hreflang + sitemap / llms.txt | src/lib/seo/(純粋関数 + 単体テスト):canonical / 4 ロケールの hreflang / MusicVenue·Breadcrumb·ImageGallery JSON-LD / /sitemap.xml / /llms.txt |
| ULID | 自前実装(src/server/id.ts) |
ulid パッケージは不使用(import 時に detectPrng() が workerd で例外を投げるため) |
[!NOTE] いくつかの実装は初期の PRD / research の記述と意図的に異なります。本リポジトリを正とします。詳しくは 仕組み → 主要な実装上のトレードオフ を参照。
[!IMPORTANT] 前提:Node ≥ 22.12(Astro 6 の要件)。
# 1. 依存関係をインストール
npm install
# 2. ローカルのシークレットを準備(デフォルトは Cloudflare ドキュメントの「常に通過」Turnstile テスト key、オフラインで動作確認可能)
cp .dev.vars.example .dev.vars
# 3.(任意)imageUrl が .svg を指す新規会場のプレースホルダー座席表を生成。収録済み会場の座席表はリポジトリに同梱
npm run gen:seatmaps
# 4. ローカル D1 を初期化(マイグレーション適用)
npm run db:migrate:local
# 5. デモのマーカーを生成して投入(座席表 / ウォーターフォール / Lightbox にコンテンツを用意)
npm run gen:seed && npm run db:seed:local
# 6. 開発サーバーを起動
npm run dev # ページ開発専用、最速の HMR(D1/KV/R2 バインディングと API は利用不可)
# または
npm run preview # フル機能(バインディング + API、miniflare 経由)
[!TIP] UI を書く・スタイルを調整するには
npm run dev(最速)。アップロード / 一時置き場 / 管理画面など Cloudflare バインディングに依存する機能を連携テストするにはnpm run preview(先にastro build、続いてwrangler devがビルド成果物dist/server/wrangler.jsonを指し、ローカルでは miniflare が D1/KV/R2 を提供)。[!WARNING] ルートの
wrangler.jsoncに対して直接wrangler devを実行しないでください(つまり-cなし):ルート設定はバインディングを宣言するだけでmain/assetsがありません。そのためビルド成果物ではなくアダプターのソースエントリが起動し、すべてのページの SSR がリテラルの[object Object]を返してしまいます。npm run preview/npm run deployは正しい設定を指すよう既に設定済みです。
すべての npm スクリプト
| コマンド | 役割 |
|---|---|
npm run dev |
astro dev、ページのホットリロード |
npm run build |
astro build、Workers バンドルを dist/ に出力 |
npm run preview |
astro build 後に wrangler dev -c dist/server/wrangler.json、ビルド成果物 + バインディングをローカルで実行 |
npm test |
node --experimental-strip-types --test、純粋ロジックの単体テストを実行 |
npm run typecheck |
astro check、型チェック |
npm run format / format:check |
Prettier フォーマット / チェック(CI は format:check) |
npm run db:generate |
drizzle-kit generate、schema からマイグレーションを生成 |
npm run db:migrate:local / :prod |
wrangler d1 migrations apply(ローカル / リモート) |
npm run gen:seatmaps |
プレースホルダー座席表 SVG を生成 |
npm run gen:seed |
デモのシード SQL を生成 |
npm run db:seed:local |
デモのシードをローカル D1 に投入 |
npm run cf-typegen |
wrangler types、バインディングの型を生成 |
npm run deploy |
astro build && wrangler deploy -c dist/server/wrangler.json |
リソースを一度作成し、返ってきた id を wrangler.jsonc に記入してから、マイグレーション + デプロイします。
# 1. D1 / KV / R2 リソースを作成
wrangler d1 create seatmap-real
wrangler kv namespace create RATE_LIMIT
wrangler kv namespace create SESSION # Astro CF アダプターは SESSION KV バインディングを要求します
wrangler r2 bucket create seatmap-images
# 2. 返ってきた実際の id を wrangler.jsonc に記入(プレースホルダー YOUR_*):
# d1_databases[0].database_id、kv_namespaces[].id(RATE_LIMIT と SESSION に各 1 つ)
# 3. リモート D1 にマイグレーションを適用
npm run db:migrate:prod
# 4. Turnstile の本番シークレットを設定(リポジトリにコミットしないこと)
wrangler secret put TURNSTILE_SECRET_KEY
# site key は .env.production の PUBLIC_TURNSTILE_SITE_KEY に記入(さらに wrangler.jsonc の vars と同期)
# 5. giscus コメントを設定(公開リソース id、シークレットではありません)
# GitHub repo で Discussions を有効化し、giscus App をインストールし、
# "Venue Comments" category を作成してから PUBLIC_GISCUS_REPO / REPO_ID /
# CATEGORY / CATEGORY_ID を .env.production に記入し、wrangler.jsonc vars と同期
# 6. デプロイ
npm run deploy
[!NOTE] 自動デプロイ (CD):設定が完了すると、
mainへの push で GitHub Actions が自動的にビルドして Cloudflare にデプロイします(.github/workflows/ci.yml。すべてのチェックが通った後にのみデプロイ。Actions タブの「Run workflow」から手動トリガーも可能)。上記のnpm run deployは初回 / ローカルでの手動デプロイ用です。一度きりの設定:Cloudflare ダッシュボードで「Edit Cloudflare Workers」テンプレートを使って API トークンを作成し(Account = 自分のアカウント、Zone =
genchi.top。custom domain で権限不足になる場合は Zone → DNS: Edit も追加)、GitHub repo → Settings → Secrets and variables → Actions でリポジトリシークレットCLOUDFLARE_API_TOKENとして追加します。CD は D1 マイグレーションを実行しません。schema を変更した場合は引き続き手動で
npm run db:migrate:prodを実行してください。[!IMPORTANT] メンテナー管理画面(
/admin+/api/admin/*)は Cloudflare Access (Zero Trust) によりエッジで保護されます:ダッシュボードの Zero Trust → Access → Applications で/*/adminと/api/admin/*をカバーする self-hosted アプリを新規作成し、Allow → メンテナーのメールアドレスの policy を 1 つ追加します。Access が認証後にCf-Access-Authenticated-User-Emailを注入し、Worker はそのヘッダーを信頼します(src/server/admin-auth.ts)。匿名トラフィックは Worker に到達しません。本番では admin の環境変数は不要です。本番でDEV_ADMIN_EMAILを絶対に設定しないでください——SSO ゲートウェイを迂回してしまいます。[!NOTE] 本リポジトリには既に日本 / 一部海外の会場データ 74 件(
public/seatmaps/下に座席図画像 90 点)+ デモのマーカーが含まれています。本番の実際のマーカーは、ユーザーがアップロードフローを通じて D1 に書き込みます。npm run db:migrate:prodの再実行が必要なのは DB schema を変更したときだけで、フロントエンドのみの変更にマイグレーションは不要です。
presigned URL によるクライアント直接アップロードではなく、sign + commit の二段階方式を採用し、D1 への書き込みが偽造不可能であること、そしてローカルの miniflare R2 だけで全工程を通しで動作確認できること(S3 認証情報 / バケット CORS 不要)を保証します:
browser-image-compression で約 500KB の WebP に圧縮(長辺 ≤ 1920、EXIF 除去)し、Turnstile を通過して token を取得します。POST /api/upload/sign —— Worker が Turnstile + 30s クールダウン + 1 日 10 件の上限(KV、キーはハッシュ化した IP)を検証し、書き込む全フィールド(venue / sub-map / 座標 / 座席番号 / ip_hash / image_key / 有効期限)をバインドした HMAC ticket を発行します。この時点では日次クォータを消費しません。POST /api/upload/commit(multipart)—— クライアントが ticket + WebP のバイト列を送り返します。Worker は HMAC + 有効期限を再検証し、バイト列を BUCKET バインディング経由で R2 に書き込み、続いてticket 内のフィールドを使って(リクエストボディは信頼しない)D1 に挿入し、最後にようやく日次クォータを消費 + 30s クールダウンを開始します。ソフト削除:メンテナーが /admin で画像を削除すると、D1 が deleted_at をセット(公開クエリは deleted_at IS NULL でフィルタし、マーカー / カードが即座に消えます)し、R2 オブジェクトはゴミ箱に残るため復元できます。ゴミ箱の「完全に削除」だけが、R2 オブジェクトと D1 行をどちらも物理削除します。
| 名称 | 種別 | 用途 | ローカル | 本番 |
|---|---|---|---|---|
DB |
D1 | photos + staging_venues + photo_correction_requests + venue_ratings / venue_rating_agg | miniflare 自動 | wrangler.jsonc に実際の database_id を記入 |
BUCKET |
R2 | アップロード画像のストレージ(バインディング直接書き込み) | miniflare 自動 | wrangler.jsonc(bucket_name) |
RATE_LIMIT |
KV | IP レート制限のカウント + クールダウン(TTL)。投稿 / 一時置き場 / 座席番号修正 / 評価を含む | miniflare 自動 | wrangler.jsonc に実際の KV id を記入 |
SESSION |
KV | Astro CF アダプターの session API が要求するバインディング(実際には書き込まない) | miniflare 自動 | wrangler.jsonc に実際の KV id を記入 |
TURNSTILE_SECRET_KEY |
シークレット | バックエンド siteverify / HMAC ticket / IP-hash salt | .dev.vars(テスト secret) |
wrangler secret put |
PUBLIC_TURNSTILE_SITE_KEY |
公開 var | フロントエンド Turnstile widget | .env.development |
.env.production(+ wrangler.jsonc vars のランタイムコピー) |
PUBLIC_R2_BASE_URL |
公開 var | アップロード画像 URL を組み立てる。空 → 同一オリジン /r2/<key> にフォールバック |
.env.development(空) |
.env.production(r2.dev / カスタムドメイン) |
PUBLIC_SITE_URL |
公開 var | サイトのベース URL | http://localhost:4321 |
本番ドメイン |
PUBLIC_GISCUS_REPO / PUBLIC_GISCUS_REPO_ID / PUBLIC_GISCUS_CATEGORY / PUBLIC_GISCUS_CATEGORY_ID |
公開 var | giscus の会場コメント設定。必須値が空の場合、コメント欄は未開放状態を表示し、第三者リソースを読み込みません | .env.development |
.env.production(+ wrangler.jsonc vars のランタイムコピー) |
DEV_ADMIN_EMAIL |
ローカル限定 | メンテナー identity の mock(Access エッジがない場合) | .dev.vars(任意のメール) |
絶対に設定しない(Cloudflare Access を使用) |
[!NOTE]
PUBLIC_*は Vite がビルド時に.env*からクライアントバンドルへインライン化します(islands はimport.meta.env.PUBLIC_*で読み取り)——wrangler.jsoncのvars(これは Worker ランタイムにのみ届く)からではありません。2 つの仕組み、2 つのファイルなので、Turnstile / R2 / giscus などの公開値は同期を忘れないでください。R2 アップロードに S3 presigned 認証情報(R2_ACCESS_KEY_IDなど)は不要で、Worker はBUCKETバインディング経由で直接書き込みます。
会場タイトル付近には VenueComments island がマウントされ、初期状態では控えめな入口(平均点 / 評価数 / コメント)だけを表示します。@giscus/react はドロワーを初めて開いたときだけ動的 import されます。閉じるときはアンマウントせず非表示にするため、再オープン時に giscus iframe が再読み込みされません。giscus は mapping="specific" + term="venue:<id>" を使うので、同じ会場は言語パスや座席表タブをまたいで 1 つの GitHub Discussions スレッドを共有します。テーマはシステムテーマではなく、サイトの html.dark クラスに追従します。PUBLIC_GISCUS_CATEGORY_ID など必須値が空の場合、コメント欄は「まだ利用できません」状態だけを表示し、第三者 script / iframe は読み込みません。
$ claude mcp add seatview \
-- python -m otcore.mcp_server <graph>