initial commit
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
6cba1f1351
245
.claude/skills/creating-visual-explainers/SKILL.md
Normal file
245
.claude/skills/creating-visual-explainers/SKILL.md
Normal file
@ -0,0 +1,245 @@
|
||||
---
|
||||
name: creating-visual-explainers
|
||||
description: Generates an illustrated HTML page about any topic and deploys it to surge.sh with a comment widget (text/pin/voice feedback). Triggered by requests like "図解を作って", "図解を生成して", "このトピックを図解して", "図解してデプロイして", "この図解を再デプロイして", or "この図解を削除して".
|
||||
---
|
||||
|
||||
# Creating Visual Explainers
|
||||
|
||||
任意のトピックについて、前提知識がなくても理解できる図解HTMLを生成し、コメント機能(テキスト引用・ピン・音声)付きで surge.sh に公開する。品質基準は「入社したての新卒社会人が読んでも腹落ちする明快さ」だが、この基準は出力には表示しない。
|
||||
|
||||
## 依存
|
||||
|
||||
- `references/base.html` — 図解テンプレート(Tailwind CSS CDN・Lucide Icons CDN・ADS配色を含む「額縁」)
|
||||
- `references/model-answer.html` — 模範回答(品質基準・デザインパターンの実例)。base.htmlと同一の額縁を含む完全なHTMLファイル。widget 未含(デプロイ時に自動注入されるため)
|
||||
- リポジトリルートの `fb-tool-url.txt` / `fb-api-token.txt` — セットアップスキル(`setting-up-comment-tool`)が生成する
|
||||
|
||||
## ワークフロー
|
||||
|
||||
### Step 0: 前提確認
|
||||
|
||||
1. `references/base.html` が存在するか確認する。存在しない場合、以下を伝えて終了:
|
||||
|
||||
> テンプレートファイルが見つかりません。スキルのフォルダ構成が壊れている可能性があります。運営に連絡してください。
|
||||
|
||||
2. リポジトリルート(`.claude` フォルダがある階層)に `fb-tool-url.txt` が存在するか確認する。存在しない場合、以下を伝えて終了:
|
||||
|
||||
> コメントツールのセットアップがまだ完了していません。
|
||||
> 先にチャット欄で「セットアップして」と伝えてください。
|
||||
|
||||
### Step 1: 模範回答の読み込み
|
||||
|
||||
`references/model-answer.html` を読み、以下を把握する:
|
||||
|
||||
- 完成品の品質水準
|
||||
- デザインパターン(色使い・余白・カード・フロー図などの視覚表現)
|
||||
- Tailwind CSSクラスの使い方
|
||||
- Lucide Iconsの使い方
|
||||
- コンテンツの構成・情報量・説明の深さ
|
||||
|
||||
模範回答がデザインガイドラインの代わりになる。パーツ一覧やルールではなく、実物から読み取る。
|
||||
|
||||
### Step 2: テンプレートの読み込み
|
||||
|
||||
`references/base.html` を読み、額縁の構造を把握する:
|
||||
|
||||
- `<!-- CONTENT_START -->` 〜 `<!-- CONTENT_END -->` のプレースホルダー位置
|
||||
- `<!-- TITLE -->`, `<!-- DESCRIPTION -->` のプレースホルダー
|
||||
- ADS配色のTailwind設定
|
||||
- 読み込み済みのCDN(Tailwind CSS・Lucide Icons)
|
||||
|
||||
### Step 3: ウェブで情報収集
|
||||
|
||||
トピックについてウェブ検索を行い、正確かつ最新の情報を収集する。
|
||||
|
||||
検索は **2〜3回** に絞る。以下の観点でクエリを組み立てる:
|
||||
|
||||
1. **正確な定義**: トピックの公式な定義、公式ドキュメントの説明
|
||||
2. **最新動向**: 直近の変更点、アップデート、現在のベストプラクティス
|
||||
3. **具体例**: 実際の使われ方、初心者に伝わるたとえに使える事例
|
||||
|
||||
検索結果から以下を整理し、Step 4 の参考情報とする:
|
||||
|
||||
- トピックの正確な定義(検索結果を優先。AIの学習データだけに頼らない)
|
||||
- 最近変わった点があれば、それを明記する
|
||||
- たとえ話に使えそうな事例や数字
|
||||
- **出典URL**: 図解に採用した情報のソースURLを控えておく(Step 4 でインライン出典に使う)
|
||||
|
||||
### Step 4: コンテンツ生成
|
||||
|
||||
Step 3 で収集した情報をもとに、図解HTMLを生成する。検索結果で得た定義・事実・具体例を優先的に採用する。
|
||||
|
||||
模範回答のデザインを参考にしつつ、Tailwindの語彙で自由にレイアウトを組む。模範回答のパターンに合うものはそのまま使い、合わないものはTailwindクラスでその場で作る。テンプレートの定義済みパーツに縛られない。
|
||||
|
||||
### Step 5: ファイル作成
|
||||
|
||||
1. `output/` ディレクトリがなければ作成する
|
||||
2. トピックに関連する短い英単語のスラッグを決める(例: `api-basics`, `git-rebase`)
|
||||
3. `references/base.html` を `output/{スラッグ}.html` にコピーする
|
||||
4. コピーしたファイル内のプレースホルダーをすべて置換する:
|
||||
- `<!-- TITLE -->` → 図解のタイトル
|
||||
- `<!-- DESCRIPTION -->` → 内容を要約した1文
|
||||
- `<!-- CONTENT_START -->` 〜 `<!-- CONTENT_END -->` → Step 4で生成したコンテンツ
|
||||
5. ファイルを保存する(ブラウザで開くのは Step 6 のデプロイ後に行う。ローカルでは開かない)
|
||||
|
||||
### Step 6: 公開
|
||||
|
||||
ファイル保存後、公開する前にまず Node.js の有無を確認する。
|
||||
|
||||
```bash
|
||||
node --version
|
||||
```
|
||||
|
||||
バージョン番号が表示された → そのまま「公開の実行」に進む。
|
||||
|
||||
`command not found` と表示された → `references/node-install-guide.md` の手順に従ってNode.jsのインストールを案内する。
|
||||
|
||||
#### 公開の実行
|
||||
|
||||
以下のスクリプトを**実行する**(中身を読む必要はない)。コメントウィジェットの埋め込みはこのスクリプトが自動で行う。
|
||||
|
||||
**macOS / Git Bash(Windows)の場合:**
|
||||
|
||||
```bash
|
||||
bash .claude/skills/creating-visual-explainers/scripts/deploy-diagram.sh output/{スラッグ}.html [スラッグ]
|
||||
```
|
||||
|
||||
**Windows(PowerShell)で bash が使えない場合:**
|
||||
|
||||
```powershell
|
||||
$fbUrl = (Get-Content fb-tool-url.txt).Trim()
|
||||
$apiToken = (Get-Content fb-api-token.txt).Trim()
|
||||
$snippet = "<script src=`"$fbUrl/widget.js`" data-token=`"$apiToken`"></script>"
|
||||
$dir = "$env:TEMP\diagram-deploy"
|
||||
New-Item -ItemType Directory -Force $dir | Out-Null
|
||||
# (?s)^(.*)</body> は貪欲マッチのため「最後の </body>」だけを置換する
|
||||
# (本文中にコード例として </body> があっても多重注入しない。bash 版と同じ挙動)
|
||||
(Get-Content output/{スラッグ}.html -Raw) -replace '(?s)^(.*)</body>', ('$1' + $snippet + '</body>') | Set-Content "$dir\index.html"
|
||||
Set-Content "$dir\robots.txt" "User-agent: *`nDisallow: /"
|
||||
npx --yes surge $dir --domain diagram-[スラッグ].surge.sh
|
||||
Add-Content deploy-history.log "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') | https://diagram-[スラッグ].surge.sh"
|
||||
```
|
||||
|
||||
スラッグにはトピックに関連する短い英単語を指定する(例: `git-rebase`, `api-basics`)。
|
||||
|
||||
#### 初回の場合(Surge未登録)
|
||||
|
||||
ターミナルにメールアドレスとパスワードの入力を求められる。以下を伝える:
|
||||
|
||||
> 初回のみアカウント登録が必要です。
|
||||
> メールアドレスを入力して Enter → パスワードを決めて入力して Enter。
|
||||
> 確認メールが届いたらリンクをクリックすれば登録完了です。
|
||||
> 次回以降はこの手順は不要です。
|
||||
|
||||
#### エラーが出た場合
|
||||
|
||||
エラーメッセージをそのまま見せず、**何が起きていて何をすれば解決するか**を、専門用語を避けて平易に説明する。
|
||||
|
||||
よくあるエラーと対応:
|
||||
|
||||
- **`npx: command not found`** — Node.js がまだ入っていない。`references/node-install-guide.md` の手順を案内する
|
||||
- **`surge: not found` / surge関連エラー** — `npm install -g surge` を実行してから再度試す
|
||||
- **認証エラー / `Login required`** — `npx surge login` を実行してメールアドレスとパスワードを入力する
|
||||
- **`fb-tool-url.txt が見つかりません`** — セットアップが未完了。チャット欄で「セットアップして」と伝えるよう案内する
|
||||
- **その他** — エラーの内容を読み、「何が問題で」「次に何をすればいいか」を平易に説明する
|
||||
|
||||
### 図解の再デプロイ
|
||||
|
||||
ユーザーが「この図解を再デプロイして」と依頼した場合(内容の修正後や、公開済み図解にコメント機能を付け直すとき):
|
||||
|
||||
1. 対象の HTML を特定する(直近に編集した `output/{スラッグ}.html`。不明ならリポジトリルートの `deploy-history.log` から URL を確認し、ドメインの `diagram-` 以降がスラッグ)
|
||||
2. 「公開の実行」と同じコマンドを、**同じスラッグ**で再実行する(同じ URL に上書き公開される)
|
||||
|
||||
### 図解の削除
|
||||
|
||||
ユーザーが「この図解を削除して」と依頼した場合:
|
||||
|
||||
1. リポジトリルートの `deploy-history.log` を読み、直近のデプロイURLを特定する
|
||||
- ログが存在しない場合 → ユーザーに削除したいURLを聞く
|
||||
2. `npx surge teardown [ドメイン]` を実行する
|
||||
3. 削除完了をユーザーに伝える
|
||||
|
||||
### Step 7: 完了報告
|
||||
|
||||
#### 公開に成功した場合
|
||||
|
||||
```
|
||||
完成・公開完了: 【図解のタイトル】
|
||||
|
||||
(図解の内容を1〜2文で要約)
|
||||
|
||||
公開URL:
|
||||
https://diagram-スラッグ.surge.sh
|
||||
|
||||
図解の主なポイント:
|
||||
- (主要トピックを3〜5個)
|
||||
|
||||
コメントの付け方:
|
||||
ページ右端のボタンから、テキスト選択・ピン・音声(マイクに向かって喋る)の
|
||||
3通りでフィードバックできます。
|
||||
|
||||
この図解を削除したいとき:
|
||||
チャット欄で「この図解を削除して」と伝えてください。
|
||||
```
|
||||
|
||||
#### 公開できなかった場合
|
||||
|
||||
```
|
||||
完成: 【図解のタイトル】
|
||||
|
||||
(図解の内容を1〜2文で要約)
|
||||
|
||||
ファイルの保存先:
|
||||
output/{スラッグ}.html(ブラウザにドラッグ&ドロップすると表示できます)
|
||||
|
||||
図解の主なポイント:
|
||||
- (主要トピックを3〜5個)
|
||||
|
||||
URLで共有したいとき:
|
||||
チャット欄で「この図解を公開して」と伝えてください。
|
||||
```
|
||||
|
||||
## 守ること(禁止事項)
|
||||
|
||||
- **見出し(`h1`〜`h4`)を必ず入れる** — 音声コメントはページ内の見出しを手がかりにピンを配置する。見出しが1つも無いHTMLでは音声フィードバックがエラーになる
|
||||
- **React・shadcn/ui を使わない** — 静的な図解にJSフレームワークは不要。AIの出力を制限し、モデルによる品質差も限定的にしてしまう
|
||||
- **絵文字を使わない** — OS依存で表示が変わる。アイコンはLucide Iconsを使う
|
||||
- **インタラクティブ要素を入れない** — トグル、フェードイン、アニメーション、フォーム、クリックで開閉する要素は一切禁止
|
||||
- **`<style>` タグを追加しない** — スタイリングはTailwind CSSクラスで行う。インラインの `style` 属性も避ける
|
||||
- **`<script>` を追加しない** — テンプレートに含まれるもの以外のJavaScriptは禁止。widget.js の埋め込みはデプロイスクリプトが自動で行うため、SKILL の範囲外
|
||||
- **外部リソースを追加しない** — テンプレートに含まれるCDN以外の外部読み込み(画像URL・フォント・追加CDN)は禁止
|
||||
- **テンプレートの額縁構造を変更しない** — `<head>`・CDN読み込み・meta タグはそのまま維持する
|
||||
- **PDF印刷で消える表現を使わない** — `bg-clip-text text-transparent` によるグラデーション文字はPDF出力時に透明のまま消える。テンプレートの print CSS でフォールバックを入れているが、グラデーション文字を使った場合はPDF出力で色が単色(`#2563EB`)に変わる点を意識すること
|
||||
|
||||
## コンテンツ生成の指針
|
||||
|
||||
- **概論 → 各論** — いきなり詳細に入らない。全体像を見せてから個別の話に入る
|
||||
- **専門用語は初出で必ず解説** — 「API(Application Programming Interface=ソフトウェア同士がやり取りするための窓口)」のように、括弧書きで平易に説明する
|
||||
- **たとえ話で身近な体験に結びつける** — レストランの注文、郵便配達、信号機など、技術を知らない人でもイメージできる例を使う
|
||||
- **簡潔にまとめすぎない** — 理解に必要な情報量は削らない。腹落ちするまで丁寧に説明する
|
||||
- **図を早く見せる** — 見出しから最初のビジュアルまでに、テキストは最大2段落(各2〜3行)。それ以上の説明が必要な場合は、図を先に見せてから図の後にテキストで補足する(図→説明の順)。「説明してから図を見せる」より「図を見せてから説明する」方が、同じ文量でも「読まされている感」がなくなる。テキストで述べたたとえ話(ゲーム、レストラン等)も、テキストだけで済まさずミニビジュアル化を検討する
|
||||
- **「見たことがあるもの」は説明するのではなく見せる** — 図解の中で読者がすでに体験しているもの(アプリの画面、ツールのUI、Webサイト)に言及するとき、テキストで「〜という画面が表示されます」と書く代わりに、その画面自体をTailwind CSSで再現して配置する。「天気アプリの画面」なら天気アプリのモックアップを、「チャット画面」ならチャットUIを、「ターミナル」ならターミナルウィンドウを見せる。読者の「見たことある!」という記憶が発火する瞬間が、テキスト説明より圧倒的に理解を深める
|
||||
- **ビジュアルには2つの役割がある** — 図解に使うビジュアルは「構造を示す図」と「体験を再現する図」の2種類。どちらか一方ではなく、両方を組み合わせて使う:
|
||||
- **構造パターン(仕組みを頭で理解する図)** — 知識の種類に応じて選ぶ:
|
||||
- **「Xとは何か」(定義)** → アナロジー図: 身近なたとえの登場人物を配置し、矢印で関係を示す。主役を中央に大きく
|
||||
- **「Xはどう動くか」(プロセス)** → ステップフロー: 番号つき横並び(モバイルは縦)。各ステップにアイコン+一言。色はステップごとに変える
|
||||
- **「XとYの違い」(比較)** → 左右対比: 2カラムで並べ、同じ観点を同じ行に揃える。✗/✓ や赤/緑で差を一目で伝える
|
||||
- **「Xの具体例」(事例)** → カードグリッド: 2列のカードにアイコン+タイトル+説明。カード内で「表の顔」と「裏の仕組み」を分けると深みが出る
|
||||
- **「Xのすごさ」(数値)** → 数字カード: 3カラムに大きな数字。text-3xl font-black で存在感を出し、色を各数字で変える
|
||||
- **「Xの構造・中身」(階層)** → 入れ子ブロック: 外側の大きなブロック内に構成要素を配置。ボーダーや背景色の濃淡で階層を表現
|
||||
- **「Xの誤解」(訂正)** → 誤解→正解カード: 赤ヘッダーに誤解、本文に緑で正解。✗と✓の対比
|
||||
- **「Xの変遷」(時系列)** → タイムライン: 左にボーダーライン、各時点に年号・ラベル・要約。色のグラデーションで時代の変化を表現
|
||||
- **体験再現パターン(読者の「見たことある」記憶と結びつける図)** — 読者が実際に触れたことのある画面をTailwind CSSで再現する。「あなたが見ているもの」「〜の画面では」「実際に使うとき」のような文脈が出てきたら、テキスト説明ではなくこちらを使う:
|
||||
- **チャットUI** — 吹き出し形式の対話画面。暗い背景+ユーザー吹き出し(右寄せ)+AI吹き出し(左寄せ)+入力欄モック
|
||||
- **エディタUI** — タイトルバー(赤黄緑ボタン)+サイドバー(ファイルツリー)+コードペイン。AIチャットパネルを加えた3ペインも可
|
||||
- **ターミナルUI** — タイトルバー(赤黄緑ボタン)+プロンプト($)+コマンド+出力。コードブロックとの違いはウィンドウ枠がある点
|
||||
- **ブラウザUI** — タイトルバー(赤黄緑ボタン)+アドレスバー+ページ内容
|
||||
- **アプリ画面** — 上記に当てはまらないアプリ(天気予報、地図、決済画面など)。角丸カード内にアイコン+数値+ラベルでミニ画面を再現
|
||||
- 共通ルール: 中身はリアルすぎず要点が伝わるミニマルな内容にする。特定ブランドのロゴや名称は使わず汎用的な表現にする。Tailwind CSSクラスのみで構成する
|
||||
- **冒頭にヒーロー+一枚絵サマリー** — タイトルで「何の図解か」を伝えたあと、そのままトピックの核心を1枚の図で見せる。ヒーローの説明文を図への導入(「ひとことで言えば──」のような引き)にし、図の直後に各論への橋渡し(「ここからひとつずつ解説していきます」)を置く。ヒーロー → 一言の答え → 図 → 各論が一本の流れになること。一枚絵サマリーの構成:
|
||||
- **一言の答え**: トピックの核心を1文で太字表示(カード冒頭)。読者が「なるほど」と思える定義を先に読ませ、そのあとの図で視覚的に裏付ける
|
||||
- **コア図解**: 一言の答えを図にしたもの。アイコン+矢印+ラベルで表現
|
||||
- **身近な接点**: 読者がすでに体験している具体例を、アイコン+一言のピルで2〜4個並べる
|
||||
- **読者のレベルに言及しない** — 「初心者向け」「入門」「未経験者でもわかる」のように読者を特定のレベルにラベリングする表現をタイトル・見出し・本文に入れない。「わかりやすく説明する」のは図解の品質であって、読者のレベルではない。たとえ話や専門用語の解説で自然に噛み砕けば、ラベルなしで誰にでも伝わる
|
||||
- **ヒーローバッジはトピックカテゴリ** — タイトル上のバッジには、トピックのカテゴリを入れる(テクノロジー、ビジネス、開発ツール、デザイン、マーケティングなど)。「初心者向け」「入門」など読者のレベルを示すラベルは入れない
|
||||
- **インライン出典** — 検索結果から採用した事実・定義・数字のすぐ近くに、出典リンクをさりげなく添える。本文の読みやすさを損なわないよう `text-xs text-ads-dim` で小さく薄く表示し、リンクテキストはURLそのままではなく「出典: ○○公式ドキュメント」のようにページ内容がわかる名前にする。段落末やカード下部など、視線の流れを邪魔しない位置に置く
|
||||
- **日本語で** — 英語メインのトピックでも、図解は日本語で書く
|
||||
@ -0,0 +1,83 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
|
||||
<meta name="googlebot" content="noindex, nofollow">
|
||||
<meta property="og:title" content="<!-- TITLE -->">
|
||||
<meta property="og:description" content="<!-- DESCRIPTION -->">
|
||||
<meta property="og:type" content="article">
|
||||
<title><!-- TITLE --></title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='20' fill='%23FFFFFF'/><rect x='8' y='8' width='84' height='84' rx='14' fill='none' stroke='%233B82F6' stroke-width='4'/><circle cx='32' cy='40' r='10' fill='%233B82F6' opacity='0.8'/><circle cx='68' cy='40' r='10' fill='%2360A5FA' opacity='0.8'/><rect x='25' y='62' width='50' height='6' rx='3' fill='%2394A3B8' opacity='0.6'/><rect x='30' y='74' width='40' height='6' rx='3' fill='%2394A3B8' opacity='0.3'/></svg>">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;500;700;900&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
ads: {
|
||||
bg: '#FFFFFF',
|
||||
surface: '#F8FAFC',
|
||||
hover: '#F1F5F9',
|
||||
border: '#E2E8F0',
|
||||
accent: '#3B82F6',
|
||||
'accent-light': '#2563EB',
|
||||
text: '#1E293B',
|
||||
muted: '#64748B',
|
||||
dim: '#94A3B8',
|
||||
positive: '#10B981',
|
||||
negative: '#EF4444',
|
||||
warning: '#F59E0B',
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['"Noto Sans JP"', '"Hiragino Sans"', '"Hiragino Kaku Gothic ProN"', '"Yu Gothic UI"', '"Meiryo"', 'sans-serif'],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
body { border-top: none !important; }
|
||||
.rounded-xl { break-inside: avoid; }
|
||||
.md\:flex-row { flex-direction: row !important; }
|
||||
.md\:hidden { display: none !important; }
|
||||
.hidden.md\:block { display: block !important; }
|
||||
.md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }
|
||||
.sm\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)) !important; }
|
||||
.md\:mb-20 { margin-bottom: 5rem !important; }
|
||||
.md\:py-16 { padding-top: 4rem !important; padding-bottom: 4rem !important; }
|
||||
.bg-clip-text.text-transparent {
|
||||
-webkit-background-clip: initial !important;
|
||||
background-clip: initial !important;
|
||||
color: #2563EB !important;
|
||||
-webkit-text-fill-color: #2563EB !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-ads-bg text-slate-600 antialiased leading-relaxed border-t-4 border-ads-accent">
|
||||
<div class="no-print max-w-3xl mx-auto px-5 pt-2 flex justify-end">
|
||||
<button onclick="window.print()" class="flex items-center gap-1.5 text-xs text-ads-dim hover:text-ads-accent transition-colors cursor-pointer">
|
||||
<i data-lucide="download" class="w-3.5 h-3.5"></i>
|
||||
PDF
|
||||
</button>
|
||||
</div>
|
||||
<main class="max-w-3xl mx-auto px-5 py-10 md:py-16">
|
||||
<!-- CONTENT_START -->
|
||||
|
||||
<!-- CONTENT_END -->
|
||||
</main>
|
||||
<footer class="max-w-3xl mx-auto px-5 pb-10 pt-6 border-t border-ads-border/30">
|
||||
<p class="text-xs text-ads-dim text-center">AI-Driven School の図解ツールで作成</p>
|
||||
</footer>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script>lucide.createIcons();</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,968 @@
|
||||
<!-- このファイルの<head>はreferences/base.htmlと同一に保つ。base.html変更時は必ずこちらも更新する -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
|
||||
<meta name="googlebot" content="noindex, nofollow">
|
||||
<meta property="og:title" content="APIの仕組み">
|
||||
<meta property="og:description" content="APIの仕組みを、身近な例とビジュアルでわかりやすく図解します。">
|
||||
<meta property="og:type" content="article">
|
||||
<title>APIの仕組み</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='20' fill='%23FFFFFF'/><rect x='8' y='8' width='84' height='84' rx='14' fill='none' stroke='%233B82F6' stroke-width='4'/><circle cx='32' cy='40' r='10' fill='%233B82F6' opacity='0.8'/><circle cx='68' cy='40' r='10' fill='%2360A5FA' opacity='0.8'/><rect x='25' y='62' width='50' height='6' rx='3' fill='%2394A3B8' opacity='0.6'/><rect x='30' y='74' width='40' height='6' rx='3' fill='%2394A3B8' opacity='0.3'/></svg>">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;500;700;900&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
ads: {
|
||||
bg: '#FFFFFF',
|
||||
surface: '#F8FAFC',
|
||||
hover: '#F1F5F9',
|
||||
border: '#E2E8F0',
|
||||
accent: '#3B82F6',
|
||||
'accent-light': '#2563EB',
|
||||
text: '#1E293B',
|
||||
muted: '#64748B',
|
||||
dim: '#94A3B8',
|
||||
positive: '#10B981',
|
||||
negative: '#EF4444',
|
||||
warning: '#F59E0B',
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['"Noto Sans JP"', '"Hiragino Sans"', '"Hiragino Kaku Gothic ProN"', '"Yu Gothic UI"', '"Meiryo"', 'sans-serif'],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
body { border-top: none !important; }
|
||||
.rounded-xl { break-inside: avoid; }
|
||||
.md\:flex-row { flex-direction: row !important; }
|
||||
.md\:hidden { display: none !important; }
|
||||
.hidden.md\:block { display: block !important; }
|
||||
.md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }
|
||||
.sm\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)) !important; }
|
||||
.md\:mb-20 { margin-bottom: 5rem !important; }
|
||||
.md\:py-16 { padding-top: 4rem !important; padding-bottom: 4rem !important; }
|
||||
.bg-clip-text.text-transparent {
|
||||
-webkit-background-clip: initial !important;
|
||||
background-clip: initial !important;
|
||||
color: #2563EB !important;
|
||||
-webkit-text-fill-color: #2563EB !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-ads-bg text-slate-600 antialiased leading-relaxed border-t-4 border-ads-accent">
|
||||
<div class="no-print max-w-3xl mx-auto px-5 pt-2 flex justify-end">
|
||||
<button onclick="window.print()" class="flex items-center gap-1.5 text-xs text-ads-dim hover:text-ads-accent transition-colors cursor-pointer">
|
||||
<i data-lucide="download" class="w-3.5 h-3.5"></i>
|
||||
PDF
|
||||
</button>
|
||||
</div>
|
||||
<main class="max-w-3xl mx-auto px-5 py-10 md:py-16">
|
||||
<!-- CONTENT_START -->
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- HERO -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="text-center mb-8 md:mb-10">
|
||||
<div class="inline-flex items-center gap-2 bg-ads-accent/10 text-ads-accent-light px-4 py-1.5 rounded-full text-sm font-medium mb-6">
|
||||
<i data-lucide="cpu" class="w-4 h-4"></i>
|
||||
テクノロジー
|
||||
</div>
|
||||
<h1 class="text-3xl md:text-5xl font-black text-slate-900 tracking-tight mb-6">
|
||||
<span class="bg-gradient-to-r from-blue-600 to-cyan-600 bg-clip-text text-transparent">API</span>の仕組み
|
||||
</h1>
|
||||
<p class="text-lg text-ads-muted max-w-xl mx-auto leading-relaxed">
|
||||
「APIって何?」と聞かれて、うまく答えられない。<br>
|
||||
ひとことで言うと ──
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- ONE-PAGE SUMMARY -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="bg-ads-surface border border-ads-border rounded-2xl p-6 md:p-10 mb-6">
|
||||
<div class="text-center mb-8 md:mb-10">
|
||||
<p class="text-xl md:text-2xl font-black text-slate-900 mb-2">
|
||||
API = ソフトウェアの<span class="text-ads-accent-light">「注文窓口」</span>
|
||||
</p>
|
||||
<p class="text-sm text-ads-muted">
|
||||
中身を知らなくても、決まった形で頼めば結果が届く
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Core flow: App → API → Server -->
|
||||
<div class="flex flex-col md:flex-row items-center justify-center gap-2 md:gap-0 mb-8">
|
||||
<!-- App -->
|
||||
<div class="flex flex-col items-center w-36 p-3">
|
||||
<div class="w-14 h-14 rounded-xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-2.5">
|
||||
<i data-lucide="smartphone" class="w-7 h-7 text-blue-600"></i>
|
||||
</div>
|
||||
<div class="font-bold text-slate-900 text-sm">あなたのアプリ</div>
|
||||
<div class="text-[11px] text-blue-600/70 mt-0.5">「天気を教えて」</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow 1: Request -->
|
||||
<div class="flex items-center justify-center md:w-16 py-1 md:py-0">
|
||||
<div class="flex flex-col items-center gap-0.5">
|
||||
<span class="text-[9px] font-medium text-ads-accent tracking-wider">リクエスト</span>
|
||||
<i data-lucide="arrow-right" class="w-5 h-5 text-ads-accent hidden md:block"></i>
|
||||
<i data-lucide="arrow-down" class="w-5 h-5 text-ads-accent md:hidden"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API (centerpiece — visually prominent) -->
|
||||
<div class="flex flex-col items-center w-40 p-4 bg-ads-accent/5 border-2 border-ads-accent/20 rounded-2xl">
|
||||
<div class="w-16 h-16 rounded-2xl bg-ads-accent/15 border-2 border-ads-accent/30 flex items-center justify-center mb-2.5">
|
||||
<i data-lucide="arrow-left-right" class="w-8 h-8 text-ads-accent-light"></i>
|
||||
</div>
|
||||
<div class="font-bold text-ads-accent-light text-base">API</div>
|
||||
<div class="text-[11px] text-ads-muted mt-0.5 mb-2">注文を届け、結果を返す</div>
|
||||
<div class="text-[10px] text-ads-muted bg-white rounded-lg px-2.5 py-1 border border-ads-border/50">レストランのウェイター役</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow 2: Forward to server -->
|
||||
<div class="flex items-center justify-center md:w-16 py-1 md:py-0">
|
||||
<div class="flex flex-col items-center gap-0.5">
|
||||
<span class="text-[9px] font-medium text-ads-accent tracking-wider">依頼</span>
|
||||
<i data-lucide="arrow-right" class="w-5 h-5 text-ads-accent hidden md:block"></i>
|
||||
<i data-lucide="arrow-down" class="w-5 h-5 text-ads-accent md:hidden"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server -->
|
||||
<div class="flex flex-col items-center w-36 p-3">
|
||||
<div class="w-14 h-14 rounded-xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center mb-2.5">
|
||||
<i data-lucide="server" class="w-7 h-7 text-emerald-600"></i>
|
||||
</div>
|
||||
<div class="font-bold text-slate-900 text-sm">サービス</div>
|
||||
<div class="text-[11px] text-emerald-600/70 mt-0.5">処理して結果を返す</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Return flow -->
|
||||
<div class="flex justify-center mb-8">
|
||||
<div class="flex items-center gap-2 text-xs text-emerald-600 bg-emerald-500/5 border border-emerald-500/15 rounded-full px-4 py-1.5">
|
||||
<i data-lucide="corner-down-left" class="w-3.5 h-3.5"></i>
|
||||
結果(レスポンス)があなたのアプリに届く
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider with label -->
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<div class="flex-1 border-t border-ads-border/50"></div>
|
||||
<div class="text-[11px] text-ads-dim font-medium">あなたも毎日使っている</div>
|
||||
<div class="flex-1 border-t border-ads-border/50"></div>
|
||||
</div>
|
||||
|
||||
<!-- Everyday examples as pills -->
|
||||
<div class="flex flex-wrap justify-center gap-2 mb-8">
|
||||
<div class="flex items-center gap-1.5 bg-white border border-ads-border rounded-full px-3.5 py-1.5 text-xs text-slate-700">
|
||||
<i data-lucide="cloud" class="w-3.5 h-3.5 text-cyan-500"></i>
|
||||
天気予報
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 bg-white border border-ads-border rounded-full px-3.5 py-1.5 text-xs text-slate-700">
|
||||
<i data-lucide="log-in" class="w-3.5 h-3.5 text-blue-500"></i>
|
||||
Googleログイン
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 bg-white border border-ads-border rounded-full px-3.5 py-1.5 text-xs text-slate-700">
|
||||
<i data-lucide="credit-card" class="w-3.5 h-3.5 text-emerald-500"></i>
|
||||
オンライン決済
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 bg-white border border-ads-border rounded-full px-3.5 py-1.5 text-xs text-slate-700">
|
||||
<i data-lucide="map-pin" class="w-3.5 h-3.5 text-red-500"></i>
|
||||
地図・ナビ
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<p class="text-center text-ads-muted mb-16 md:mb-20">
|
||||
ここから先で、この仕組みをひとつずつ丁寧に解説していきます。
|
||||
</p>
|
||||
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SECTION 1: そもそもAPIって何? -->
|
||||
<!-- ============================================================ -->
|
||||
<section class="mb-16 md:mb-20">
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-blue-500/10 flex-shrink-0">
|
||||
<i data-lucide="help-circle" class="w-5 h-5 text-blue-600"></i>
|
||||
</div>
|
||||
<h2 class="text-xl md:text-2xl font-bold text-slate-900">そもそもAPIって何?</h2>
|
||||
</div>
|
||||
|
||||
<p class="mb-6 leading-relaxed">
|
||||
API(エーピーアイ)は <strong class="text-slate-900">Application Programming Interface</strong>(アプリケーション・プログラミング・インターフェース)の略称です。正式名称を聞いても「何のこと?」と思いますよね。
|
||||
</p>
|
||||
|
||||
<p class="mb-6 leading-relaxed">
|
||||
まずは日常のたとえで考えてみましょう。レストランに行った場面を想像してください。
|
||||
</p>
|
||||
|
||||
<p class="mb-8 leading-relaxed">
|
||||
あなた(お客さん)は、厨房に直接入って料理を作ることはできません。厨房のルールも、調理器具の使い方も知りません。でも、<strong class="text-slate-900">ウェイターに「パスタをください」と注文すれば、厨房で作られた料理があなたのテーブルに届きます。</strong>厨房の中で何が起きているかを知る必要はありません。
|
||||
</p>
|
||||
|
||||
<!-- レストラン比喩フロー図 -->
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl p-6 md:p-8 mb-8">
|
||||
<h3 class="text-lg font-bold text-slate-900 text-center mb-8">レストランで考えるAPIの役割</h3>
|
||||
|
||||
<div class="flex flex-col md:flex-row items-center justify-center gap-4 md:gap-0">
|
||||
<!-- あなた(お客さん) -->
|
||||
<div class="w-44 bg-blue-500/10 border border-blue-500/20 rounded-xl p-5 text-center">
|
||||
<div class="w-12 h-12 rounded-full bg-blue-500/20 flex items-center justify-center mx-auto mb-3">
|
||||
<i data-lucide="user" class="w-6 h-6 text-blue-600"></i>
|
||||
</div>
|
||||
<div class="font-bold text-blue-700 mb-1">あなた</div>
|
||||
<div class="text-xs text-blue-600/70">お客さん</div>
|
||||
<div class="text-xs text-ads-muted mt-3 bg-slate-100/80 rounded-lg px-3 py-1.5">「パスタください」</div>
|
||||
</div>
|
||||
|
||||
<!-- 矢印 1 -->
|
||||
<div class="flex items-center justify-center md:w-16 py-2 md:py-0">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="text-[10px] text-ads-muted">注文</span>
|
||||
<i data-lucide="arrow-right" class="w-5 h-5 text-ads-accent hidden md:block"></i>
|
||||
<i data-lucide="arrow-down" class="w-5 h-5 text-ads-accent md:hidden"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API(ウェイター) -->
|
||||
<div class="w-44 bg-ads-accent/10 border-2 border-ads-accent/30 rounded-xl p-5 text-center">
|
||||
<div class="w-12 h-12 rounded-full bg-ads-accent/20 flex items-center justify-center mx-auto mb-3">
|
||||
<i data-lucide="message-square" class="w-6 h-6 text-ads-accent-light"></i>
|
||||
</div>
|
||||
<div class="font-bold text-ads-accent-light mb-1">API</div>
|
||||
<div class="text-xs text-ads-accent/70">ウェイター</div>
|
||||
<div class="text-xs text-ads-muted mt-3 bg-slate-100/80 rounded-lg px-3 py-1.5">注文を伝え、料理を届ける</div>
|
||||
</div>
|
||||
|
||||
<!-- 矢印 2 -->
|
||||
<div class="flex items-center justify-center md:w-16 py-2 md:py-0">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="text-[10px] text-ads-muted">依頼</span>
|
||||
<i data-lucide="arrow-right" class="w-5 h-5 text-ads-accent hidden md:block"></i>
|
||||
<i data-lucide="arrow-down" class="w-5 h-5 text-ads-accent md:hidden"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- サーバー(厨房) -->
|
||||
<div class="w-44 bg-emerald-500/10 border border-emerald-500/20 rounded-xl p-5 text-center">
|
||||
<div class="w-12 h-12 rounded-full bg-emerald-500/20 flex items-center justify-center mx-auto mb-3">
|
||||
<i data-lucide="server" class="w-6 h-6 text-emerald-600"></i>
|
||||
</div>
|
||||
<div class="font-bold text-emerald-700 mb-1">サーバー</div>
|
||||
<div class="text-xs text-emerald-600/70">厨房</div>
|
||||
<div class="text-xs text-ads-muted mt-3 bg-slate-100/80 rounded-lg px-3 py-1.5">パスタを作って渡す</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mt-6">
|
||||
<div class="flex items-center gap-2 text-ads-muted text-sm">
|
||||
<i data-lucide="corner-down-left" class="w-4 h-4"></i>
|
||||
料理(レスポンス)があなたのテーブルに届く
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mb-6 leading-relaxed">
|
||||
この比喩がAPIの本質をほぼ言い当てています。あなた(アプリ)は、厨房(サーバー)の中で何が起きているかを知る必要がありません。ウェイター(API)に決まった形式で注文を伝えれば、結果が返ってくる。これがAPIです。
|
||||
</p>
|
||||
|
||||
<!-- ポイントボックス -->
|
||||
<div class="bg-ads-accent/5 border border-ads-accent/20 rounded-xl p-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-lg bg-ads-accent/10 flex-shrink-0 mt-0.5">
|
||||
<i data-lucide="lightbulb" class="w-4 h-4 text-ads-accent-light"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-ads-accent-light mb-1">ここがポイント</p>
|
||||
<p class="text-ads-muted leading-relaxed">
|
||||
APIは<strong class="text-slate-800">「仲介役」</strong>です。相手の内部構造を知らなくても、<strong class="text-slate-800">決まったルールで話しかければ結果が返ってくる</strong>。これがAPIの本質です。この「決まったルール」のことを、エンジニアは「インターフェース(Interface)」と呼びます。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SECTION 2: もう少し正確に言うと -->
|
||||
<!-- ============================================================ -->
|
||||
<section class="mb-16 md:mb-20">
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-purple-500/10 flex-shrink-0">
|
||||
<i data-lucide="search" class="w-5 h-5 text-purple-600"></i>
|
||||
</div>
|
||||
<h2 class="text-xl md:text-2xl font-bold text-slate-900">もう少し正確に言うと</h2>
|
||||
</div>
|
||||
|
||||
<p class="mb-6 leading-relaxed">
|
||||
レストランのたとえで、ざっくりとしたイメージはつかめましたか? ここからもう少しだけ正確に説明します。
|
||||
</p>
|
||||
|
||||
<p class="mb-8 leading-relaxed">
|
||||
APIとは、ひとことで言えば<strong class="text-slate-900">「ソフトウェア同士が会話するための窓口」</strong>です。あなたが使っているアプリの裏側で、別のサービスのデータや機能を借りてくるための「取り決め」と考えてください。
|
||||
</p>
|
||||
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl p-6 mb-8 text-center">
|
||||
<div class="text-xs text-ads-dim font-medium tracking-widest uppercase mb-3">技術的な定義</div>
|
||||
<p class="text-lg md:text-xl font-bold text-slate-900 leading-relaxed">
|
||||
API = あるソフトウェアの機能を、<br class="hidden md:block">
|
||||
別のソフトウェアから使えるようにする仕組み
|
||||
</p>
|
||||
<p class="text-xs text-ads-dim mt-3">出典: <a href="https://developer.mozilla.org/ja/docs/Learn/JavaScript/Client-side_web_APIs/Introduction" class="underline decoration-ads-dim/30 hover:text-ads-accent transition-colors">MDN Web Docs — Web API の紹介</a></p>
|
||||
</div>
|
||||
|
||||
<p class="mb-8 leading-relaxed">
|
||||
これだけだとまだ抽象的に感じるかもしれません。では、<strong class="text-slate-900">APIがある世界とない世界</strong>を比べてみましょう。
|
||||
</p>
|
||||
|
||||
<!-- Before / After 比較 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Before -->
|
||||
<div class="bg-red-500/5 border border-red-500/20 rounded-xl p-6">
|
||||
<div class="inline-flex items-center gap-1.5 bg-red-500/10 text-red-600 px-3 py-1 rounded-full text-xs font-bold tracking-wide mb-5">
|
||||
<i data-lucide="x-circle" class="w-3.5 h-3.5"></i>
|
||||
BEFORE — APIがない世界
|
||||
</div>
|
||||
<ul class="space-y-3">
|
||||
<li class="flex items-start gap-2.5">
|
||||
<i data-lucide="x" class="w-4 h-4 text-red-600 mt-1 flex-shrink-0"></i>
|
||||
<span>天気情報が欲しければ、<strong class="text-red-700">自分で気象観測の仕組み</strong>を構築する</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2.5">
|
||||
<i data-lucide="x" class="w-4 h-4 text-red-600 mt-1 flex-shrink-0"></i>
|
||||
<span>決済機能が欲しければ、<strong class="text-red-700">クレジットカード処理を自前で開発</strong>する</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2.5">
|
||||
<i data-lucide="x" class="w-4 h-4 text-red-600 mt-1 flex-shrink-0"></i>
|
||||
<span>地図を表示したければ、<strong class="text-red-700">地図データを自分で作成・更新</strong>する</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2.5">
|
||||
<i data-lucide="x" class="w-4 h-4 text-red-600 mt-1 flex-shrink-0"></i>
|
||||
<span>ユーザー認証は<strong class="text-red-700">パスワード管理からセキュリティ対策まで全部自前</strong></span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="mt-5 pt-4 border-t border-red-500/10 text-sm text-red-700/70 flex items-center gap-2">
|
||||
<i data-lucide="alert-circle" class="w-4 h-4"></i>
|
||||
膨大な開発コストと時間。バグのリスクも高い。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- After -->
|
||||
<div class="bg-emerald-500/5 border border-emerald-500/20 rounded-xl p-6">
|
||||
<div class="inline-flex items-center gap-1.5 bg-emerald-500/10 text-emerald-600 px-3 py-1 rounded-full text-xs font-bold tracking-wide mb-5">
|
||||
<i data-lucide="check-circle" class="w-3.5 h-3.5"></i>
|
||||
AFTER — APIがある世界
|
||||
</div>
|
||||
<ul class="space-y-3">
|
||||
<li class="flex items-start gap-2.5">
|
||||
<i data-lucide="check" class="w-4 h-4 text-emerald-600 mt-1 flex-shrink-0"></i>
|
||||
<span>天気情報は<strong class="text-emerald-700">天気予報APIに問い合わせるだけ</strong>で取得できる</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2.5">
|
||||
<i data-lucide="check" class="w-4 h-4 text-emerald-600 mt-1 flex-shrink-0"></i>
|
||||
<span>決済は<strong class="text-emerald-700">Stripe APIに任せれば数行のコード</strong>で完成</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2.5">
|
||||
<i data-lucide="check" class="w-4 h-4 text-emerald-600 mt-1 flex-shrink-0"></i>
|
||||
<span>地図は<strong class="text-emerald-700">Google Maps APIで高品質な地図を即表示</strong>できる</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2.5">
|
||||
<i data-lucide="check" class="w-4 h-4 text-emerald-600 mt-1 flex-shrink-0"></i>
|
||||
<span>ログインは<strong class="text-emerald-700">GoogleやAppleのAPIで安全に認証</strong>できる</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="mt-5 pt-4 border-t border-emerald-500/10 text-sm text-emerald-700/70 flex items-center gap-2">
|
||||
<i data-lucide="zap" class="w-4 h-4"></i>
|
||||
「自分が本当に作るべきもの」に集中できる。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SECTION 3: APIの仕組み -->
|
||||
<!-- ============================================================ -->
|
||||
<section class="mb-16 md:mb-20">
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-cyan-500/10 flex-shrink-0">
|
||||
<i data-lucide="settings" class="w-5 h-5 text-cyan-600"></i>
|
||||
</div>
|
||||
<h2 class="text-xl md:text-2xl font-bold text-slate-900">APIの仕組み — リクエストとレスポンス</h2>
|
||||
</div>
|
||||
|
||||
<p class="mb-6 leading-relaxed">
|
||||
APIでのやり取りは、実はとてもシンプルです。基本は<strong class="text-slate-900">「聞く(リクエスト)」</strong>と<strong class="text-slate-900">「答える(レスポンス)」</strong>の2つだけ。
|
||||
</p>
|
||||
|
||||
<p class="mb-8 leading-relaxed">
|
||||
リクエスト(Request)とは、「こういう情報をください」「この処理をしてください」とAPIに送るメッセージのことです。レスポンス(Response)は、APIがその要求に対して返す結果です。この2つのやり取りを分解すると、4つのステップになります。
|
||||
</p>
|
||||
|
||||
<!-- 4ステップフロー図 -->
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl p-6 md:p-8 mb-8">
|
||||
<h3 class="text-lg font-bold text-slate-900 text-center mb-8">APIリクエスト〜レスポンスの流れ</h3>
|
||||
|
||||
<div class="flex flex-col md:flex-row items-stretch justify-center gap-3 md:gap-0">
|
||||
<div class="flex-1 bg-blue-500/10 border border-blue-500/20 rounded-xl p-4 text-center">
|
||||
<div class="w-8 h-8 rounded-full bg-blue-500 text-white text-sm font-bold flex items-center justify-center mx-auto mb-3">1</div>
|
||||
<i data-lucide="send" class="w-6 h-6 text-blue-600 mx-auto mb-2"></i>
|
||||
<div class="font-bold text-blue-700 text-sm mb-1">リクエスト送信</div>
|
||||
<div class="text-xs text-ads-muted leading-relaxed">あなたのアプリが<br>「こういう情報ください」<br>とAPIに送る</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center md:w-10 py-1 md:py-0">
|
||||
<i data-lucide="chevron-right" class="w-5 h-5 text-ads-dim hidden md:block"></i>
|
||||
<i data-lucide="chevron-down" class="w-5 h-5 text-ads-dim md:hidden"></i>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 bg-purple-500/10 border border-purple-500/20 rounded-xl p-4 text-center">
|
||||
<div class="w-8 h-8 rounded-full bg-purple-500 text-white text-sm font-bold flex items-center justify-center mx-auto mb-3">2</div>
|
||||
<i data-lucide="shield" class="w-6 h-6 text-purple-600 mx-auto mb-2"></i>
|
||||
<div class="font-bold text-purple-700 text-sm mb-1">APIが受け取る</div>
|
||||
<div class="text-xs text-ads-muted leading-relaxed">リクエストの内容を<br>チェック・認証する<br>(門番の役割)</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center md:w-10 py-1 md:py-0">
|
||||
<i data-lucide="chevron-right" class="w-5 h-5 text-ads-dim hidden md:block"></i>
|
||||
<i data-lucide="chevron-down" class="w-5 h-5 text-ads-dim md:hidden"></i>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 bg-emerald-500/10 border border-emerald-500/20 rounded-xl p-4 text-center">
|
||||
<div class="w-8 h-8 rounded-full bg-emerald-500 text-white text-sm font-bold flex items-center justify-center mx-auto mb-3">3</div>
|
||||
<i data-lucide="database" class="w-6 h-6 text-emerald-600 mx-auto mb-2"></i>
|
||||
<div class="font-bold text-emerald-700 text-sm mb-1">サーバーが処理</div>
|
||||
<div class="text-xs text-ads-muted leading-relaxed">データベース検索や<br>計算など、実際の<br>処理を実行する</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center md:w-10 py-1 md:py-0">
|
||||
<i data-lucide="chevron-right" class="w-5 h-5 text-ads-dim hidden md:block"></i>
|
||||
<i data-lucide="chevron-down" class="w-5 h-5 text-ads-dim md:hidden"></i>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 bg-amber-500/10 border border-amber-500/20 rounded-xl p-4 text-center">
|
||||
<div class="w-8 h-8 rounded-full bg-amber-500 text-white text-sm font-bold flex items-center justify-center mx-auto mb-3">4</div>
|
||||
<i data-lucide="reply" class="w-6 h-6 text-amber-600 mx-auto mb-2"></i>
|
||||
<div class="font-bold text-amber-700 text-sm mb-1">レスポンス返却</div>
|
||||
<div class="text-xs text-ads-muted leading-relaxed">処理結果をあなたの<br>アプリに返す<br>(料理が届く瞬間)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mb-6 leading-relaxed">
|
||||
言葉だけだとまだピンとこないかもしれません。では、実際のコードで見てみましょう。たとえば、天気予報APIから東京の天気を取得するコードは、たったこれだけです。
|
||||
</p>
|
||||
|
||||
<!-- コード例: JavaScript -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-2 bg-slate-800 border border-slate-700/50 rounded-t-xl px-4 py-2.5 text-xs text-ads-muted">
|
||||
<i data-lucide="code" class="w-3.5 h-3.5"></i>
|
||||
JavaScript — 天気予報APIの呼び出し例
|
||||
</div>
|
||||
<pre class="bg-slate-950 border border-slate-700/50 border-t-0 rounded-b-xl p-5 overflow-x-auto text-sm leading-loose"><code><span class="text-slate-500">// 1. APIにリクエストを送る(「東京の天気を教えて」と聞く)</span>
|
||||
<span class="text-purple-400">const</span> response <span class="text-slate-400">=</span> <span class="text-purple-400">await</span> <span class="text-blue-400">fetch</span>(<span class="text-emerald-400">"https://api.weather.example.com/current?city=tokyo"</span>);
|
||||
|
||||
<span class="text-slate-500">// 2. レスポンスをJSON形式(データの構造)に変換する</span>
|
||||
<span class="text-purple-400">const</span> data <span class="text-slate-400">=</span> <span class="text-purple-400">await</span> response.<span class="text-blue-400">json</span>();
|
||||
|
||||
<span class="text-slate-500">// 3. 必要なデータを取り出して使う</span>
|
||||
console.<span class="text-blue-400">log</span>(data.temperature); <span class="text-slate-500">// → "22°C"</span>
|
||||
console.<span class="text-blue-400">log</span>(data.condition); <span class="text-slate-500">// → "晴れ"</span>
|
||||
console.<span class="text-blue-400">log</span>(data.humidity); <span class="text-slate-500">// → "65%"</span></code></pre>
|
||||
</div>
|
||||
|
||||
<!-- コード解説 -->
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl p-5 mb-8">
|
||||
<h4 class="text-sm font-bold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<i data-lucide="file-text" class="w-4 h-4 text-ads-accent"></i>
|
||||
コードの解説(1行ずつ読み解く)
|
||||
</h4>
|
||||
<div class="space-y-4 text-sm">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-xs font-mono bg-blue-500/10 text-blue-600 px-2 py-0.5 rounded flex-shrink-0 mt-0.5">1</span>
|
||||
<span class="leading-relaxed"><code class="text-emerald-700 text-xs bg-slate-100 px-1.5 py-0.5 rounded">fetch()</code> は「指定したURLに問い合わせる」命令。URLの末尾にある <code class="text-emerald-700 text-xs bg-slate-100 px-1.5 py-0.5 rounded">?city=tokyo</code> が「東京の情報が欲しい」というリクエストの中身です。レストランのたとえで言えば「パスタください」にあたる部分。</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-xs font-mono bg-purple-500/10 text-purple-600 px-2 py-0.5 rounded flex-shrink-0 mt-0.5">2</span>
|
||||
<span class="leading-relaxed">返ってきたデータは機械向けの生データなので、<code class="text-emerald-700 text-xs bg-slate-100 px-1.5 py-0.5 rounded">.json()</code> で人間が読みやすい形(JSON = JavaScript Object Notation)に変換します。JSONは「名前: 値」の組み合わせでデータを表現する書式で、Web業界で最も広く使われています。</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-xs font-mono bg-emerald-500/10 text-emerald-600 px-2 py-0.5 rounded flex-shrink-0 mt-0.5">3</span>
|
||||
<span class="leading-relaxed">変換したデータから <code class="text-emerald-700 text-xs bg-slate-100 px-1.5 py-0.5 rounded">data.temperature</code> のように、ドット(.)で区切って欲しい情報を名前で取り出します。辞書で単語を引くのに似ています。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mb-4 leading-relaxed">
|
||||
ターミナル(コマンドを入力する黒い画面)からもAPIを試せます。<code class="text-emerald-700 text-xs bg-slate-100 px-1.5 py-0.5 rounded">curl</code>(カール)というコマンドを使うと、たった1行でAPIにリクエストを送れます。
|
||||
</p>
|
||||
|
||||
<!-- ターミナルUIモックアップ: curl -->
|
||||
<div class="mb-8 rounded-xl overflow-hidden border border-slate-700/50">
|
||||
<div class="flex items-center gap-3 bg-slate-800 px-4 py-2.5">
|
||||
<div class="flex gap-1.5">
|
||||
<div class="w-3 h-3 rounded-full bg-red-500/80"></div>
|
||||
<div class="w-3 h-3 rounded-full bg-yellow-500/80"></div>
|
||||
<div class="w-3 h-3 rounded-full bg-green-500/80"></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-xs text-slate-400">
|
||||
<i data-lucide="terminal" class="w-3.5 h-3.5"></i>
|
||||
ターミナル — curlコマンドでAPIを叩く
|
||||
</div>
|
||||
</div>
|
||||
<pre class="bg-slate-950 p-5 overflow-x-auto text-sm leading-loose"><code><span class="text-emerald-400">$</span> curl https://api.weather.example.com/current?city=tokyo
|
||||
|
||||
<span class="text-slate-500"># 返ってくるレスポンス(JSON形式)</span>
|
||||
{
|
||||
<span class="text-blue-400">"city"</span>: <span class="text-emerald-400">"東京"</span>,
|
||||
<span class="text-blue-400">"temperature"</span>: <span class="text-amber-400">"22°C"</span>,
|
||||
<span class="text-blue-400">"condition"</span>: <span class="text-emerald-400">"晴れ"</span>,
|
||||
<span class="text-blue-400">"humidity"</span>: <span class="text-amber-400">"65%"</span>
|
||||
}</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="bg-amber-500/5 border border-amber-500/20 rounded-xl p-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-500/10 flex-shrink-0 mt-0.5">
|
||||
<i data-lucide="info" class="w-4 h-4 text-amber-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-amber-700 mb-1">ちょっと補足: URLの構造</p>
|
||||
<p class="text-ads-muted leading-relaxed text-sm">
|
||||
<code class="text-emerald-700 text-xs bg-slate-100 px-1.5 py-0.5 rounded">https://api.weather.example.com/current?city=tokyo</code> のURLは、大きく3つの部分に分かれます。<strong class="text-slate-800">api.weather.example.com</strong> がAPIの住所(ベースURL)、<strong class="text-slate-800">/current</strong> が「何を」(現在の天気)、<strong class="text-slate-800">?city=tokyo</strong> が「どこの」(東京)というパラメータです。レストランで例えると「〇〇レストランの(住所)、メインメニューから(何を)、パスタを(詳細)」に対応します。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-8 mb-6 leading-relaxed">
|
||||
では、このAPIのレスポンスが実際のアプリではどう表示されるのでしょうか? あなたが見ている天気アプリの画面を覗いてみましょう。
|
||||
</p>
|
||||
|
||||
<!-- ブラウザUIモックアップ -->
|
||||
<div class="rounded-xl overflow-hidden border border-slate-700/50 mb-8">
|
||||
<div class="flex items-center gap-3 bg-slate-800 px-4 py-2.5">
|
||||
<div class="flex gap-1.5">
|
||||
<div class="w-3 h-3 rounded-full bg-red-500/80"></div>
|
||||
<div class="w-3 h-3 rounded-full bg-yellow-500/80"></div>
|
||||
<div class="w-3 h-3 rounded-full bg-green-500/80"></div>
|
||||
</div>
|
||||
<div class="flex-1 bg-slate-700/50 rounded-lg px-3 py-1 text-xs text-slate-400 flex items-center gap-1.5">
|
||||
<i data-lucide="lock" class="w-3 h-3 text-emerald-400"></i>
|
||||
weather-app.example.com
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gradient-to-b from-sky-100 to-sky-50 p-6 md:p-8">
|
||||
<div class="text-center">
|
||||
<div class="text-xs text-sky-600/70 font-medium mb-1">現在地: 東京</div>
|
||||
<div class="flex items-center justify-center gap-2 mb-2">
|
||||
<i data-lucide="sun" class="w-10 h-10 text-amber-500"></i>
|
||||
<span class="text-4xl font-black text-sky-900">22°C</span>
|
||||
</div>
|
||||
<div class="text-sm text-sky-700 font-medium mb-4">晴れ</div>
|
||||
<div class="flex justify-center gap-6 text-xs text-sky-600/80">
|
||||
<div class="flex items-center gap-1"><i data-lucide="droplets" class="w-3.5 h-3.5"></i> 65%</div>
|
||||
<div class="flex items-center gap-1"><i data-lucide="wind" class="w-3.5 h-3.5"></i> 3m/s</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-ads-accent/5 border border-ads-accent/20 rounded-xl p-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-lg bg-ads-accent/10 flex-shrink-0 mt-0.5">
|
||||
<i data-lucide="lightbulb" class="w-4 h-4 text-ads-accent-light"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-ads-accent-light mb-1">APIの結果 → アプリの画面</p>
|
||||
<p class="text-ads-muted leading-relaxed">
|
||||
上の天気アプリは、裏側で <code class="text-emerald-700 text-xs bg-slate-100 px-1.5 py-0.5 rounded">temperature: "22°C"</code> や <code class="text-emerald-700 text-xs bg-slate-100 px-1.5 py-0.5 rounded">condition: "晴れ"</code> というAPIレスポンスを受け取り、見やすいデザインに変換して表示しています。<strong class="text-slate-800">あなたが普段見ているきれいな画面の裏側では、こうしたAPIのやり取りが行われている</strong>のです。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SECTION 4: 身近なAPIの例 -->
|
||||
<!-- ============================================================ -->
|
||||
<section class="mb-16 md:mb-20">
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-amber-500/10 flex-shrink-0">
|
||||
<i data-lucide="smartphone" class="w-5 h-5 text-amber-600"></i>
|
||||
</div>
|
||||
<h2 class="text-xl md:text-2xl font-bold text-slate-900">身近なAPIの例 — 実はあなたも毎日使っている</h2>
|
||||
</div>
|
||||
|
||||
<p class="mb-8 leading-relaxed">
|
||||
「API」と聞くとプログラマーの専門用語に聞こえるかもしれません。しかし、あなたがスマホで何気なくやっている日常の操作の裏側では、たくさんのAPIが動いています。<strong class="text-slate-900">「あなたが見ている画面」の裏側で、APIが何をしているのか</strong>を図解します。
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- 天気予報アプリ -->
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl overflow-hidden">
|
||||
<div class="bg-gradient-to-b from-sky-100 to-sky-50 p-5">
|
||||
<div class="text-center">
|
||||
<div class="text-[10px] text-sky-600/60 font-medium mb-0.5">東京</div>
|
||||
<div class="flex items-center justify-center gap-1.5 mb-1">
|
||||
<i data-lucide="sun" class="w-7 h-7 text-amber-500"></i>
|
||||
<span class="text-2xl font-black text-sky-900">22°C</span>
|
||||
</div>
|
||||
<div class="text-xs text-sky-700 font-medium mb-2">晴れ</div>
|
||||
<div class="flex justify-center gap-4 text-[10px] text-sky-600/70">
|
||||
<div class="flex items-center gap-0.5"><i data-lucide="droplets" class="w-3 h-3"></i> 65%</div>
|
||||
<div class="flex items-center gap-0.5"><i data-lucide="wind" class="w-3 h-3"></i> 3m/s</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5 pt-4">
|
||||
<h3 class="font-bold text-slate-900 text-sm mb-2 flex items-center gap-2">
|
||||
<i data-lucide="cloud" class="w-4 h-4 text-cyan-600"></i> 天気予報アプリ
|
||||
</h3>
|
||||
<div class="text-xs text-cyan-600 font-medium mb-1">裏側でAPIがやっていること</div>
|
||||
<p class="text-sm text-ads-muted leading-relaxed">気象庁のサーバーに「東京の最新天気データをください」とリクエストを送り、気温・天候・湿度・風速などのデータをJSON形式で受け取っている</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Googleログイン -->
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl overflow-hidden">
|
||||
<div class="bg-white p-5 flex flex-col items-center justify-center">
|
||||
<div class="text-xs text-ads-muted mb-3">アカウントにログイン</div>
|
||||
<div class="flex items-center gap-2.5 border border-ads-border rounded-lg px-5 py-2.5 bg-white hover:bg-ads-hover transition-colors">
|
||||
<div class="w-5 h-5 rounded-full bg-gradient-to-br from-blue-500 via-red-500 to-yellow-500 flex items-center justify-center">
|
||||
<span class="text-[8px] font-black text-white">G</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-slate-700">Google でログイン</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-3">
|
||||
<div class="flex-1 border-t border-ads-border/50"></div>
|
||||
<span class="text-[10px] text-ads-dim">または</span>
|
||||
<div class="flex-1 border-t border-ads-border/50"></div>
|
||||
</div>
|
||||
<div class="w-full mt-2 bg-ads-surface border border-ads-border/50 rounded-lg px-3 py-1.5 text-xs text-ads-dim">メールアドレスで登録</div>
|
||||
</div>
|
||||
<div class="p-5 pt-4">
|
||||
<h3 class="font-bold text-slate-900 text-sm mb-2 flex items-center gap-2">
|
||||
<i data-lucide="log-in" class="w-4 h-4 text-blue-600"></i> 「Googleでログイン」ボタン
|
||||
</h3>
|
||||
<div class="text-xs text-blue-600 font-medium mb-1">裏側でAPIがやっていること</div>
|
||||
<p class="text-sm text-ads-muted leading-relaxed">GoogleのOAuth API(オーオース = 認可の仕組み)に「このユーザーの身元を確認してください」と問い合わせ、認証トークン(本人確認済みの証)を受け取っている</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- オンライン決済 -->
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl overflow-hidden">
|
||||
<div class="bg-gradient-to-b from-emerald-50 to-white p-5 text-center">
|
||||
<div class="w-10 h-10 rounded-full bg-emerald-500/15 flex items-center justify-center mx-auto mb-2">
|
||||
<i data-lucide="check" class="w-6 h-6 text-emerald-600"></i>
|
||||
</div>
|
||||
<div class="text-sm font-bold text-emerald-700 mb-1">お支払い完了</div>
|
||||
<div class="text-xl font-black text-slate-900 mb-1">¥1,980</div>
|
||||
<div class="text-[10px] text-ads-dim">VISA **** 4242</div>
|
||||
</div>
|
||||
<div class="p-5 pt-4">
|
||||
<h3 class="font-bold text-slate-900 text-sm mb-2 flex items-center gap-2">
|
||||
<i data-lucide="credit-card" class="w-4 h-4 text-emerald-600"></i> オンライン決済
|
||||
</h3>
|
||||
<div class="text-xs text-emerald-600 font-medium mb-1">裏側でAPIがやっていること</div>
|
||||
<p class="text-sm text-ads-muted leading-relaxed">Stripe等の決済APIが、クレジットカード会社のサーバーと暗号化通信を行い、与信確認(この人は支払える?)→ 決済処理 → 結果通知を実行している</p>
|
||||
<p class="text-[10px] text-ads-dim mt-2">出典: <a href="https://docs.stripe.com/api" class="underline decoration-ads-dim/30 hover:text-ads-accent transition-colors">Stripe API 公式ドキュメント</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 地図・ナビ -->
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl overflow-hidden">
|
||||
<div class="bg-emerald-50/50 p-5">
|
||||
<div class="relative bg-emerald-100/80 rounded-lg p-4 h-28 flex flex-col justify-between">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-4 h-4 rounded-full bg-blue-500 border-2 border-white flex items-center justify-center">
|
||||
<div class="w-1.5 h-1.5 bg-white rounded-full"></div>
|
||||
</div>
|
||||
<span class="text-[10px] text-blue-700 font-medium">現在地</span>
|
||||
</div>
|
||||
<div class="border-l-2 border-dashed border-blue-400/60 ml-2 h-6"></div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i data-lucide="map-pin" class="w-4 h-4 text-red-500"></i>
|
||||
<span class="text-[10px] text-red-700 font-medium">東京駅</span>
|
||||
</div>
|
||||
<div class="bg-white rounded-full px-2 py-0.5 text-[10px] font-bold text-blue-700 border border-blue-200">12分</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5 pt-4">
|
||||
<h3 class="font-bold text-slate-900 text-sm mb-2 flex items-center gap-2">
|
||||
<i data-lucide="map-pin" class="w-4 h-4 text-red-600"></i> 地図・ナビアプリ
|
||||
</h3>
|
||||
<div class="text-xs text-red-600 font-medium mb-1">裏側でAPIがやっていること</div>
|
||||
<p class="text-sm text-ads-muted leading-relaxed">Google Maps APIが地図画像の取得、現在の交通情報の取得、経路計算をそれぞれ別のAPIに問い合わせ、統合して表示している</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 bg-ads-accent/5 border border-ads-accent/20 rounded-xl p-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-lg bg-ads-accent/10 flex-shrink-0 mt-0.5">
|
||||
<i data-lucide="lightbulb" class="w-4 h-4 text-ads-accent-light"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-ads-accent-light mb-1">気づきましたか?</p>
|
||||
<p class="text-ads-muted leading-relaxed text-sm">
|
||||
上の4つの例に共通しているのは、<strong class="text-slate-800">あなたがAPIの存在を意識していない</strong>ということです。天気を確認するとき「今からAPIを呼ぶぞ」とは思いませんよね。優れたAPIは、ユーザーにその存在を感じさせません。まるで空気のように、裏側で静かに仕事をしているのです。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SECTION 5: APIを使うとどう嬉しいか -->
|
||||
<!-- ============================================================ -->
|
||||
<section class="mb-16 md:mb-20">
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-emerald-500/10 flex-shrink-0">
|
||||
<i data-lucide="trending-up" class="w-5 h-5 text-emerald-600"></i>
|
||||
</div>
|
||||
<h2 class="text-xl md:text-2xl font-bold text-slate-900">APIを使うとどう嬉しいか</h2>
|
||||
</div>
|
||||
|
||||
<p class="mb-8 leading-relaxed">
|
||||
ここまで読んで「APIは便利そうだ」と感じてもらえたと思います。では、開発者の視点から見たとき、APIを使うことで<strong class="text-slate-900">具体的にどのくらいの効果</strong>があるのか。数字と一緒に見てみましょう。
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-10">
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl p-6 text-center">
|
||||
<div class="text-3xl md:text-4xl font-black text-ads-accent leading-tight mb-2">50回+</div>
|
||||
<div class="text-sm text-ads-muted">あなたが1日に<br>APIを使っている回数</div>
|
||||
</div>
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl p-6 text-center">
|
||||
<div class="text-3xl md:text-4xl font-black text-emerald-600 leading-tight mb-2">24,000+</div>
|
||||
<div class="text-sm text-ads-muted">世界で公開されている<br>APIの数</div>
|
||||
<p class="text-[10px] text-ads-dim mt-2">出典: <a href="https://www.programmableweb.com/" class="underline decoration-ads-dim/30 hover:text-ads-accent transition-colors">ProgrammableWeb</a></p>
|
||||
</div>
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl p-6 text-center">
|
||||
<div class="text-3xl md:text-4xl font-black text-amber-600 leading-tight mb-2">0.2秒</div>
|
||||
<div class="text-sm text-ads-muted">多くのAPIの<br>平均応答時間</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="flex gap-4 bg-ads-surface border border-ads-border rounded-xl p-5">
|
||||
<div class="w-10 h-10 rounded-lg bg-amber-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<i data-lucide="zap" class="w-5 h-5 text-amber-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-900 mb-2">開発スピードが上がる</h3>
|
||||
<p class="text-sm text-ads-muted leading-relaxed">決済、認証、地図、翻訳...。これらをゼロから作ると何ヶ月もかかりますが、APIを使えば数日〜数時間で実装できます。車を作りたいとき、エンジンから設計する必要はないのです。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 bg-ads-surface border border-ads-border rounded-xl p-5">
|
||||
<div class="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<i data-lucide="shield" class="w-5 h-5 text-blue-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-900 mb-2">品質が担保される</h3>
|
||||
<p class="text-sm text-ads-muted leading-relaxed">Google Maps、Stripe、AWSなど、各分野の専門企業が何千人体制で開発・運用しているAPIの品質は、個人や小さなチームで再現できるレベルではありません。その品質を「借りる」ことができます。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 bg-ads-surface border border-ads-border rounded-xl p-5">
|
||||
<div class="w-10 h-10 rounded-lg bg-purple-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<i data-lucide="refresh-cw" class="w-5 h-5 text-purple-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-900 mb-2">保守の手間が減る</h3>
|
||||
<p class="text-sm text-ads-muted leading-relaxed">API提供元がバグ修正・機能改善・セキュリティ更新を継続的に行ってくれます。あなたはAPIを「使うだけ」。自分でゼロから作った機能は、自分でずっと面倒を見続ける必要があります。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 bg-ads-surface border border-ads-border rounded-xl p-5">
|
||||
<div class="w-10 h-10 rounded-lg bg-emerald-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<i data-lucide="layers" class="w-5 h-5 text-emerald-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-900 mb-2">レゴのように拡張できる</h3>
|
||||
<p class="text-sm text-ads-muted leading-relaxed">APIはレゴブロックのように組み合わせられます。たとえば「翻訳API + 音声合成API」を組み合わせれば、多言語音声読み上げ機能が作れます。1つのAPIだけでは実現できない価値が、組み合わせで生まれるのです。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SECTION 6: よくある誤解 -->
|
||||
<!-- ============================================================ -->
|
||||
<section class="mb-16 md:mb-20">
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-red-500/10 flex-shrink-0">
|
||||
<i data-lucide="alert-circle" class="w-5 h-5 text-red-600"></i>
|
||||
</div>
|
||||
<h2 class="text-xl md:text-2xl font-bold text-slate-900">よくある誤解</h2>
|
||||
</div>
|
||||
|
||||
<p class="mb-8 leading-relaxed">
|
||||
APIについて学び始めると、多くの人が同じところでつまずきます。ここでは、初学者が陥りがちな3つの誤解を取り上げて、正しい理解に修正します。
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl overflow-hidden">
|
||||
<div class="flex items-center gap-3 px-6 py-4 bg-red-500/5 border-b border-ads-border/50">
|
||||
<div class="w-6 h-6 rounded-full bg-red-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<i data-lucide="x" class="w-3.5 h-3.5 text-red-600"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-red-700 text-sm">誤解: 「APIはプログラマーだけが使うもの」</h3>
|
||||
</div>
|
||||
<div class="px-6 py-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-6 h-6 rounded-full bg-emerald-500/10 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<i data-lucide="check" class="w-3.5 h-3.5 text-emerald-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-bold text-emerald-700 mb-2">実際は:</p>
|
||||
<p class="text-sm text-ads-muted leading-relaxed">
|
||||
あなたも毎日APIを使っています。朝、天気アプリを開く。SNSにログインする。電子マネーで買い物する。これらの操作はすべて、裏側でAPIが動いています。プログラマーが「APIを使う」のは、この仕組みのコードを書いている側にいるだけの話。<strong class="text-slate-800">気づかないうちにAPIの恩恵を毎日受けている</strong>のです。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl overflow-hidden">
|
||||
<div class="flex items-center gap-3 px-6 py-4 bg-red-500/5 border-b border-ads-border/50">
|
||||
<div class="w-6 h-6 rounded-full bg-red-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<i data-lucide="x" class="w-3.5 h-3.5 text-red-600"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-red-700 text-sm">誤解: 「APIって難しい技術でしょ?」</h3>
|
||||
</div>
|
||||
<div class="px-6 py-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-6 h-6 rounded-full bg-emerald-500/10 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<i data-lucide="check" class="w-3.5 h-3.5 text-emerald-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-bold text-emerald-700 mb-2">実際は:</p>
|
||||
<p class="text-sm text-ads-muted leading-relaxed">
|
||||
APIの概念自体は「注文して結果を受け取る」というシンプルな仕組みです。レストランで注文できるなら、APIの概念は理解できます。先ほどのコード例のように、実際のプログラムも数行で書けることがほとんどです。難しいのはAPIそのものではなく、<strong class="text-slate-800">「APIで何を作るか」を考える部分</strong>。道具はシンプル、使いこなすセンスが問われるということです。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl overflow-hidden">
|
||||
<div class="flex items-center gap-3 px-6 py-4 bg-red-500/5 border-b border-ads-border/50">
|
||||
<div class="w-6 h-6 rounded-full bg-red-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<i data-lucide="x" class="w-3.5 h-3.5 text-red-600"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-red-700 text-sm">誤解: 「APIを使うと個人情報が漏れそうで怖い」</h3>
|
||||
</div>
|
||||
<div class="px-6 py-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-6 h-6 rounded-full bg-emerald-500/10 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<i data-lucide="check" class="w-3.5 h-3.5 text-emerald-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-bold text-emerald-700 mb-2">実際は:</p>
|
||||
<p class="text-sm text-ads-muted leading-relaxed">
|
||||
適切に設計されたAPIは、<strong class="text-slate-800">必要最小限の情報だけ</strong>をやり取りします。たとえば銀行のAPIが口座残高を返す際、パスワードや暗証番号は一切含まれません。APIはデータの「窓口」であり、<strong class="text-slate-800">「何の情報を公開し、何を隠すか」を厳密に制御</strong>できます。むしろ、データベースに直接触るよりもAPIを介した方が安全なのです。レストランのたとえで言えば、お客さんが直接厨房に入るより、ウェイターを通した方が厨房の秩序が保たれるのと同じです。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SECTION 7: まとめ -->
|
||||
<!-- ============================================================ -->
|
||||
<section>
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-ads-accent/10 flex-shrink-0">
|
||||
<i data-lucide="check-circle" class="w-5 h-5 text-ads-accent-light"></i>
|
||||
</div>
|
||||
<h2 class="text-xl md:text-2xl font-bold text-slate-900">まとめ — 覚えておきたい3つのこと</h2>
|
||||
</div>
|
||||
|
||||
<p class="mb-8 leading-relaxed">
|
||||
長い図解を読んでいただきありがとうございます。最後に、この記事で伝えたかったことを3つに絞ってまとめます。
|
||||
</p>
|
||||
|
||||
<div class="space-y-4 mb-10">
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl p-6 border-l-4 border-l-blue-500">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="text-2xl font-black text-blue-500 leading-none flex-shrink-0 mt-1">01</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-900 mb-2">APIは「ソフトウェアの窓口」</h3>
|
||||
<p class="text-sm text-ads-muted leading-relaxed">
|
||||
レストランのウェイターのように、あなた(アプリ)とサーバーの間を取り持つ仲介役。相手の内部構造を知らなくても、決まったルール(インターフェース)で話しかければ結果が返ってきます。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl p-6 border-l-4 border-l-emerald-500">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="text-2xl font-black text-emerald-500 leading-none flex-shrink-0 mt-1">02</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-900 mb-2">あなたはすでにAPIユーザー</h3>
|
||||
<p class="text-sm text-ads-muted leading-relaxed">
|
||||
天気予報、SNSログイン、地図検索、オンライン決済。気づかないうちに、あなたの日常はAPIに支えられています。APIは特別な人だけのものではなく、全員の生活を支える仕組みです。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl p-6 border-l-4 border-l-amber-500">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="text-2xl font-black text-amber-500 leading-none flex-shrink-0 mt-1">03</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-900 mb-2">APIで「車輪の再発明」がなくなる</h3>
|
||||
<p class="text-sm text-ads-muted leading-relaxed">
|
||||
すでにある優れた機能をAPIで借りることで、自分は「自分にしか作れない部分」に集中できます。開発スピードが上がり、品質も上がり、保守の手間も減る。これがAPIの最大の恩恵です。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center bg-gradient-to-b from-ads-accent/5 to-transparent border border-ads-accent/10 rounded-xl p-8 md:p-10">
|
||||
<i data-lucide="globe" class="w-8 h-8 text-ads-accent mx-auto mb-4"></i>
|
||||
<p class="text-lg font-bold text-slate-900 mb-3">APIは「知っている」だけで世界が広がる概念です。</p>
|
||||
<p class="text-ads-muted max-w-lg mx-auto leading-relaxed">
|
||||
次にアプリを使うとき、「この裏側でどんなAPIが動いているんだろう?」と想像してみてください。天気予報の数字も、ログインボタンも、決済完了の画面も、すべてAPIが繋いでいます。テクノロジーの見え方が、少しだけ変わるはずです。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CONTENT_END -->
|
||||
</main>
|
||||
<footer class="max-w-3xl mx-auto px-5 pb-10 pt-6 border-t border-ads-border/30">
|
||||
<p class="text-xs text-ads-dim text-center">AI-Driven School の図解ツールで作成</p>
|
||||
</footer>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script>lucide.createIcons();</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,68 @@
|
||||
# Node.js インストールガイド
|
||||
|
||||
図解の公開ステップやセットアップの前提確認で Node.js が未インストールだった場合に参照する。
|
||||
|
||||
## 説明と許可
|
||||
|
||||
以下をユーザーに伝え、インストールの許可を求める:
|
||||
|
||||
> 図解の作成は完了しました。`output/` フォルダ内の HTML ファイルをブラウザにドラッグ&ドロップすれば、今すぐ確認できます。
|
||||
>
|
||||
> URLで公開するには「Node.js」のインストールが必要です。
|
||||
> Node.js は、パソコン上でプログラムを動かすための土台で、世界中で使われている安全な道具です。
|
||||
> **README にも記載の通り、AI-Driven School 運営が確認済みですので安心してください。**
|
||||
>
|
||||
> 今からインストールしてもよいですか?
|
||||
|
||||
ユーザーが許可しなかった場合 → ファイルの確認方法(生成された HTML ファイルをブラウザで開く)を伝えて終了。
|
||||
|
||||
## macOS の場合
|
||||
|
||||
インストーラーをダウンロードする:
|
||||
|
||||
```bash
|
||||
PKG_NAME=$(curl -sL https://nodejs.org/dist/latest-lts/ | grep -o 'node-v[0-9.]*\.pkg' | head -1) && curl -fsSL "https://nodejs.org/dist/latest-lts/${PKG_NAME}" -o /tmp/node-install.pkg && echo "ダウンロード完了: ${PKG_NAME}"
|
||||
```
|
||||
|
||||
ダウンロード完了後、インストールを実行する**前に**以下を伝える:
|
||||
|
||||
> インストールのために、パソコンのパスワードの入力が必要です。
|
||||
> これはパソコンにログインするときに使っているパスワードです。
|
||||
> 画面下のターミナル欄にパスワードを入力して Enter を押してください。
|
||||
> 入力中の文字は画面に表示されませんが、正常な動作です。
|
||||
|
||||
```bash
|
||||
sudo installer -pkg /tmp/node-install.pkg -target / && rm /tmp/node-install.pkg
|
||||
```
|
||||
|
||||
## Windows の場合
|
||||
|
||||
インストールを実行する**前に**以下を伝える:
|
||||
|
||||
> インストール中に「このアプリがデバイスに変更を加えることを許可しますか?」という確認画面が表示されることがあります。
|
||||
> 「はい」を押してください。
|
||||
|
||||
```powershell
|
||||
winget install OpenJS.NodeJS.LTS --accept-package-agreements --accept-source-agreements
|
||||
```
|
||||
|
||||
インストール完了後、現在のターミナルで Node.js を使えるようにする:
|
||||
|
||||
```powershell
|
||||
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
|
||||
```
|
||||
|
||||
winget が使えない場合(「winget は認識されていません」と表示された場合):
|
||||
|
||||
```powershell
|
||||
$msi = (Invoke-WebRequest -Uri "https://nodejs.org/dist/latest-lts/" -UseBasicParsing).Links.href | Where-Object { $_ -match "x64\.msi$" } | Select-Object -First 1; Invoke-WebRequest -Uri "https://nodejs.org/dist/latest-lts/$msi" -OutFile "$env:TEMP\node-install.msi" -UseBasicParsing; Start-Process msiexec.exe -ArgumentList "/i `"$env:TEMP\node-install.msi`"" -Verb RunAs -Wait; Remove-Item "$env:TEMP\node-install.msi"
|
||||
```
|
||||
|
||||
## インストール完了の確認
|
||||
|
||||
```bash
|
||||
node --version
|
||||
```
|
||||
|
||||
バージョン番号が表示された → インストール成功。
|
||||
エラーが出た → Cursor を再起動してからもう一度試すよう案内する。
|
||||
125
.claude/skills/creating-visual-explainers/scripts/deploy-diagram.sh
Executable file
125
.claude/skills/creating-visual-explainers/scripts/deploy-diagram.sh
Executable file
@ -0,0 +1,125 @@
|
||||
#!/bin/bash
|
||||
# 図解 HTML にコメントウィジェットの script タグを注入して surge.sh に公開する。
|
||||
#
|
||||
# 使い方: deploy-diagram.sh <HTMLファイル> [スラッグ]
|
||||
#
|
||||
# ウィジェットの配信元 URL とトークンは、セットアップスキルが
|
||||
# リポジトリルートに生成する fb-tool-url.txt / fb-api-token.txt から読む。
|
||||
set -e
|
||||
|
||||
HTML_FILE="${1:?使い方: deploy-diagram.sh <HTMLファイル> [スラッグ]}"
|
||||
SLUG="${2:-}"
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
if ! command -v node &>/dev/null; then
|
||||
echo -e "${RED}エラー: Node.js がインストールされていません${NC}" >&2
|
||||
echo "Node.js をインストールしてから、もう一度試してください。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$HTML_FILE" ]; then
|
||||
echo -e "${RED}エラー: $HTML_FILE が見つかりません${NC}" >&2
|
||||
echo "先に図解を生成してください。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$SLUG" ]; then
|
||||
DOMAIN="diagram-${SLUG}.surge.sh"
|
||||
else
|
||||
DOMAIN="diagram-$(date +%y%m%d%H%M).surge.sh"
|
||||
fi
|
||||
|
||||
# このスクリプトは <ルート>/.claude/skills/creating-visual-explainers/scripts/ に置かれる前提。
|
||||
# 4階層上 = このスキル一式が置かれているリポジトリのルート
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/../../../.." && pwd)"
|
||||
|
||||
FB_URL_FILE="$ROOT_DIR/fb-tool-url.txt"
|
||||
if [ ! -f "$FB_URL_FILE" ]; then
|
||||
echo -e "${RED}エラー: fb-tool-url.txt が見つかりません${NC}" >&2
|
||||
echo "チャット欄で「セットアップして」と伝えてください。" >&2
|
||||
exit 1
|
||||
fi
|
||||
FB_URL=$(cat "$FB_URL_FILE")
|
||||
|
||||
FB_TOKEN_FILE="$ROOT_DIR/fb-api-token.txt"
|
||||
if [ ! -f "$FB_TOKEN_FILE" ]; then
|
||||
echo -e "${RED}エラー: fb-api-token.txt が見つかりません${NC}" >&2
|
||||
echo "セットアップが完了していない可能性があります。チャット欄で「セットアップして」と伝えてください。" >&2
|
||||
exit 1
|
||||
fi
|
||||
API_TOKEN=$(cat "$FB_TOKEN_FILE")
|
||||
|
||||
if [[ ! "$FB_URL" =~ ^https:// ]]; then
|
||||
echo -e "${RED}エラー: fb-tool-url.txt の URL が https:// で始まっていません${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$FB_URL" =~ [\\|\&$'\n'] ]]; then
|
||||
echo -e "${RED}エラー: fb-tool-url.txt に不正な文字が含まれています${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$API_TOKEN" =~ [\\|\&$'\n'\ ] ]]; then
|
||||
echo -e "${RED}エラー: fb-api-token.txt に不正な文字が含まれています${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -q '</body>' "$HTML_FILE"; then
|
||||
echo -e "${RED}エラー: $HTML_FILE に </body> タグが見つかりません${NC}" >&2
|
||||
echo "HTML ファイルの構造が壊れている可能性があります。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TEMP_DIR"' EXIT
|
||||
|
||||
SNIPPET="<script src=\"${FB_URL}/widget.js\" data-token=\"${API_TOKEN}\"></script></body>"
|
||||
|
||||
# 最後の </body> だけを置換する。図解の題材が HTML やコードだと本文中にも
|
||||
# </body> が現れうるため、一括置換すると script タグが複数挿し込まれて壊れる。
|
||||
awk -v snippet="$SNIPPET" '
|
||||
{ line[NR] = $0; if (index($0, "</body>")) last = NR }
|
||||
END {
|
||||
for (i = 1; i <= NR; i++) {
|
||||
if (i == last) {
|
||||
# 該当行の最後の </body> を置換する(sub は先頭一致なので位置を自力で求める)
|
||||
s = line[i]; pos = 0; from = 1
|
||||
while ((p = index(substr(s, from), "</body>")) > 0) {
|
||||
pos = from + p - 1
|
||||
from = pos + 7
|
||||
}
|
||||
printf "%s%s%s\n", substr(s, 1, pos - 1), snippet, substr(s, pos + 7)
|
||||
} else {
|
||||
print line[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
' "$HTML_FILE" > "$TEMP_DIR/index.html"
|
||||
|
||||
printf "User-agent: *\nDisallow: /\n" > "$TEMP_DIR/robots.txt"
|
||||
|
||||
echo -e "${YELLOW}公開中...${NC}"
|
||||
npx --yes surge "$TEMP_DIR" --domain "$DOMAIN"
|
||||
|
||||
touch "$ROOT_DIR/deploy-history.log"
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') | https://${DOMAIN}" >> "$ROOT_DIR/deploy-history.log"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}完了!${NC}"
|
||||
echo "URL: https://${DOMAIN}"
|
||||
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
echo "https://${DOMAIN}" | pbcopy
|
||||
echo -e "${GREEN}URLをクリップボードにコピーしました${NC}"
|
||||
open "https://${DOMAIN}"
|
||||
elif command -v clip.exe &>/dev/null; then
|
||||
echo -n "https://${DOMAIN}" | clip.exe
|
||||
echo -e "${GREEN}URLをクリップボードにコピーしました${NC}"
|
||||
start "https://${DOMAIN}" 2>/dev/null || true
|
||||
elif command -v xdg-open &>/dev/null; then
|
||||
xdg-open "https://${DOMAIN}"
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}削除するとき: npx surge teardown ${DOMAIN}${NC}"
|
||||
294
.claude/skills/setting-up-comment-tool/SKILL.md
Normal file
294
.claude/skills/setting-up-comment-tool/SKILL.md
Normal file
@ -0,0 +1,294 @@
|
||||
---
|
||||
name: setting-up-comment-tool
|
||||
description: 音声図解コメントツールの初期セットアップ(Vercel・Neon・Gemini APIキー)を対話的にガイドするスキル。「セットアップして」「セットアップの続きをして」「コメントツールをセットアップして」「フィードバック機能を設定して」「音声図解コメントツールを使えるようにして」と依頼された際、および「コメントが保存されません」などセットアップ起因のトラブル調査を求められた際に使用する。
|
||||
---
|
||||
|
||||
# Setting Up Comment Tool
|
||||
|
||||
図解に対するコメント機能(テキスト引用・ピン・音声)を使えるようにする初期セットアップ。Vercel(ホスティング)・Neon Postgres(データベース)・Gemini APIキー(音声解析)の設定を対話的にガイドする。
|
||||
|
||||
**実行するのは1回だけ。** セットアップ完了後は、図解を作ってsurge.shにデプロイするだけで自動的にコメント機能が付く。
|
||||
|
||||
## 用語の定義
|
||||
|
||||
- **ツールフォルダ**: このツール本体(`package.json` の name が `voice-diagram-comment-tool` のフォルダ)。`npm` / `vercel` コマンドはすべてここで実行する
|
||||
- **リポジトリルート**: `.claude` フォルダがある階層。`fb-tool-url.txt` / `fb-api-token.txt` はここに保存する(デプロイスクリプトがここから読むため)
|
||||
|
||||
## Step 0: ツールフォルダの特定
|
||||
|
||||
1. ワークスペースのルートに `package.json` があり、name が `voice-diagram-comment-tool` なら、ルートがツールフォルダ(単体利用)
|
||||
2. なければ、ワークスペース内から name が `voice-diagram-comment-tool` の `package.json` を探す(別リポジトリに組み込んだ利用。フォルダ名は通常 `voice-diagram-comment`)
|
||||
3. 見つからない場合、以下を伝えて終了:
|
||||
|
||||
> ツール本体のフォルダが見つかりません。「voice-diagram-comment」(音声図解コメントツール)フォルダがこのリポジトリ内にあるか確認してください。
|
||||
|
||||
## 前提確認
|
||||
|
||||
### Node.js
|
||||
|
||||
```bash
|
||||
node --version
|
||||
```
|
||||
|
||||
バージョン番号が表示された → 次に進む。
|
||||
`command not found` → `.claude/skills/creating-visual-explainers/references/node-install-guide.md` の手順でインストールを案内する。
|
||||
|
||||
### Vercel CLI
|
||||
|
||||
```bash
|
||||
vercel --version
|
||||
```
|
||||
|
||||
バージョン番号が表示された → 次に進む。
|
||||
`command not found` → 以下を実行:
|
||||
|
||||
```bash
|
||||
npm install -g vercel
|
||||
```
|
||||
|
||||
## ワークフロー
|
||||
|
||||
以降のコマンドは、明記がない限り**ツールフォルダで実行する**。
|
||||
|
||||
### Step 1: 依存関係のインストール
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Step 2: Vercelにログイン
|
||||
|
||||
```bash
|
||||
vercel login
|
||||
```
|
||||
|
||||
ブラウザが開く。以下を伝える:
|
||||
|
||||
> ブラウザでVercelのログイン画面が開きます。
|
||||
> アカウントを持っていない場合は「Sign Up」から無料アカウントを作成してください。
|
||||
> メールアドレスまたはGitHubアカウントで登録できます。
|
||||
> ログインが完了したら、ターミナルに戻ってください。
|
||||
|
||||
### Step 3: Vercelに初回デプロイ
|
||||
|
||||
先にデプロイしてVercelプロジェクトを作成する(環境変数の設定にはプロジェクトが必要なため)。
|
||||
|
||||
自動命名に任せると名前の衝突などで不正になることがある。最初にプロジェクト名を明示してリンクする:
|
||||
|
||||
```bash
|
||||
vercel link --yes --project voice-diagram-comment-tool
|
||||
```
|
||||
|
||||
(同名プロジェクトが既にある等でエラーになったら、`voice-comment-tool-2` のように名前を変えて再実行する)
|
||||
|
||||
続いて本番デプロイ:
|
||||
|
||||
```bash
|
||||
vercel --yes --prod
|
||||
```
|
||||
|
||||
デプロイが完了すると以下の2つのURLが出力される。両方を控えておく:
|
||||
|
||||
- **Inspect URL**: `https://vercel.com/{slug}/{project}/...` 形式(Step 6 で使う)
|
||||
- **Production URL**: `https://xxx.vercel.app` 形式(最終的なツールURL)
|
||||
|
||||
この時点では環境変数が未設定なのでツールはまだ動かない。そのまま次に進む。
|
||||
|
||||
### Step 4: APIトークンの生成と設定
|
||||
|
||||
APIを保護するためのトークンを生成し、Vercelの環境変数に設定する。
|
||||
|
||||
```bash
|
||||
openssl rand -hex 16
|
||||
```
|
||||
|
||||
**Windows(PowerShell)の場合:**
|
||||
|
||||
```powershell
|
||||
[System.Guid]::NewGuid().ToString("N")
|
||||
```
|
||||
|
||||
表示された文字列がトークン。これを環境変数として設定する:
|
||||
|
||||
```bash
|
||||
echo "生成したトークン" | vercel env add API_TOKEN production
|
||||
```
|
||||
|
||||
「生成したトークン」は実際に生成した値に置き換える。`vercel env add` が対話式プロンプトを出さずに値を受け取るよう、パイプで渡す。
|
||||
|
||||
同じトークンを**リポジトリルート**の `fb-api-token.txt` に保存する(1行、トークンのみ)。
|
||||
|
||||
### Step 5: Gemini APIキーの設定
|
||||
|
||||
音声フィードバック(喋った内容をAIが聞き取って図解にピンを配置する機能)に使うAPIキーを設定する。
|
||||
|
||||
ユーザーに以下を案内する:
|
||||
|
||||
> 音声フィードバックには Google の Gemini というAIを使います。無料のAPIキーを発行しましょう。
|
||||
>
|
||||
> 1. ブラウザで https://aistudio.google.com/apikey を開く
|
||||
> 2. Googleアカウントでログイン
|
||||
> 3. 「APIキーを作成」(Create API key)をクリック
|
||||
> 4. 表示されたキー(`AIza...` で始まる文字列)をコピー
|
||||
> 5. コピーしたキーをこのチャット欄に貼り付けてください
|
||||
>
|
||||
> 無料枠の範囲で使えます。クレジットカードの登録は不要です。
|
||||
|
||||
キーを受け取ったら設定する:
|
||||
|
||||
```bash
|
||||
echo "受け取ったAPIキー" | vercel env add GEMINI_API_KEY production
|
||||
```
|
||||
|
||||
**注意**: 受け取ったAPIキーは環境変数の設定にだけ使う。ファイルに書き出したり、チャットの返答で復唱したりしない。
|
||||
|
||||
### Step 6: データベースの追加
|
||||
|
||||
Step 3 の Inspect URL からデプロイID部分(末尾のランダム文字列)を削り、`/stores` を付けて Storage ページの直接URLを組み立てる。
|
||||
|
||||
例: Inspect URL が `https://vercel.com/your-projects/voice-diagram-comment-tool/abc123xyz` なら
|
||||
→ `https://vercel.com/your-projects/voice-diagram-comment-tool/stores`
|
||||
|
||||
ユーザーにブラウザでの操作を案内する。以下の `{StorageページURL}` を組み立てたURLに置き換えて伝える:
|
||||
|
||||
> コメントを保存するデータベースを追加します。ブラウザで以下のURLを開いてください。
|
||||
>
|
||||
> {StorageページURL}
|
||||
>
|
||||
> ページが開いたら、以下の操作をしてください。
|
||||
>
|
||||
> 1. 「Create Database」をクリック
|
||||
> 2. 「Neon Postgres」を選択
|
||||
> 3. プランは「Free」を選択(無料、クレジットカード不要)
|
||||
> 4. 「Create」をクリック
|
||||
> 5. 次の画面で:
|
||||
> - 「Search Projects」からプロジェクトを選択
|
||||
> - 「Custom Prefix」の欄を `DATABASE` に変更
|
||||
> - 「Connect」をクリック
|
||||
>
|
||||
> 完了したら教えてください。
|
||||
|
||||
### Step 7: 環境変数の取得とマイグレーション
|
||||
|
||||
```bash
|
||||
vercel env pull .env.local
|
||||
```
|
||||
|
||||
テーブルを作成:
|
||||
|
||||
```bash
|
||||
npm run db:migrate
|
||||
```
|
||||
|
||||
`Migration complete.` と表示されれば成功。
|
||||
|
||||
### Step 8: 再デプロイ
|
||||
|
||||
環境変数を反映するため、もう一度デプロイする。
|
||||
|
||||
```bash
|
||||
vercel --prod
|
||||
```
|
||||
|
||||
### Step 9: URLを保存する
|
||||
|
||||
ツールの**固定URL**(`https://プロジェクト名.vercel.app` 形式。通常は `https://voice-diagram-comment-tool.vercel.app`)を、**リポジトリルート**の `fb-tool-url.txt` に書き出す。URLのみを1行で保存する。
|
||||
|
||||
**注意**: `vercel --prod` の出力に表示されるランダム文字列入りのURL(例: `https://voice-diagram-comment-tool-a1b2c3-xxx.vercel.app`)は保存しない。デプロイ固有URLはVercelの保護機能により外部から読めないことがある。
|
||||
|
||||
保存したら、公開ページから widget.js に到達できることを確認する:
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}" "$(cat fb-tool-url.txt)/widget.js"
|
||||
```
|
||||
|
||||
**Windows(PowerShell)の場合:**
|
||||
|
||||
```powershell
|
||||
(Invoke-WebRequest "$((Get-Content fb-tool-url.txt).Trim())/widget.js").StatusCode
|
||||
```
|
||||
|
||||
`200` ならOK。`404` や `401` が返る場合は、Vercelダッシュボードのプロジェクト → Settings → Domains に表示されているドメインを確認し、そのURLで `fb-tool-url.txt` を保存し直して再確認する。
|
||||
|
||||
### Step 10: 音声解析APIの通し検証
|
||||
|
||||
widget.js は静的ファイルなので、これだけではAPI(サーバーレス関数)が動いている保証にならない。無音の音声データを実際に `/api/voice-analyze` へ送り、Gemini まで通ることを確認する。
|
||||
|
||||
まず、16kHz・モノラル・1秒の無音WAVを生成してbase64化し、リクエストボディを組み立てる(**リポジトリルート**で実行):
|
||||
|
||||
```bash
|
||||
node -e '
|
||||
const sr = 16000, dataLen = sr * 2; // 16bit mono 1秒
|
||||
const b = Buffer.alloc(44 + dataLen);
|
||||
b.write("RIFF", 0); b.writeUInt32LE(36 + dataLen, 4); b.write("WAVE", 8);
|
||||
b.write("fmt ", 12); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
|
||||
b.writeUInt32LE(sr, 24); b.writeUInt32LE(sr * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34);
|
||||
b.write("data", 36); b.writeUInt32LE(dataLen, 40);
|
||||
const body = {
|
||||
audioBase64: b.toString("base64"),
|
||||
mimeType: "audio/wav",
|
||||
targets: [{ index: 0, text: "テスト", heading: "テスト", isHeading: true }],
|
||||
};
|
||||
require("fs").writeFileSync("/tmp/fb-voice-check.json", JSON.stringify(body));
|
||||
'
|
||||
```
|
||||
|
||||
**Windows(PowerShell)の場合**: 上と同じ `node -e` スクリプトを実行し、出力先だけ `$env:TEMP + "\\fb-voice-check.json"` に読み替える(`node -e` はWindowsでも動く)。
|
||||
|
||||
続いて、APIトークンを付けてPOSTする:
|
||||
|
||||
```bash
|
||||
curl -s -w "\n%{http_code}" -X POST "$(cat fb-tool-url.txt)/api/voice-analyze" \
|
||||
-H "Authorization: Bearer $(cat fb-api-token.txt)" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data @/tmp/fb-voice-check.json
|
||||
```
|
||||
|
||||
結果の判定:
|
||||
|
||||
- **HTTP 200 で `{"results":[]}`**(無音なので空配列)→ 音声解析まで通しで動いている。セットアップ成功
|
||||
- **HTTP 404** → APIルートがデプロイされていない。Vercelのビルド設定の問題(エラー対応の「音声の解析に失敗しました」を参照)
|
||||
- **`Gemini エラー` を含む応答** → Geminiのモデル名またはAPIキーの問題(エラー対応の「Gemini エラー (404)」を参照)
|
||||
- **HTTP 403** → `fb-api-token.txt` の値と Vercel の `API_TOKEN` が食い違っている。Step 4 をやり直す
|
||||
|
||||
### Step 11: 完了報告
|
||||
|
||||
リポジトリルートに `fb-tool-url.txt` と `fb-api-token.txt` があることを確認し、以下を伝える:
|
||||
|
||||
```
|
||||
セットアップ完了
|
||||
|
||||
あなたのコメントツール URL:
|
||||
https://xxx.vercel.app
|
||||
|
||||
以降「図解を作って」と伝えるだけで、コメント機能付きの図解が公開されます。
|
||||
|
||||
公開した図解では、3通りの方法でフィードバックできます:
|
||||
- テキストをマウスで選択してコメント
|
||||
- 好きな場所にピンを刺してコメント
|
||||
- マイクに向かって喋るだけ(AIが該当箇所にピンを自動配置)
|
||||
|
||||
APIはトークンで保護されています。図解のデプロイ時に自動で埋め込まれるため、
|
||||
追加の操作は不要です。
|
||||
```
|
||||
|
||||
`fb-tool-url.txt` / `fb-api-token.txt` / `.env.local` はGitにコミットしない(このフォルダの `.gitignore` で除外済み。別リポジトリに組み込んでいる場合は、そのリポジトリの `.gitignore` にも追加するよう案内する)。
|
||||
|
||||
## エラー対応
|
||||
|
||||
エラーメッセージをそのまま見せず、何が起きていて何をすれば解決するかを平易に説明する。
|
||||
|
||||
- **`vercel: command not found`** → `npm install -g vercel` を実行
|
||||
- **`DATABASE_URL is not set`** → Step 6のデータベース追加が完了しているか確認。完了していれば `vercel env pull .env.local` を再実行
|
||||
- **マイグレーション失敗** → ツールフォルダの `.env.local` に `DATABASE_URL` が含まれているか確認
|
||||
- **音声フィードバックで「GEMINI_API_KEY が未設定です」** → Step 5が完了しているか `vercel env ls` で確認し、設定後に `vercel --prod` で再デプロイ
|
||||
- **「音声の解析に失敗しました」** → まず `/api/voice-analyze` が404を返していないか確認する(`curl -s -o /dev/null -w "%{http_code}" -X POST "$(cat fb-tool-url.txt)/api/voice-analyze"` — 404以外=APIは存在する)。404ならAPIルートがデプロイされていない。原因はVercelのビルド設定: `vercel.json` に `buildCommand` を直接書くとVercelがNext.jsを認識できず、APIが生成されない。ツールフォルダの `vercel.json` が `{"framework": "nextjs"}` のみで、`package.json` に `vercel-build` スクリプトがあることを確認し、`vercel --prod` で再デプロイする
|
||||
- **「Gemini エラー (404)」** → 指定中のGeminiモデルが新規APIキーでは利用不可になっている(例: `gemini-2.5-flash` は新規ユーザーに提供終了)。`echo "gemini-flash-latest" | vercel env add GEMINI_MODEL production` で環境変数を設定し、`vercel --prod` で再デプロイする(ツールが最新版なら既定値が `gemini-flash-latest` のためこのエラーは起きない)
|
||||
- **「コメントが保存されません」と相談された** → 順に切り分ける: ①Step 9の到達確認(widget.js が200か)②`vercel env ls` で `DATABASE_URL` と `API_TOKEN` が production にあるか ③マイグレーション済みか(`vercel env pull .env.local` → `npm run db:migrate`)④環境変数を後から足した場合は `vercel --prod` で再デプロイしたか
|
||||
|
||||
## 依存
|
||||
|
||||
- ツールフォルダの `package.json` — 依存関係と `db:migrate` スクリプト
|
||||
- ツールフォルダの `scripts/migrate.ts` — DBマイグレーションスクリプト
|
||||
- `.claude/skills/creating-visual-explainers/references/node-install-guide.md` — Node.jsインストール手順
|
||||
- `.claude/skills/creating-visual-explainers/scripts/deploy-diagram.sh` — `fb-tool-url.txt` / `fb-api-token.txt` の読み取り側
|
||||
19
.env.example
Normal file
19
.env.example
Normal file
@ -0,0 +1,19 @@
|
||||
# ローカルで API(npm run dev)を動かすときに使う環境変数のテンプレート。
|
||||
# このファイルを .env.local にコピーして、各値を自分のものに置き換える。
|
||||
# 通常は `vercel env pull .env.local` で Vercel から取得すればよく、手で書く必要はない。
|
||||
#
|
||||
# 本番(Vercel)には同じ変数を Vercel プロジェクトの環境変数として設定する。
|
||||
# 設定手順はセットアップスキル(「セットアップして」)が案内する。
|
||||
|
||||
# Neon Postgres への接続文字列。Vercel の Storage 連携で自動的に設定される
|
||||
DATABASE_URL=
|
||||
|
||||
# API を保護する合言葉。セットアップ時に自動生成される(fb-api-token.txt と同じ値)
|
||||
# ローカル開発では未設定でも動く
|
||||
API_TOKEN=
|
||||
|
||||
# 音声解析に使う Google AI Studio の API キー(https://aistudio.google.com/apikey で無料発行)
|
||||
GEMINI_API_KEY=
|
||||
|
||||
# 任意。未指定なら gemini-flash-latest(常に最新のflash系を指す公式エイリアス)が使われる
|
||||
# GEMINI_MODEL=
|
||||
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal file
@ -0,0 +1,42 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files(シークレットは絶対にコミットしない。テンプレートの .env.example だけは配布に含める)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# 図解の生成物・セットアップで生成されるファイル
|
||||
output/*.html
|
||||
deploy-history.log
|
||||
fb-tool-url.txt
|
||||
fb-api-token.txt
|
||||
.env*.local
|
||||
259
README.md
Normal file
259
README.md
Normal file
@ -0,0 +1,259 @@
|
||||
# 音声図解コメントツール
|
||||
|
||||
わからない言葉やしくみを AI に伝えるだけで、噛み砕いた図解ページを自動で作って公開できるツールです。公開した図解には、見た人がコメントを付けられます。コメントの付け方は3通りあります。
|
||||
|
||||
- **テキストを選んでコメント** — 気になる箇所をマウスで選択すると、引用付きでコメントできます
|
||||
- **ピンを刺してコメント** — 図の好きな場所をクリックしてピンを立て、コメントできます
|
||||
- **マイクに向かって喋るだけ** — 「上の説明はもっとシンプルに。真ん中の図は矢印が逆」と喋ると、AI(Gemini)が聞き取って、図解の該当箇所にピンを自動で配置します
|
||||
|
||||
さらに、こんな特徴があります。
|
||||
|
||||
- **優先度が色でわかる** — Must(赤)・Better(黄)・Want(緑)の3段階で重要度を伝えられます
|
||||
- **セットアップは1回だけ** — 一度設定すれば、以降は図解を公開するたびにコメント機能が自動で付きます
|
||||
- **無料で使える** — Vercel・Neon・Surge・Gemini API のすべて無料プランで動きます。クレジットカードの登録は不要です
|
||||
|
||||
## しくみ
|
||||
|
||||
このツールは2つの部品でできています。
|
||||
|
||||
```
|
||||
「○○を図解して」と AI に依頼
|
||||
│
|
||||
▼
|
||||
① 図解 HTML(output/ に生成 → Surge に公開される静的ページ)
|
||||
│ デプロイ時に </body> の直前へ1行だけ自動注入される
|
||||
│ <script src="{あなたのツールURL}/widget.js" data-token="xxx"></script>
|
||||
▼
|
||||
② バックエンド(あなたの Vercel で常時動く Next.js アプリ)= このフォルダ
|
||||
├─ widget.js … コメント UI 本体(画面右端に出るパネル)
|
||||
├─ /api/comments … コメントの保存・取得・更新・削除
|
||||
└─ /api/voice-analyze … 音声を Gemini に渡してピン配置情報に変換
|
||||
│
|
||||
▼
|
||||
Neon Postgres(comments テーブル)
|
||||
```
|
||||
|
||||
②は1つ動いていればOKです。図解を作るたびに何かをセットアップする必要はありません。
|
||||
|
||||
## セットアップ
|
||||
|
||||
初回だけ必要な設定です。AI が順番に案内してくれます。
|
||||
|
||||
```
|
||||
① Node.js / Vercel CLI を確認
|
||||
↓
|
||||
② Vercel にログイン・デプロイ
|
||||
↓
|
||||
③ API トークンを自動生成
|
||||
↓
|
||||
④ Gemini API キーを発行(音声機能用・ブラウザで操作)
|
||||
↓
|
||||
⑤ データベースを追加(ブラウザで操作)
|
||||
↓
|
||||
⑥ マイグレーション実行・再デプロイ
|
||||
↓
|
||||
⑦ 完了(fb-tool-url.txt と fb-api-token.txt が生成される)
|
||||
```
|
||||
|
||||
|
||||
|
||||
### やり方
|
||||
|
||||
このフォルダを Cursor で開き、チャット欄で次のように伝えてください。
|
||||
|
||||
```
|
||||
セットアップして
|
||||
```
|
||||
|
||||
AI がステップごとに案内してくれます。
|
||||
|
||||
### 必要なもの
|
||||
|
||||
|
||||
| 必要なもの | 説明 | 費用 |
|
||||
| ------------- | ---------------------------------------- | ------------- |
|
||||
| Node.js | パソコン上でプログラムを動かすための土台。AI が自動でインストールを案内します | 無料 |
|
||||
| Vercel アカウント | コメント機能を動かすサーバー。メールアドレスで登録できます | 無料(Hobby プラン) |
|
||||
| Neon Postgres | コメントデータを保存するデータベース。Vercel の画面から追加します | 無料 |
|
||||
| Google アカウント | 音声を聞き取る AI(Gemini)の API キー発行に使います | 無料 |
|
||||
| Surge アカウント | 図解を公開するサービス。初回にメールアドレスとパスワードで登録します | 無料 |
|
||||
|
||||
アカウントを持っていなくても大丈夫です。セットアップの途中で AI が「今このアカウントを作ってください」と、必要になったタイミングで案内します。
|
||||
|
||||
### Gemini API キーの発行手順(音声機能に必要)
|
||||
|
||||
API キーとは、AI サービスを利用するための「あなた専用の合鍵」です。発行したことがなくても、以下の4ステップ・3分程度で作れます。セットアップ中に AI がこの手順を案内するので、事前にやっておく必要はありません。
|
||||
|
||||
1. ブラウザで [Google AI Studio](https://aistudio.google.com/apikey) を開く
|
||||
2. Google アカウントでログインする
|
||||
3. 「API キーを作成」(Create API key)ボタンを押す
|
||||
4. 表示された `AIza...` で始まる文字列がキーです。セットアップ中に AI から求められたら、チャット欄に貼り付けてください
|
||||
|
||||
無料枠の範囲で使えます。クレジットカードの登録は不要です。
|
||||
|
||||
### 大事な注意
|
||||
|
||||
- **API キーやトークンは自分専用です。** 他の人と共有したり、チャットや SNS に貼ったりしないでください
|
||||
- セットアップで生成される `fb-tool-url.txt`・`fb-api-token.txt`・`.env.local` は Git にコミットされない設定になっています(`.gitignore` で除外済み)
|
||||
- 図解は URL を知っていれば誰でも見られます。**機密情報を図解に載せない**前提で使ってください
|
||||
|
||||
|
||||
|
||||
## 使い方
|
||||
|
||||
|
||||
|
||||
### 図解を作る
|
||||
|
||||
Cursor のチャット欄に、知りたいことを書いて送信します。
|
||||
|
||||
```
|
||||
APIについて図解して
|
||||
```
|
||||
|
||||
AI が図解を作り、コメント機能付きで自動デプロイします。
|
||||
|
||||
### コメントを付ける(見る側)
|
||||
|
||||
公開された図解を開くと、画面右端にボタンが出ます。ボタンには3つの機能が並んでいます。
|
||||
|
||||
|
||||
| アイコン | 機能 |
|
||||
| ---- | -------------------- |
|
||||
| パネル | サイドバーの開閉。コメント一覧を見る |
|
||||
| ピン | フィードバックモードの ON / OFF |
|
||||
| マイク | 音声フィードバックの録音開始 / 停止 |
|
||||
|
||||
|
||||
**音声で伝える**: マイクアイコンを押して喋り、もう一度押して停止します。数秒後、AI が「どの見出しへの指摘か」を判定して、該当箇所にピンを自動で配置します。初回はブラウザがマイクの使用許可を求めるので「許可」を選んでください。
|
||||
|
||||
**ピンで伝える**: ピンアイコンで フィードバックモードを ON にすると、クリックでピン、ドラッグでテキスト引用コメントになります。`Esc` でモードを抜けます。
|
||||
|
||||
初回だけ名前の入力を求められます(次回以降は自動で記憶されます)。
|
||||
|
||||
### 優先度の意味
|
||||
|
||||
|
||||
| 優先度 | 色 | 使いどころ |
|
||||
| ------ | --- | -------------------------- |
|
||||
| Must | 赤 | 必ず直すべき箇所(事実の間違い、重大なわかりにくさ) |
|
||||
| Better | 黄 | 直すとよくなる箇所(表現の改善、構成の入れ替え) |
|
||||
| Want | 緑 | 余裕があれば対応(細かい表現、好みの範囲) |
|
||||
|
||||
|
||||
|
||||
|
||||
### コメントを確認・管理する
|
||||
|
||||
画面右端のパネルボタンでサイドバーが開きます。
|
||||
|
||||
- **未解決 / 解決済 / すべて** のフィルタで絞り込めます
|
||||
- コメントカードと本文の該当箇所は相互にジャンプできます
|
||||
- 返信・解決・編集・削除・優先度変更ができます
|
||||
- サイドバーのボタンから **JSON / Markdown** でエクスポートできます
|
||||
|
||||
|
||||
|
||||
### 図解の削除
|
||||
|
||||
チャット欄で「この図解を削除して」と伝えてください。AI がデプロイ履歴(`deploy-history.log`。公開のたびに自動で記録されます)から URL を特定し、削除を実行します。
|
||||
|
||||
## 普段使っているリポジトリに組み込む
|
||||
|
||||
このフォルダを単体で使うだけでなく、**普段開発しているリポジトリの中にこのツールを入れて使う**ことができます。ファイルを手で動かす必要はありません。組み込み作業は AI に任せます。
|
||||
|
||||
### 手順
|
||||
|
||||
1. このツールのフォルダ(`voice-diagram-comment`)を、パソコンの好きな場所に置きます(クローンまたはダウンロード。すでにあるならそのままでOK)
|
||||
2. 組み込みたいリポジトリを Cursor で開きます
|
||||
3. 下のプロンプトをチャット欄に貼り付け、**1行目のパスだけ自分の環境に書き換えて**送信します
|
||||
|
||||
```
|
||||
「音声図解コメントツール」をこのリポジトリに組み込んでください。
|
||||
|
||||
ツールの場所: (ここにフォルダのパスを書く。
|
||||
Mac の例: /Users/あなたの名前/Downloads/voice-diagram-comment
|
||||
Windows の例: C:\Users\あなたの名前\Downloads\voice-diagram-comment)
|
||||
|
||||
やってほしいこと:
|
||||
1. 上記フォルダを、このリポジトリ内の tools/voice-diagram-comment/ にコピーする
|
||||
(node_modules・.next・.vercel・.env.local はコピーしない。.env.example はコピーしてよい)
|
||||
2. コピーしたフォルダ内の .claude/skills/ にある2つのスキルフォルダを、
|
||||
このリポジトリ直下の .claude/skills/ に移動する
|
||||
(スキルはリポジトリのルートにないと Cursor に認識されないため)
|
||||
3. このリポジトリの .gitignore に、トークンファイルと生成物の除外設定を追加する
|
||||
(除外すべきものは、コピーしたフォルダ内の .gitignore に書いてある。
|
||||
パスは実際の配置に合わせて調整する)
|
||||
4. コピー元のフォルダ直下に fb-tool-url.txt と fb-api-token.txt があれば、
|
||||
このリポジトリのルートにコピーする(セットアップ済み環境の引き継ぎ)
|
||||
5. 終わったら、組み込んだ内容と「次に何をすればいいか」を教えてください
|
||||
(fb-tool-url.txt が無い場合は、次に「セットアップして」と伝えれば
|
||||
初期セットアップが始まる、と案内してください)
|
||||
```
|
||||
|
||||
4. AI の作業が終わったら、案内に従って「セットアップして」と伝えます(すでに単体でセットアップ済みの場合は不要です。Vercel の再デプロイも不要です)
|
||||
|
||||
### 組み込むとこうなる
|
||||
|
||||
```
|
||||
あなたのリポジトリ/
|
||||
├── src/ ... ← 既存のファイルはそのまま
|
||||
├── .claude/skills/ ← スキルはルートに置かれる
|
||||
│ ├── setting-up-comment-tool/
|
||||
│ └── creating-visual-explainers/
|
||||
├── tools/
|
||||
│ └── voice-diagram-comment/ ← ツール本体
|
||||
├── output/ ← 図解の生成先(自動作成)
|
||||
├── fb-tool-url.txt ← セットアップで生成
|
||||
└── fb-api-token.txt ← セットアップで生成
|
||||
```
|
||||
|
||||
以降は、そのリポジトリのチャット欄で「図解を作って」と伝えるだけで、コメント機能付きの図解が公開できます。
|
||||
|
||||
## フォルダの中身
|
||||
|
||||
```
|
||||
voice-diagram-comment/
|
||||
├── .claude/skills/
|
||||
│ ├── creating-visual-explainers/ ← 図解生成スキル
|
||||
│ │ ├── SKILL.md
|
||||
│ │ ├── references/ ← 図解テンプレート・模範回答・Nodeインストールガイド
|
||||
│ │ └── scripts/
|
||||
│ │ └── deploy-diagram.sh ← デプロイ+widget 自動注入
|
||||
│ └── setting-up-comment-tool/ ← セットアップスキル
|
||||
│ └── SKILL.md
|
||||
├── output/ ← 図解の保存先
|
||||
├── public/
|
||||
│ ├── widget.js ← コメント UI 本体(ビルド成果物)
|
||||
│ └── feedback-demo.html ← ローカル動作確認用デモ
|
||||
├── src/
|
||||
│ ├── app/api/comments/ ← コメント CRUD API
|
||||
│ ├── app/api/voice-analyze/ ← 音声解析 API(Gemini)
|
||||
│ └── lib/ shared/ widget/ ← DB接続・共通処理・コメント UI のソースコード
|
||||
├── tests/ ← 自動テスト
|
||||
├── scripts/
|
||||
│ └── migrate.ts ← DB テーブル作成(セットアップ時に1回だけ実行)
|
||||
├── .env.example ← 環境変数のテンプレート(実際の値は書かない)
|
||||
├── package.json
|
||||
└── README.md ← この説明書
|
||||
```
|
||||
|
||||
セットアップ完了後に `fb-tool-url.txt`(ツールの URL)と `fb-api-token.txt`(API トークン)がリポジトリのルートに自動生成されます。あなたが手で編集する必要のあるファイルはありません。
|
||||
|
||||
## 困ったとき
|
||||
|
||||
|
||||
| 症状 | 原因と対処 |
|
||||
| ----------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| 「セットアップして」と言っても反応しない | スキルが認識されていません。このフォルダ自体(または組み込み先リポジトリのルート)を Cursor で開いているか、`.claude/skills/` がルート直下にあるか確認してください |
|
||||
| マイクを押すと「マイクへのアクセスが許可されませんでした」 | ブラウザのマイク権限を確認。`https://` のページでないと許可されません(Surge の URL は https なので通常OK) |
|
||||
| 音声で「ピンを置ける要素が見つかりませんでした」 | 図解 HTML に見出しがありません。このツールのスキルで作った図解なら自動で入ります |
|
||||
| 音声で「GEMINI_API_KEY が未設定です」 | Gemini API キーの設定が漏れています。チャット欄で「セットアップの続きをして」と伝えてください |
|
||||
| コメントが保存されない | データベースの設定が未完了の可能性。チャット欄で「コメントが保存されません」と伝えると AI が原因を調べます |
|
||||
| 公開済みの図解にコメント機能が付かない | コメント機能はデプロイ時に注入されます。「この図解を再デプロイして」と伝えてください |
|
||||
| その他 | 何が起きても、まずはチャット欄で AI に状況を伝えてください。エラーの内容を読み取って、次にやるべきことを案内してくれます |
|
||||
|
||||
|
||||
---
|
||||
|
||||
Mac / Windows どちらでも使えます。AI-Driven School 運営が動作確認済みです。
|
||||
20
eslint.config.mjs
Normal file
20
eslint.config.mjs
Normal file
@ -0,0 +1,20 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
// esbuild が生成するバンドル。lint 対象にすると実コードの警告が埋もれる
|
||||
"public/widget.js",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
8225
package-lock.json
generated
Normal file
8225
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "voice-diagram-comment-tool",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"vercel-build": "npm run build:widget && next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"build:widget": "esbuild src/widget/index.ts --bundle --format=iife --outfile=public/widget.js --minify",
|
||||
"dev:widget": "esbuild src/widget/index.ts --bundle --format=iife --outfile=public/widget.js --watch",
|
||||
"db:migrate": "npx tsx scripts/migrate.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@neondatabase/serverless": "^1.0.2",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsdom": "^28.0.1",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"dotenv": "^17.3.1",
|
||||
"esbuild": "^0.27.4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"jsdom": "^27.0.1",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
111
public/feedback-demo.html
Normal file
111
public/feedback-demo.html
Normal file
@ -0,0 +1,111 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>HTTPリクエストの流れ(フィードバック確認用)</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;500;700;900&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
ads: {
|
||||
bg: '#FFFFFF',
|
||||
surface: '#F8FAFC',
|
||||
hover: '#F1F5F9',
|
||||
border: '#E2E8F0',
|
||||
accent: '#3B82F6',
|
||||
'accent-light': '#2563EB',
|
||||
text: '#1E293B',
|
||||
muted: '#64748B',
|
||||
dim: '#94A3B8',
|
||||
positive: '#10B981',
|
||||
negative: '#EF4444',
|
||||
warning: '#F59E0B',
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['"Noto Sans JP"', '"Hiragino Sans"', '"Yu Gothic UI"', '"Meiryo"', 'sans-serif'],
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-ads-bg text-slate-600 antialiased leading-relaxed border-t-4 border-ads-accent">
|
||||
<main class="max-w-3xl mx-auto px-5 py-10 md:py-16">
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center gap-2 bg-ads-accent/10 text-ads-accent-light px-4 py-1.5 rounded-full text-sm font-medium mb-6">
|
||||
フィードバック確認用サンプル
|
||||
</div>
|
||||
<h1 class="text-3xl md:text-4xl font-black text-slate-900 tracking-tight mb-4">
|
||||
<span class="text-ads-accent-light">HTTP</span>リクエストの流れ
|
||||
</h1>
|
||||
<p class="text-lg text-ads-muted max-w-xl mx-auto">
|
||||
テキストを選択すると、右下のフィードバックボタンからコメントできます。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-ads-surface border border-ads-border rounded-2xl p-6 md:p-8 mb-6">
|
||||
<h2 class="text-xl font-bold text-slate-900 mb-6 text-center">4ステップで理解する</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex gap-4 items-start p-4 bg-white border border-ads-border rounded-xl">
|
||||
<div class="w-10 h-10 rounded-full bg-blue-100 text-blue-700 font-bold flex items-center justify-center shrink-0">1</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-900 mb-1">ブラウザがリクエストを送る</h3>
|
||||
<p class="text-sm">ユーザーがURLを開くと、ブラウザはサーバーへ「このページをください」とHTTPリクエストを送ります。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 items-start p-4 bg-white border border-ads-border rounded-xl">
|
||||
<div class="w-10 h-10 rounded-full bg-blue-100 text-blue-700 font-bold flex items-center justify-center shrink-0">2</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-900 mb-1">サーバーが処理する</h3>
|
||||
<p class="text-sm">Webサーバーはリクエストを受け取り、必要なファイルやデータを探して応答内容を組み立てます。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 items-start p-4 bg-white border border-ads-border rounded-xl">
|
||||
<div class="w-10 h-10 rounded-full bg-blue-100 text-blue-700 font-bold flex items-center justify-center shrink-0">3</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-900 mb-1">レスポンスが返る</h3>
|
||||
<p class="text-sm">サーバーはHTTPレスポンスとしてHTMLやJSON、画像などをブラウザへ返します。ステータスコード200は成功を意味します。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 items-start p-4 bg-white border border-ads-border rounded-xl">
|
||||
<div class="w-10 h-10 rounded-full bg-blue-100 text-blue-700 font-bold flex items-center justify-center shrink-0">4</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-900 mb-1">ブラウザが表示する</h3>
|
||||
<p class="text-sm">ブラウザは受け取った内容を解釈し、画面上にページとして描画します。CSSやJavaScriptもここで適用されます。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-xl p-5 mb-6">
|
||||
<p class="text-sm text-amber-900 font-medium mb-2">このページの使い方</p>
|
||||
<ul class="text-sm text-amber-900/90 space-y-1 list-disc list-inside">
|
||||
<li>画面右端中央のボタン → フィードバックサイドバーを開く</li>
|
||||
<li>本文テキストをドラッグ選択 → 引用付きコメントを追加</li>
|
||||
<li>サイドバーのピン・マイクボタン → ピン留めコメント/音声フィードバックを試せる</li>
|
||||
<li>ローカルの <code class="bg-white/70 px-1 rounded">/widget.js</code> を読み込んでいます(<code class="bg-white/70 px-1 rounded">npm run dev</code> で起動して確認)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="max-w-3xl mx-auto px-5 pb-10 pt-6 border-t border-ads-border/30">
|
||||
<p class="text-xs text-ads-dim text-center">音声図解コメントツール / ローカル動作確認用デモ</p>
|
||||
</footer>
|
||||
|
||||
<!-- コメントウィジェット(ローカル開発サーバーの widget.js を読み込む) -->
|
||||
<script src="/widget.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
2
public/widget.js
Normal file
2
public/widget.js
Normal file
File diff suppressed because one or more lines are too long
53
scripts/migrate.ts
Normal file
53
scripts/migrate.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { config } from 'dotenv';
|
||||
config({ path: '.env.local' });
|
||||
import { neon } from '@neondatabase/serverless';
|
||||
|
||||
async function migrate() {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error('DATABASE_URL is not set. Add it to .env.local');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sql = neon(url);
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id TEXT PRIMARY KEY,
|
||||
author TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'comment',
|
||||
quote TEXT NOT NULL DEFAULT '',
|
||||
quote_context_before TEXT NOT NULL DEFAULT '',
|
||||
quote_context_after TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
priority TEXT NOT NULL DEFAULT 'want',
|
||||
parent_id TEXT,
|
||||
resolved BOOLEAN NOT NULL DEFAULT false,
|
||||
resolved_by TEXT,
|
||||
resolved_at BIGINT,
|
||||
timestamp BIGINT NOT NULL,
|
||||
updated_at BIGINT,
|
||||
page_url TEXT NOT NULL,
|
||||
project_slug TEXT NOT NULL
|
||||
)
|
||||
`;
|
||||
|
||||
// ピンコメント用の座標列(既存テーブルにも安全に追加できるよう冪等)
|
||||
await sql`ALTER TABLE comments ADD COLUMN IF NOT EXISTS pin_x DOUBLE PRECISION`;
|
||||
await sql`ALTER TABLE comments ADD COLUMN IF NOT EXISTS pin_y DOUBLE PRECISION`;
|
||||
|
||||
// 要素アンカー方式(レスポンシブ追従): 最寄り要素のセレクタ+その要素からの相対オフセット
|
||||
await sql`ALTER TABLE comments ADD COLUMN IF NOT EXISTS anchor_selector TEXT`;
|
||||
await sql`ALTER TABLE comments ADD COLUMN IF NOT EXISTS anchor_dx DOUBLE PRECISION`;
|
||||
await sql`ALTER TABLE comments ADD COLUMN IF NOT EXISTS anchor_dy DOUBLE PRECISION`;
|
||||
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_comments_project ON comments (project_slug)`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments (parent_id)`;
|
||||
|
||||
console.log('Migration complete.');
|
||||
}
|
||||
|
||||
migrate().catch((e) => {
|
||||
console.error('Migration failed:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
157
src/app/api/comments/route.ts
Normal file
157
src/app/api/comments/route.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDb } from '@/lib/db';
|
||||
import type { Comment } from '@/shared/types';
|
||||
|
||||
function corsHeaders() {
|
||||
return {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
};
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new NextResponse(null, { status: 204, headers: corsHeaders() });
|
||||
}
|
||||
|
||||
function json(data: unknown, status = 200) {
|
||||
return NextResponse.json(data, { status, headers: corsHeaders() });
|
||||
}
|
||||
|
||||
function verifyToken(request: NextRequest): boolean {
|
||||
const token = process.env.API_TOKEN;
|
||||
// ローカル開発ではトークン無しで動かせるようにする。本番で未設定なら、
|
||||
// 設定ミスで API が誰でも書き込める状態になるより落ちた方が安全。
|
||||
if (!token) return process.env.NODE_ENV !== 'production';
|
||||
|
||||
const auth = request.headers.get('Authorization');
|
||||
if (!auth?.startsWith('Bearer ')) return false;
|
||||
|
||||
return auth.slice(7) === token;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!verifyToken(request)) return json({ error: 'Unauthorized' }, 403);
|
||||
const slug = request.nextUrl.searchParams.get('slug');
|
||||
if (!slug) return json({ error: 'slug is required' }, 400);
|
||||
|
||||
const sql = getDb();
|
||||
const rows = await sql`
|
||||
SELECT * FROM comments WHERE project_slug = ${slug} ORDER BY timestamp ASC
|
||||
`;
|
||||
|
||||
return json(rows.map(toComment));
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!verifyToken(request)) return json({ error: 'Unauthorized' }, 403);
|
||||
const body = await request.json();
|
||||
const { id, author, type, quote, quoteContext, content, priority, parentId, pageUrl, projectSlug, timestamp, pinX, pinY, anchorSelector, anchorDx, anchorDy } = body;
|
||||
|
||||
if (!id || !author || !projectSlug) return json({ error: 'Missing required fields' }, 400);
|
||||
|
||||
const sql = getDb();
|
||||
await sql`
|
||||
INSERT INTO comments (id, author, type, quote, quote_context_before, quote_context_after, content, priority, parent_id, page_url, project_slug, timestamp, pin_x, pin_y, anchor_selector, anchor_dx, anchor_dy)
|
||||
VALUES (${id}, ${author}, ${type || 'comment'}, ${quote || ''}, ${quoteContext?.beforeText || ''}, ${quoteContext?.afterText || ''}, ${content || ''}, ${priority || 'want'}, ${parentId || null}, ${pageUrl || ''}, ${projectSlug}, ${timestamp || Date.now()}, ${pinX ?? null}, ${pinY ?? null}, ${anchorSelector ?? null}, ${anchorDx ?? null}, ${anchorDy ?? null})
|
||||
`;
|
||||
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
type PutAction = 'edit' | 'resolve' | 'cyclePriority' | 'rename' | 'move';
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
if (!verifyToken(request)) return json({ error: 'Unauthorized' }, 403);
|
||||
const body = await request.json();
|
||||
const { id, action } = body as { id: string; action: PutAction; [key: string]: unknown };
|
||||
|
||||
if (!id) return json({ error: 'id is required' }, 400);
|
||||
if (!action) return json({ error: 'action is required' }, 400);
|
||||
|
||||
const sql = getDb();
|
||||
|
||||
switch (action) {
|
||||
case 'edit': {
|
||||
const { content, priority } = body;
|
||||
if (content === undefined || priority === undefined) return json({ error: 'content and priority are required for edit' }, 400);
|
||||
await sql`
|
||||
UPDATE comments SET content = ${content}, priority = ${priority}, updated_at = ${Date.now()} WHERE id = ${id}
|
||||
`;
|
||||
break;
|
||||
}
|
||||
case 'resolve': {
|
||||
const { resolved, resolvedBy, resolvedAt } = body;
|
||||
await sql`
|
||||
UPDATE comments SET resolved = ${resolved}, resolved_by = ${resolvedBy || null}, resolved_at = ${resolvedAt || null} WHERE id = ${id}
|
||||
`;
|
||||
break;
|
||||
}
|
||||
case 'cyclePriority': {
|
||||
const { priority } = body;
|
||||
if (!priority) return json({ error: 'priority is required for cyclePriority' }, 400);
|
||||
await sql`
|
||||
UPDATE comments SET priority = ${priority} WHERE id = ${id}
|
||||
`;
|
||||
break;
|
||||
}
|
||||
case 'rename': {
|
||||
const { author, oldAuthor, projectSlug } = body;
|
||||
if (!author || !oldAuthor || !projectSlug) return json({ error: 'author, oldAuthor, projectSlug are required for rename' }, 400);
|
||||
await sql`
|
||||
UPDATE comments SET author = ${author} WHERE project_slug = ${projectSlug} AND author = ${oldAuthor}
|
||||
`;
|
||||
break;
|
||||
}
|
||||
case 'move': {
|
||||
const { pinX, pinY, anchorSelector, anchorDx, anchorDy } = body;
|
||||
if (pinX === undefined || pinY === undefined) return json({ error: 'pinX and pinY are required for move' }, 400);
|
||||
await sql`
|
||||
UPDATE comments SET pin_x = ${pinX}, pin_y = ${pinY}, anchor_selector = ${anchorSelector ?? null}, anchor_dx = ${anchorDx ?? null}, anchor_dy = ${anchorDy ?? null}, updated_at = ${Date.now()} WHERE id = ${id}
|
||||
`;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return json({ error: `Unknown action: ${action}` }, 400);
|
||||
}
|
||||
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
if (!verifyToken(request)) return json({ error: 'Unauthorized' }, 403);
|
||||
const id = request.nextUrl.searchParams.get('id');
|
||||
if (!id) return json({ error: 'id is required' }, 400);
|
||||
|
||||
const sql = getDb();
|
||||
await sql`DELETE FROM comments WHERE id = ${id} OR parent_id = ${id}`;
|
||||
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
function toComment(row: Record<string, unknown>): Comment {
|
||||
return {
|
||||
id: row.id as string,
|
||||
author: row.author as string,
|
||||
type: (row.type as Comment['type']) || 'comment',
|
||||
quote: (row.quote as string) || '',
|
||||
quoteContext: {
|
||||
beforeText: (row.quote_context_before as string) || '',
|
||||
afterText: (row.quote_context_after as string) || '',
|
||||
},
|
||||
content: (row.content as string) || '',
|
||||
priority: (row.priority as Comment['priority']) || 'want',
|
||||
parentId: (row.parent_id as string) || null,
|
||||
resolved: (row.resolved as boolean) || false,
|
||||
resolvedBy: (row.resolved_by as string) || null,
|
||||
resolvedAt: row.resolved_at ? Number(row.resolved_at) : null,
|
||||
timestamp: Number(row.timestamp),
|
||||
updatedAt: row.updated_at ? Number(row.updated_at) : null,
|
||||
pageUrl: (row.page_url as string) || '',
|
||||
pinX: row.pin_x != null ? Number(row.pin_x) : null,
|
||||
pinY: row.pin_y != null ? Number(row.pin_y) : null,
|
||||
anchorSelector: (row.anchor_selector as string) || null,
|
||||
anchorDx: row.anchor_dx != null ? Number(row.anchor_dx) : null,
|
||||
anchorDy: row.anchor_dy != null ? Number(row.anchor_dy) : null,
|
||||
};
|
||||
}
|
||||
163
src/app/api/voice-analyze/route.ts
Normal file
163
src/app/api/voice-analyze/route.ts
Normal file
@ -0,0 +1,163 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
// 音声ファイルを base64 で受けるため body サイズ上限を緩める
|
||||
export const maxDuration = 60;
|
||||
|
||||
function corsHeaders() {
|
||||
return {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
};
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new NextResponse(null, { status: 204, headers: corsHeaders() });
|
||||
}
|
||||
|
||||
function json(data: unknown, status = 200) {
|
||||
return NextResponse.json(data, { status, headers: corsHeaders() });
|
||||
}
|
||||
|
||||
function verifyToken(request: NextRequest): boolean {
|
||||
const token = process.env.API_TOKEN;
|
||||
// ローカル開発ではトークン無しで動かせるようにする。本番で未設定なら、
|
||||
// 設定ミスで Gemini を誰でも叩ける状態になるより落ちた方が安全。
|
||||
if (!token) return process.env.NODE_ENV !== 'production';
|
||||
const auth = request.headers.get('Authorization');
|
||||
if (!auth?.startsWith('Bearer ')) return false;
|
||||
return auth.slice(7) === token;
|
||||
}
|
||||
|
||||
interface Target {
|
||||
index: number;
|
||||
text: string;
|
||||
heading: string;
|
||||
isHeading?: boolean;
|
||||
}
|
||||
|
||||
// gemini-flash-latest は常に最新の flash 系モデルを指す公式エイリアス。
|
||||
// 固定バージョン名(例: gemini-2.5-flash)は新規APIキーで使えなくなることがある。
|
||||
const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-flash-latest';
|
||||
|
||||
// Gemini に JSON 配列で返させるためのスキーマ
|
||||
const RESPONSE_SCHEMA = {
|
||||
type: 'ARRAY',
|
||||
items: {
|
||||
type: 'OBJECT',
|
||||
properties: {
|
||||
target_index: { type: 'INTEGER' },
|
||||
quote: { type: 'STRING' },
|
||||
comment: { type: 'STRING' },
|
||||
priority: { type: 'STRING', enum: ['must', 'better', 'want'] },
|
||||
},
|
||||
required: ['target_index', 'quote', 'comment', 'priority'],
|
||||
},
|
||||
};
|
||||
|
||||
function buildPrompt(targets: Target[]): string {
|
||||
const list = targets
|
||||
.map((t) => {
|
||||
const context = t.isHeading ? '見出し' : t.heading || '(見出しなし)';
|
||||
return `${t.index}\t[${context}]\t${t.text}`;
|
||||
})
|
||||
.join('\n');
|
||||
return [
|
||||
'あなたは図解レビューのフィードバック解析AIです。',
|
||||
'ユーザーが音声で話したフィードバックを聞き取り、図解のどの部分に対する指摘かを1つずつ特定してください。',
|
||||
'',
|
||||
'# 図解の要素一覧',
|
||||
'各行は「index<TAB>[分類]<TAB>その要素に表示されている文字」です。',
|
||||
'[分類] が「見出し」の行はセクションの見出しそのもの、それ以外はその名前のセクションに属する本文・ラベルです。',
|
||||
'ピンはあなたが選んだ要素の真上に立ちます。',
|
||||
list,
|
||||
'',
|
||||
'# 指示',
|
||||
'- 音声を聞き、指摘・要望・感想を1件ずつ抽出する',
|
||||
'- quote には、その指摘の根拠になった発言を音声のまま短く書き出す',
|
||||
'- target_index は、話者が実際に言及した語句と最も一致する要素を選ぶ',
|
||||
'- 話者が具体的な文言(カードのラベル・数字・固有名詞など)を口にしたら、見出しではなくその文言そのものの要素を選ぶ',
|
||||
'- 具体的な文言がなく話題だけ分かる場合は、そのセクションの見出し要素を選ぶ',
|
||||
'- どの要素にも結び付けられない場合だけ target_index に -1 を入れる',
|
||||
'- comment はユーザーの意図を簡潔にまとめた日本語にする',
|
||||
'- priority は次の基準で割り当てる: 修正が必須=must / 改善してほしい=better / 軽微な要望や肯定的な発言=want',
|
||||
'- 「ここはOK」「良い」などの肯定的な発言も want として記録する',
|
||||
'- 指摘が複数あれば配列で複数返す。何も聞き取れなければ空配列を返す',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!verifyToken(request)) return json({ error: 'Unauthorized' }, 403);
|
||||
|
||||
const apiKey = process.env.GEMINI_API_KEY;
|
||||
if (!apiKey) {
|
||||
return json({ error: 'GEMINI_API_KEY が未設定です' }, 500);
|
||||
}
|
||||
|
||||
let body: { audioBase64?: string; mimeType?: string; targets?: Target[] };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return json({ error: 'リクエストボディが不正です' }, 400);
|
||||
}
|
||||
|
||||
const { audioBase64, mimeType, targets } = body;
|
||||
if (!audioBase64) return json({ error: '音声データがありません' }, 400);
|
||||
if (!Array.isArray(targets) || targets.length === 0) {
|
||||
return json({ error: 'ページの要素情報がありません' }, 400);
|
||||
}
|
||||
|
||||
// Gemini は codecs パラメータ付き mimeType を嫌うことがあるためベース型に正規化
|
||||
const audioMime = (mimeType || 'audio/webm').split(';')[0];
|
||||
|
||||
const geminiBody = {
|
||||
contents: [
|
||||
{
|
||||
parts: [
|
||||
{ text: buildPrompt(targets) },
|
||||
{ inlineData: { mimeType: audioMime, data: audioBase64 } },
|
||||
],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
responseMimeType: 'application/json',
|
||||
responseSchema: RESPONSE_SCHEMA,
|
||||
},
|
||||
};
|
||||
|
||||
const url = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`;
|
||||
|
||||
let geminiRes: Response;
|
||||
try {
|
||||
geminiRes = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-goog-api-key': apiKey,
|
||||
},
|
||||
body: JSON.stringify(geminiBody),
|
||||
});
|
||||
} catch {
|
||||
return json({ error: 'Gemini への接続に失敗しました' }, 502);
|
||||
}
|
||||
|
||||
if (!geminiRes.ok) {
|
||||
const detail = await geminiRes.text().catch(() => '');
|
||||
return json({ error: `Gemini エラー (${geminiRes.status})`, detail }, 502);
|
||||
}
|
||||
|
||||
const data = await geminiRes.json();
|
||||
const text: string | undefined = data?.candidates?.[0]?.content?.parts?.[0]?.text;
|
||||
if (!text) {
|
||||
return json({ error: 'Gemini から有効な応答が得られませんでした' }, 502);
|
||||
}
|
||||
|
||||
let results: unknown;
|
||||
try {
|
||||
results = JSON.parse(text);
|
||||
} catch {
|
||||
return json({ error: 'Gemini 応答の JSON パースに失敗しました', detail: text }, 502);
|
||||
}
|
||||
|
||||
return json({ results });
|
||||
}
|
||||
11
src/app/layout.tsx
Normal file
11
src/app/layout.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="ja">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
7
src/lib/db.ts
Normal file
7
src/lib/db.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { neon } from '@neondatabase/serverless';
|
||||
|
||||
export function getDb() {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error('DATABASE_URL is not set');
|
||||
return neon(url);
|
||||
}
|
||||
5
src/shared/api-client.ts
Normal file
5
src/shared/api-client.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export function authHeaders(token: string): Record<string, string> {
|
||||
const hdrs: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (token) hdrs['Authorization'] = 'Bearer ' + token;
|
||||
return hdrs;
|
||||
}
|
||||
61
src/shared/constants.ts
Normal file
61
src/shared/constants.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import type { Priority } from './types';
|
||||
|
||||
export const PRIORITY_COLORS: Record<
|
||||
Priority,
|
||||
{ bg: string; text: string; light: string; border: string }
|
||||
> = {
|
||||
must: {
|
||||
bg: '#ef4444',
|
||||
text: '#fff',
|
||||
light: 'rgba(239,68,68,0.1)',
|
||||
border: 'rgba(239,68,68,0.3)',
|
||||
},
|
||||
better: {
|
||||
bg: '#f59e0b',
|
||||
text: '#fff',
|
||||
light: 'rgba(245,158,11,0.1)',
|
||||
border: 'rgba(245,158,11,0.3)',
|
||||
},
|
||||
want: {
|
||||
bg: '#22c55e',
|
||||
text: '#fff',
|
||||
light: 'rgba(34,197,94,0.1)',
|
||||
border: 'rgba(34,197,94,0.3)',
|
||||
},
|
||||
};
|
||||
|
||||
export const PRIORITY_LABELS: Record<Priority, string> = {
|
||||
must: 'Must',
|
||||
better: 'Better',
|
||||
want: 'Want',
|
||||
};
|
||||
|
||||
export const PRIORITY_CYCLE: Record<Priority, Priority> = {
|
||||
must: 'better',
|
||||
better: 'want',
|
||||
want: 'must',
|
||||
};
|
||||
|
||||
export const HIGHLIGHT_COLORS: Record<
|
||||
Priority,
|
||||
{ bg: string; hoverBg: string; border: string }
|
||||
> = {
|
||||
must: {
|
||||
bg: 'rgba(239,68,68,0.15)',
|
||||
hoverBg: 'rgba(239,68,68,0.25)',
|
||||
border: '#ef4444',
|
||||
},
|
||||
better: {
|
||||
bg: 'rgba(245,158,11,0.15)',
|
||||
hoverBg: 'rgba(245,158,11,0.25)',
|
||||
border: '#f59e0b',
|
||||
},
|
||||
want: {
|
||||
bg: 'rgba(34,197,94,0.15)',
|
||||
hoverBg: 'rgba(34,197,94,0.25)',
|
||||
border: '#22c55e',
|
||||
},
|
||||
};
|
||||
|
||||
export const USERNAME_KEY = 'fb-username';
|
||||
export const SIDEBAR_WIDTH_KEY = 'fb-sidebar-width';
|
||||
10
src/shared/slug.ts
Normal file
10
src/shared/slug.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export function slugify(url: string): string {
|
||||
return url
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/[^a-zA-Z0-9\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF]/g, '_')
|
||||
.substring(0, 100);
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substring(2, 8);
|
||||
}
|
||||
12
src/shared/time.ts
Normal file
12
src/shared/time.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export function fmtTime(ts: number): string {
|
||||
const d = Date.now() - ts;
|
||||
if (d < 60000) return 'たった今';
|
||||
if (d < 3600000) return Math.floor(d / 60000) + '分前';
|
||||
if (d < 86400000) return Math.floor(d / 3600000) + '時間前';
|
||||
return new Date(ts).toLocaleDateString('ja-JP', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
27
src/shared/types.ts
Normal file
27
src/shared/types.ts
Normal file
@ -0,0 +1,27 @@
|
||||
export type Priority = 'must' | 'better' | 'want';
|
||||
|
||||
export type CommentType = 'comment' | 'strikethrough' | 'pin';
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
author: string;
|
||||
type: CommentType;
|
||||
quote: string;
|
||||
quoteContext: { beforeText: string; afterText: string };
|
||||
content: string;
|
||||
priority: Priority;
|
||||
parentId: string | null;
|
||||
resolved: boolean;
|
||||
resolvedBy: string | null;
|
||||
resolvedAt: number | null;
|
||||
timestamp: number;
|
||||
updatedAt: number | null;
|
||||
pageUrl: string;
|
||||
pinX: number | null; // type==='pin' のときコンテナ幅に対する%(フォールバック)
|
||||
pinY: number | null; // type==='pin' のときコンテナ上端からのpx(フォールバック)
|
||||
anchorSelector: string | null; // 追従の基準にする最寄り要素の CSS セレクタ
|
||||
anchorDx: number | null; // 基準要素の左上からの相対px(横)
|
||||
anchorDy: number | null; // 基準要素の左上からの相対px(縦)
|
||||
}
|
||||
|
||||
export type FilterMode = 'unresolved' | 'resolved' | 'all';
|
||||
53
src/widget/api.ts
Normal file
53
src/widget/api.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { authHeaders } from '../shared/api-client';
|
||||
|
||||
let apiBase = '';
|
||||
let apiToken = '';
|
||||
|
||||
export function initApi(base: string, token: string): void {
|
||||
apiBase = base;
|
||||
apiToken = token;
|
||||
}
|
||||
|
||||
export function api(method: string, params: Record<string, unknown>): Promise<unknown> {
|
||||
let url = apiBase + '/api/comments';
|
||||
const hdrs = authHeaders(apiToken);
|
||||
const opts: RequestInit = { method, headers: hdrs };
|
||||
if (method === 'GET') {
|
||||
url += '?slug=' + encodeURIComponent(params.slug as string);
|
||||
} else if (method === 'DELETE') {
|
||||
url += '?id=' + encodeURIComponent(params.id as string);
|
||||
} else {
|
||||
opts.body = JSON.stringify(params);
|
||||
}
|
||||
return fetch(url, opts).then((r) => r.json());
|
||||
}
|
||||
|
||||
export interface VoiceTargetInput {
|
||||
index: number;
|
||||
text: string; // その要素に表示されている文字
|
||||
heading: string; // 直前の見出し(どのセクションの中か)
|
||||
isHeading: boolean; // 見出し要素そのものか
|
||||
}
|
||||
|
||||
export interface VoiceResult {
|
||||
target_index: number; // 一致する要素がなければ -1
|
||||
quote: string; // 判断の根拠になった発言
|
||||
comment: string;
|
||||
priority: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 録音した音声(base64) + ページ上の要素一覧を /api/voice-analyze に送り、
|
||||
* Gemini が「文字起こし+要素への振り分け」した結果を受け取る。
|
||||
*/
|
||||
export function voiceApi(
|
||||
audioBase64: string,
|
||||
mimeType: string,
|
||||
targets: VoiceTargetInput[],
|
||||
): Promise<{ results?: VoiceResult[]; error?: string }> {
|
||||
return fetch(apiBase + '/api/voice-analyze', {
|
||||
method: 'POST',
|
||||
headers: authHeaders(apiToken),
|
||||
body: JSON.stringify({ audioBase64, mimeType, targets }),
|
||||
}).then((r) => r.json());
|
||||
}
|
||||
24
src/widget/dom.ts
Normal file
24
src/widget/dom.ts
Normal file
@ -0,0 +1,24 @@
|
||||
type Attrs = Record<string, unknown>;
|
||||
|
||||
export function el(tag: string, attrs?: Attrs | null, children?: string | Node | (Node | null)[] | null): HTMLElement {
|
||||
const e = document.createElement(tag);
|
||||
if (attrs) Object.keys(attrs).forEach((k) => {
|
||||
const v = attrs[k];
|
||||
if (k === 'className') e.className = v as string;
|
||||
else if (k === 'innerHTML') e.innerHTML = v as string;
|
||||
else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.substring(2).toLowerCase(), v as EventListener);
|
||||
else e.setAttribute(k, String(v));
|
||||
});
|
||||
if (children != null) {
|
||||
if (typeof children === 'string') e.textContent = children;
|
||||
else if (Array.isArray(children)) children.forEach((c) => { if (c) e.appendChild(c); });
|
||||
else e.appendChild(children);
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
export function esc(s: string): string {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
110
src/widget/highlight.ts
Normal file
110
src/widget/highlight.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import { state, type FbComment } from './state';
|
||||
|
||||
function collectTextNodes(): Text[] {
|
||||
const nodes: Text[] = [];
|
||||
const tw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(n) {
|
||||
const p = n.parentElement;
|
||||
if (!p) return NodeFilter.FILTER_REJECT;
|
||||
if (p.tagName === 'SCRIPT' || p.tagName === 'STYLE') return NodeFilter.FILTER_REJECT;
|
||||
if (p.closest('#fb-sidebar,#fb-toggle,#fb-popup,.fb-highlight')) return NodeFilter.FILTER_REJECT;
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
}
|
||||
});
|
||||
let n: Node | null;
|
||||
while ((n = tw.nextNode())) nodes.push(n as Text);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export function mapNormToOrig(orig: string, normStart: number, normEnd: number): [number, number] | null {
|
||||
const normalized = orig.replace(/[\s\u00A0]+/g, ' ');
|
||||
if (normStart >= normalized.length) return null;
|
||||
let origIdx = 0, normIdx = 0;
|
||||
let origStart = -1, origEnd = -1;
|
||||
while (origIdx < orig.length) {
|
||||
if (normIdx === normStart && origStart === -1) origStart = origIdx;
|
||||
if (normIdx === normEnd) { origEnd = origIdx; break; }
|
||||
if (normIdx < normalized.length && orig[origIdx] === normalized[normIdx]) {
|
||||
origIdx++; normIdx++;
|
||||
} else {
|
||||
origIdx++;
|
||||
}
|
||||
}
|
||||
if (origEnd === -1 && normIdx >= normEnd) origEnd = origIdx;
|
||||
if (origStart === -1 || origEnd === -1 || origStart >= origEnd) return null;
|
||||
return [origStart, origEnd];
|
||||
}
|
||||
|
||||
function wrapTextRange(node: Text, start: number, end: number, comment: FbComment, onClickHighlight: (id: string) => void): void {
|
||||
const orig = node.textContent!;
|
||||
const before = document.createTextNode(orig.substring(0, start));
|
||||
const mark = document.createElement('mark');
|
||||
mark.className = 'fb-highlight fb-highlight-' + comment.priority;
|
||||
mark.dataset.commentId = comment.id;
|
||||
mark.textContent = orig.substring(start, end);
|
||||
mark.addEventListener('click', () => { onClickHighlight(comment.id); });
|
||||
const after = document.createTextNode(orig.substring(end));
|
||||
node.parentNode!.insertBefore(before, node);
|
||||
node.parentNode!.insertBefore(mark, node);
|
||||
node.parentNode!.insertBefore(after, node);
|
||||
node.parentNode!.removeChild(node);
|
||||
}
|
||||
|
||||
export function applyHighlights(onClickHighlight: (id: string) => void): void {
|
||||
document.querySelectorAll('.fb-highlight').forEach((el) => {
|
||||
const t = document.createTextNode(el.textContent || '');
|
||||
el.parentNode!.replaceChild(t, el);
|
||||
});
|
||||
document.body.normalize();
|
||||
|
||||
state.comments.filter((c) => !c.parentId && !c.resolved && c.quote && c.quote.length >= 2).forEach((c) => {
|
||||
const search = c.quote.replace(/[\s\u00A0]+/g, ' ').trim();
|
||||
const textNodes = collectTextNodes();
|
||||
let found = false;
|
||||
|
||||
for (let i = 0; i < textNodes.length; i++) {
|
||||
const node = textNodes[i];
|
||||
const orig = node.textContent!;
|
||||
|
||||
const di = orig.indexOf(search);
|
||||
if (di !== -1) {
|
||||
try { wrapTextRange(node, di, di + search.length, c, onClickHighlight); found = true; } catch (_) { /* ignore */ }
|
||||
break;
|
||||
}
|
||||
|
||||
const norm = orig.replace(/[\s\u00A0]+/g, ' ');
|
||||
const ni = norm.indexOf(search);
|
||||
if (ni === -1) continue;
|
||||
const range = mapNormToOrig(orig, ni, ni + search.length);
|
||||
if (range) {
|
||||
try { wrapTextRange(node, range[0], range[1], c, onClickHighlight); found = true; } catch (_) { /* ignore */ }
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
let concat = '';
|
||||
const nodeMap: Array<{ node: Text; start: number; end: number }> = [];
|
||||
for (let j = 0; j < textNodes.length; j++) {
|
||||
const s = concat.length;
|
||||
concat += textNodes[j].textContent;
|
||||
nodeMap.push({ node: textNodes[j], start: s, end: concat.length });
|
||||
}
|
||||
const concatNorm = concat.replace(/[\s\u00A0]+/g, ' ');
|
||||
const ci = concatNorm.indexOf(search);
|
||||
if (ci !== -1) {
|
||||
const range = mapNormToOrig(concat, ci, ci + search.length);
|
||||
if (range) {
|
||||
const mStart = range[0], mEnd = range[1];
|
||||
for (let k = nodeMap.length - 1; k >= 0; k--) {
|
||||
const nm = nodeMap[k];
|
||||
if (nm.end <= mStart || nm.start >= mEnd) continue;
|
||||
const ls = Math.max(0, mStart - nm.start);
|
||||
const le = Math.min(nm.node.textContent!.length, mEnd - nm.start);
|
||||
try { wrapTextRange(nm.node, ls, le, c, onClickHighlight); } catch (_) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
16
src/widget/icons.ts
Normal file
16
src/widget/icons.ts
Normal file
@ -0,0 +1,16 @@
|
||||
export const SVG: Record<string, string> = {
|
||||
message: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"/></svg>',
|
||||
check: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
|
||||
rotateCcw: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>',
|
||||
pencil: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></svg>',
|
||||
trash: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>',
|
||||
user: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>',
|
||||
x: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>',
|
||||
panelRight: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/></svg>',
|
||||
mic: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>',
|
||||
loader: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>',
|
||||
};
|
||||
|
||||
export function icon(name: string, size = 14, strokeWidth = 2): string {
|
||||
return (SVG[name] || '').replace(/SIZE/g, String(size)).replace(/SW/g, String(strokeWidth));
|
||||
}
|
||||
286
src/widget/index.ts
Normal file
286
src/widget/index.ts
Normal file
@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Icons: Lucide (https://lucide.dev)
|
||||
* ISC License - Copyright (c) Lucide Contributors 2026
|
||||
*/
|
||||
|
||||
// document.currentScript は IIFE 先頭でキャプチャ必須(esbuild の import 巻き上げ前に実行される)
|
||||
const SCRIPT = document.currentScript as HTMLScriptElement | null;
|
||||
const API_BASE = SCRIPT ? SCRIPT.src.replace(/\/widget\.js.*$/, '') : '';
|
||||
const API_TOKEN = SCRIPT ? (SCRIPT.dataset.token || '') : '';
|
||||
|
||||
import type { Priority } from '../shared/types';
|
||||
import { USERNAME_KEY, PRIORITY_CYCLE } from '../shared/constants';
|
||||
import { generateId } from '../shared/slug';
|
||||
import { initApi, api } from './api';
|
||||
import { state, slug, type FbComment } from './state';
|
||||
import { injectStyles } from './styles';
|
||||
import { render, toggleSidebar, setRenderDeps } from './render/index';
|
||||
import { setToggleAreaHandler, setToggleVoiceHandler } from './render/toggle';
|
||||
import { setSidebarActions } from './render/sidebar';
|
||||
import { applyHighlights } from './highlight';
|
||||
import { scrollToQuote, scrollToCard, scrollToPin } from './scroll';
|
||||
import { setupTextSelection } from './selection';
|
||||
import {
|
||||
enterPinMode, exitPinMode,
|
||||
handlePinClick, renderPins, renderPinPopup, cancelPinPopup,
|
||||
setupPinDrag,
|
||||
} from './pin';
|
||||
import { toggleRecording, setVoiceOnChange } from './voice';
|
||||
|
||||
initApi(API_BASE, API_TOKEN);
|
||||
|
||||
function loadComments(): Promise<void> {
|
||||
return api('GET', { slug }).then((c) => {
|
||||
if (Array.isArray(c)) state.comments = c;
|
||||
render();
|
||||
renderPins(); // ピンも state.comments に含まれるため取得後に描画
|
||||
applyHighlights(onClickHighlight);
|
||||
}).catch(() => {
|
||||
render();
|
||||
renderPins();
|
||||
applyHighlights(onClickHighlight);
|
||||
});
|
||||
}
|
||||
|
||||
function closePopup(): void {
|
||||
state.selectedText = '';
|
||||
state.selectedRect = null;
|
||||
state.popupContent = '';
|
||||
state.popupPriority = 'must';
|
||||
render();
|
||||
}
|
||||
|
||||
function submitComment(priority: Priority): void {
|
||||
if (!state.selectedText) return;
|
||||
const c: FbComment = {
|
||||
id: generateId(), author: state.username || '匿名', type: 'comment',
|
||||
quote: state.selectedText, quoteContext: state.selectedQuoteContext,
|
||||
content: state.popupContent.trim(),
|
||||
priority, parentId: null, pageUrl: window.location.href,
|
||||
projectSlug: slug, timestamp: Date.now(),
|
||||
resolved: false, resolvedBy: null, resolvedAt: null, updatedAt: null,
|
||||
};
|
||||
state.comments.push(c);
|
||||
closePopup();
|
||||
applyHighlights(onClickHighlight);
|
||||
api('POST', c as unknown as Record<string, unknown>).then(loadComments);
|
||||
}
|
||||
|
||||
function resolveComment(id: string): void {
|
||||
const c = state.comments.find((x) => x.id === id);
|
||||
if (!c) return;
|
||||
const now = !c.resolved;
|
||||
c.resolved = now;
|
||||
c.resolvedBy = now ? state.username : null;
|
||||
c.resolvedAt = now ? Date.now() : null;
|
||||
render(); renderPins(); applyHighlights(onClickHighlight);
|
||||
api('PUT', { id, action: 'resolve', resolved: now, resolvedBy: c.resolvedBy, resolvedAt: c.resolvedAt });
|
||||
}
|
||||
|
||||
function cyclePriority(id: string): void {
|
||||
const c = state.comments.find((x) => x.id === id);
|
||||
if (!c || c.author !== state.username) return;
|
||||
c.priority = PRIORITY_CYCLE[c.priority] || 'must';
|
||||
render(); renderPins(); applyHighlights(onClickHighlight);
|
||||
api('PUT', { id, action: 'cyclePriority', priority: c.priority });
|
||||
}
|
||||
|
||||
function deleteComment(id: string): void {
|
||||
state.comments = state.comments.filter((c) => c.id !== id && c.parentId !== id);
|
||||
render(); renderPins(); applyHighlights(onClickHighlight);
|
||||
api('DELETE', { id });
|
||||
}
|
||||
|
||||
function deleteReply(id: string): void {
|
||||
state.comments = state.comments.filter((c) => c.id !== id);
|
||||
render();
|
||||
api('DELETE', { id });
|
||||
}
|
||||
|
||||
function saveEdit(id: string): void {
|
||||
const c = state.comments.find((x) => x.id === id);
|
||||
if (!c) return;
|
||||
c.content = state.editContent;
|
||||
c.priority = state.editPriority;
|
||||
state.editingId = null;
|
||||
render(); renderPins(); applyHighlights(onClickHighlight);
|
||||
api('PUT', { id, action: 'edit', content: c.content, priority: c.priority });
|
||||
}
|
||||
|
||||
function submitReply(parentId: string): void {
|
||||
if (!state.replyText.trim() || !state.username) return;
|
||||
const r: FbComment = {
|
||||
id: generateId(), author: state.username, type: 'comment',
|
||||
quote: '', quoteContext: { beforeText: '', afterText: '' },
|
||||
content: state.replyText.trim(), priority: 'want',
|
||||
parentId, pageUrl: window.location.href,
|
||||
projectSlug: slug, timestamp: Date.now(),
|
||||
resolved: false, resolvedBy: null, resolvedAt: null, updatedAt: null,
|
||||
};
|
||||
state.comments.push(r);
|
||||
state.replyingTo = null;
|
||||
state.replyText = '';
|
||||
render();
|
||||
api('POST', r as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function finishNameEdit(): void {
|
||||
if (state.nameInput.trim() && state.nameInput.trim() !== state.username) {
|
||||
const oldName = state.username;
|
||||
state.username = state.nameInput.trim();
|
||||
localStorage.setItem(USERNAME_KEY, state.username);
|
||||
api('PUT', { id: '_rename', action: 'rename', author: state.username, oldAuthor: oldName, projectSlug: slug }).then(loadComments);
|
||||
}
|
||||
state.editingName = false;
|
||||
render();
|
||||
}
|
||||
|
||||
function onClickHighlight(id: string): void {
|
||||
scrollToCard(id, toggleSidebar);
|
||||
}
|
||||
|
||||
function togglePinMode(): void {
|
||||
if (state.pinMode) {
|
||||
exitPinMode();
|
||||
} else {
|
||||
enterPinMode();
|
||||
}
|
||||
render();
|
||||
renderPins(); // 吹き出しの編集UI(バッジ/削除)の表示をモードに合わせて更新
|
||||
}
|
||||
|
||||
function handleVoiceToggle(): void {
|
||||
toggleRecording();
|
||||
}
|
||||
|
||||
let voiceToastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** 音声解析の失敗を画面に出す。console だけだと気づけず、原因不明のまま使い続けてしまう。 */
|
||||
function showVoiceToast(message: string): void {
|
||||
let toast = document.getElementById('fb-voice-toast');
|
||||
if (!toast) {
|
||||
toast = document.createElement('div');
|
||||
toast.id = 'fb-voice-toast';
|
||||
toast.className = 'fb-voice-toast';
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
toast.textContent = message;
|
||||
// サイドバーが開いているとその上に重なって読めないので、開いている分だけ左に寄せる
|
||||
toast.style.right = (state.sidebarOpen ? state.sidebarWidth + 16 : 16) + 'px';
|
||||
// 追加直後に show を付けるとトランジションが走らないため 1 フレーム待つ
|
||||
requestAnimationFrame(() => toast.classList.add('show'));
|
||||
if (voiceToastTimer) clearTimeout(voiceToastTimer);
|
||||
voiceToastTimer = setTimeout(() => toast.classList.remove('show'), 6000);
|
||||
}
|
||||
|
||||
// Wire up dependencies before first render
|
||||
setRenderDeps(closePopup, submitComment);
|
||||
setToggleAreaHandler(togglePinMode);
|
||||
setToggleVoiceHandler(handleVoiceToggle);
|
||||
// 録音状態の変化やエラー、ピン配置完了時に UI を更新する
|
||||
setVoiceOnChange(() => {
|
||||
render();
|
||||
if (state.voiceError) {
|
||||
showVoiceToast(state.voiceError);
|
||||
state.voiceError = null; // 一度知らせたら消す(次の録音に持ち越さない)
|
||||
}
|
||||
});
|
||||
setSidebarActions({
|
||||
toggleSidebar,
|
||||
cyclePriority,
|
||||
scrollToQuote,
|
||||
scrollToPin,
|
||||
resolveComment,
|
||||
deleteComment,
|
||||
deleteReply,
|
||||
saveEdit,
|
||||
submitReply,
|
||||
finishNameEdit,
|
||||
});
|
||||
|
||||
// フィードバックモード ON 中の「クリック=ピン / ドラッグ=テキスト選択」自動判定。
|
||||
// クリックかドラッグかは mousedown→click の移動距離で判定し、テキスト選択時はピンを置かない。
|
||||
const WIDGET_UI_SELECTOR = '#fb-sidebar,#fb-toggle,#fb-pin-popup,.fb-pin-marker,.fb-pin-tooltip,.fb-pin-delete,.fb-popup';
|
||||
const CLICK_DRAG_THRESHOLD = 6; // px
|
||||
|
||||
function closePinPopup(): void {
|
||||
cancelPinPopup();
|
||||
state.editingPinId = null;
|
||||
const popup = document.getElementById('fb-pin-popup');
|
||||
if (popup) popup.classList.remove('show');
|
||||
render();
|
||||
}
|
||||
|
||||
function setupClickAndDrag(): void {
|
||||
let downX = 0;
|
||||
let downY = 0;
|
||||
|
||||
document.addEventListener('mousedown', (e) => {
|
||||
downX = e.clientX;
|
||||
downY = e.clientY;
|
||||
}, true);
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
const inWidgetUi = !!(e.target as HTMLElement).closest(WIDGET_UI_SELECTOR);
|
||||
|
||||
if (!state.pinMode) {
|
||||
// モードOFFでもピンをクリックすれば編集ポップアップが開く。
|
||||
// モードON時のように「外側クリック=別の場所にピンを追加」へは
|
||||
// 遷移しないので、ここで閉じないと閉じ手段が Escape だけになる。
|
||||
if (state.pinPopupPos && !inWidgetUi) closePinPopup();
|
||||
return;
|
||||
}
|
||||
if (inWidgetUi) return;
|
||||
|
||||
// ドラッグでテキスト選択された場合はテキストフロー(mode1)に委譲
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.toString().trim()) return;
|
||||
|
||||
// 移動が大きければドラッグとみなしてピンは置かない
|
||||
if (Math.hypot(e.clientX - downX, e.clientY - downY) >= CLICK_DRAG_THRESHOLD) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handlePinClick(e);
|
||||
if (state.pinPopupPos) {
|
||||
renderPinPopup(render);
|
||||
}
|
||||
render();
|
||||
}, true);
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (state.pinPopupPos) {
|
||||
closePinPopup();
|
||||
} else if (state.pinMode) {
|
||||
exitPinMode();
|
||||
render();
|
||||
renderPins();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function setupPinReposition(): void {
|
||||
// 画面幅が変わるとアンカー基準位置も変わるため、ピンを再配置する(追従)
|
||||
window.addEventListener('resize', () => {
|
||||
if (resizeTimer) clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => { renderPins(); }, 100);
|
||||
});
|
||||
}
|
||||
|
||||
function init(): void {
|
||||
injectStyles();
|
||||
render();
|
||||
setupTextSelection(render, closePopup);
|
||||
setupClickAndDrag();
|
||||
setupPinDrag(render);
|
||||
setupPinReposition();
|
||||
loadComments(); // 取得後に renderPins も実行される(ピンは state.comments に含まれる)
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
513
src/widget/pin.ts
Normal file
513
src/widget/pin.ts
Normal file
@ -0,0 +1,513 @@
|
||||
import type { Priority } from '../shared/types';
|
||||
import { PRIORITY_COLORS } from '../shared/constants';
|
||||
import { generateId } from '../shared/slug';
|
||||
import { state, slug, type FbComment } from './state';
|
||||
import { api } from './api';
|
||||
import { esc } from './dom';
|
||||
|
||||
function getContainer(): HTMLElement {
|
||||
return document.querySelector('main') || document.body;
|
||||
}
|
||||
|
||||
/**
|
||||
* ピンの追従基準。
|
||||
* dx/dy は「基準要素の左上からの相対px」(手動ピン=クリックした一点を保つ)。
|
||||
* dx/dy が null のときは「その要素の文字の中央上端」を意味し、表示のたびに測り直す。
|
||||
* 音声ピンは要素そのものを指すので後者を使う。絶対pxで持つと、画面幅が変わって
|
||||
* 要素が縮んだときにオフセットだけが元のまま残り、文字から大きく外れてしまう。
|
||||
*/
|
||||
export interface PinAnchor { selector: string; dx: number | null; dy: number | null; }
|
||||
|
||||
// 新規ピン配置時に handlePinClick が捕捉したアンカーを submit まで一時保持する
|
||||
let pendingAnchor: PinAnchor | null = null;
|
||||
|
||||
/** 要素から、document.querySelector で再取得できる安定的な CSS パスを生成する。 */
|
||||
function cssPath(el: Element): string {
|
||||
const esc = (s: string) => (window.CSS && CSS.escape ? CSS.escape(s) : s);
|
||||
const parts: string[] = [];
|
||||
let node: Element | null = el;
|
||||
while (node && node.nodeType === 1 && node !== document.body) {
|
||||
if ((node as HTMLElement).id) { parts.unshift('#' + esc((node as HTMLElement).id)); break; }
|
||||
let sel = node.tagName.toLowerCase();
|
||||
const parent: Element | null = node.parentElement;
|
||||
if (parent) {
|
||||
const sib = Array.from(parent.children).filter((c) => c.tagName === node!.tagName);
|
||||
if (sib.length > 1) sel += ':nth-of-type(' + (sib.indexOf(node) + 1) + ')';
|
||||
}
|
||||
parts.unshift(sel);
|
||||
node = parent;
|
||||
}
|
||||
return parts.join('>');
|
||||
}
|
||||
|
||||
const WIDGET_UI = '#fb-sidebar,#fb-toggle,#fb-pin-popup,.fb-pin-marker,.fb-pin-tooltip';
|
||||
|
||||
/**
|
||||
* 画面座標(clientX,clientY)の下にある「コンテナ内の要素」をアンカーとして捕捉する。
|
||||
* elementFromPoint(単数)だと最前面で止まるため、その位置にピンのマーカーや
|
||||
* 吹き出しが重なっていると本文を拾えずアンカーを失う。重なりを上から順に見て、
|
||||
* 最初に見つかった本文要素を採用する。
|
||||
*/
|
||||
function captureAnchorAtPoint(clientX: number, clientY: number): PinAnchor | null {
|
||||
const container = getContainer();
|
||||
const stack = document.elementsFromPoint(clientX, clientY) as HTMLElement[];
|
||||
const el = stack.find(
|
||||
(e) => !e.closest(WIDGET_UI) && e !== container && e !== document.body && container.contains(e),
|
||||
);
|
||||
if (!el) return null;
|
||||
const eRect = el.getBoundingClientRect();
|
||||
return { selector: cssPath(el), dx: clientX - eRect.left, dy: clientY - eRect.top };
|
||||
}
|
||||
|
||||
/** その座標がサイドバー・トグル・ポップアップに覆われているか。 */
|
||||
function droppedOnWidgetUi(clientX: number, clientY: number): boolean {
|
||||
return document
|
||||
.elementsFromPoint(clientX, clientY)
|
||||
.some((e) => (e as HTMLElement).closest('#fb-sidebar,#fb-toggle,#fb-pin-popup'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 要素内で実際に描画されている「文字」の矩形を返す。
|
||||
* 要素の矩形をそのまま使うと、中央寄せの見出しのように box が横幅いっぱいの場合に
|
||||
* 左端=文字から遠く離れた余白を指してしまう。Range で中身を選択すると
|
||||
* 文字が占めている範囲だけが取れる。
|
||||
*/
|
||||
function textRect(el: Element): DOMRect {
|
||||
try {
|
||||
const r = document.createRange();
|
||||
r.selectNodeContents(el);
|
||||
const box = r.getBoundingClientRect();
|
||||
if (box.width >= 1 && box.height >= 1) return box;
|
||||
} catch {
|
||||
// Range が張れない要素(置換要素など)は要素矩形にフォールバック
|
||||
}
|
||||
return el.getBoundingClientRect();
|
||||
}
|
||||
|
||||
/**
|
||||
* 要素の「文字の中央上端」をビューポート座標で返す。
|
||||
* ピンは transform:translate(-50%,-100%) で先端が座標を指すため、
|
||||
* ここを指すとその文字の真上にピンが立つ。
|
||||
*/
|
||||
function textAnchorPoint(el: Element): { x: number; y: number } {
|
||||
const t = textRect(el);
|
||||
return { x: t.left + t.width / 2, y: t.top };
|
||||
}
|
||||
|
||||
/** 要素の左上から dx/dy だけずらした点(手動ピン:クリックした一点を保つ)。 */
|
||||
function offsetPoint(el: Element, dx: number, dy: number): { x: number; y: number } {
|
||||
const e = el.getBoundingClientRect();
|
||||
return { x: e.left + dx, y: e.top + dy };
|
||||
}
|
||||
|
||||
/**
|
||||
* 要素そのものをアンカーにする(音声ピン)。
|
||||
* オフセットは持たせず、描画のたびに文字位置を測り直す(resolvePinPosition 参照)。
|
||||
*/
|
||||
export function anchorFromElement(el: Element): PinAnchor {
|
||||
return { selector: cssPath(el), dx: null, dy: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* ピンの表示座標を解決する。
|
||||
* anchorSelector があり要素が見つかれば「要素位置 + 相対オフセット」で再計算(レスポンシブ追従)。
|
||||
* 見つからなければ保存済みの pinX%/pinY px にフォールバック。
|
||||
*/
|
||||
function resolvePinPosition(c: { pinX?: number | null; pinY?: number | null; anchorSelector?: string | null; anchorDx?: number | null; anchorDy?: number | null; }): { xPct: number; yPx: number } {
|
||||
const container = getContainer();
|
||||
const cRect = container.getBoundingClientRect();
|
||||
if (c.anchorSelector) {
|
||||
let el: Element | null = null;
|
||||
try { el = document.querySelector(c.anchorSelector); } catch { el = null; }
|
||||
if (el) {
|
||||
// オフセット無し = 要素の文字を指すピン。現在のレイアウトで測り直すので
|
||||
// 画面幅が変わって折り返しが変化しても文字に追従する。
|
||||
const base = c.anchorDx == null || c.anchorDy == null
|
||||
? textAnchorPoint(el)
|
||||
: offsetPoint(el, c.anchorDx, c.anchorDy);
|
||||
let xPct = ((base.x - cRect.left) / cRect.width) * 100;
|
||||
if (xPct < 5) xPct = 5;
|
||||
if (xPct > 95) xPct = 95;
|
||||
return { xPct, yPx: base.y - cRect.top };
|
||||
}
|
||||
}
|
||||
// コンテナの外(余白)をクリックして作られたピンは pinX が範囲外になりうる。
|
||||
// 画面幅が狭いとそのまま画面外に出て触れなくなるので、ここでも丸めておく。
|
||||
let xPct = c.pinX ?? 50;
|
||||
if (xPct < 5) xPct = 5;
|
||||
if (xPct > 95) xPct = 95;
|
||||
return { xPct, yPx: c.pinY ?? 0 };
|
||||
}
|
||||
|
||||
/** state.comments の中からピン(type==='pin' のトップレベル)を作成順で返す。 */
|
||||
export function getPins(): FbComment[] {
|
||||
return state.comments.filter((c) => c.type === 'pin' && !c.parentId);
|
||||
}
|
||||
|
||||
/** ピンの通し番号(マーカーとサイドバーカードで一致させる。作成順=配列順)。 */
|
||||
export function pinNumber(id: string): number {
|
||||
return getPins().findIndex((p) => p.id === id) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* ページ上の要素を、ピン座標系(x: コンテナ幅に対する %, y: コンテナ上端からの px)に変換する。
|
||||
* handlePinClick と同じ基準で座標を出すため、音声(voice.ts)から打つピンも手動ピンと整合する。
|
||||
* 指す先は要素の左上ではなく「文字の中央上端」。anchorFromElement と同じ基準にすること。
|
||||
*/
|
||||
export function elementToPinCoords(target: Element): { x: number; y: number } {
|
||||
const container = getContainer();
|
||||
const cRect = container.getBoundingClientRect();
|
||||
const p = textAnchorPoint(target);
|
||||
let xPct = ((p.x - cRect.left) / cRect.width) * 100;
|
||||
if (xPct < 5) xPct = 5; // 左端に寄りすぎるとピンが見切れる
|
||||
if (xPct > 95) xPct = 95;
|
||||
return { x: xPct, y: p.y - cRect.top }; // y はスクロールに依存しないコンテナ内オフセット
|
||||
}
|
||||
|
||||
export function enterPinMode(): void {
|
||||
state.pinMode = true;
|
||||
state.pinPopupPos = null;
|
||||
document.body.classList.add('fb-pin-mode');
|
||||
}
|
||||
|
||||
export function exitPinMode(): void {
|
||||
state.pinMode = false;
|
||||
state.pinPopupPos = null;
|
||||
document.body.classList.remove('fb-pin-mode');
|
||||
}
|
||||
|
||||
export function handlePinClick(e: MouseEvent): void {
|
||||
if ((e.target as HTMLElement).closest('#fb-sidebar,#fb-toggle,#fb-pin-popup,.fb-pin-marker,.fb-pin-tooltip')) return;
|
||||
|
||||
const container = getContainer();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
const relX = e.clientX - containerRect.left;
|
||||
const xPct = (relX / containerRect.width) * 100;
|
||||
const yPx = e.pageY - (containerRect.top + window.scrollY);
|
||||
|
||||
state.editingPinId = null; // 新規ピン追加(編集モードを解除)
|
||||
state.pinPopupPos = { x: xPct, y: yPx };
|
||||
pendingAnchor = captureAnchorAtPoint(e.clientX, e.clientY); // 最寄り要素を追従基準に
|
||||
}
|
||||
|
||||
/**
|
||||
* ピンを1件追加する汎用関数(手動クリック・音声解析の両方から呼ばれる)。
|
||||
* type='pin' のコメントとして state.comments に積み、DB に保存する。
|
||||
* 描画はまとめたいことがあるため、ここでは renderPins しない(呼び出し側で行う)。
|
||||
*/
|
||||
export function addPin(x: number, y: number, content: string, priority: Priority, anchor?: PinAnchor | null): FbComment {
|
||||
const c: FbComment = {
|
||||
id: generateId(),
|
||||
author: state.username || '匿名',
|
||||
type: 'pin',
|
||||
quote: '',
|
||||
quoteContext: { beforeText: '', afterText: '' },
|
||||
content: content.trim() || '(コメントなし)',
|
||||
priority,
|
||||
parentId: null,
|
||||
pageUrl: window.location.href,
|
||||
projectSlug: slug,
|
||||
timestamp: Date.now(),
|
||||
resolved: false,
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
updatedAt: null,
|
||||
pinX: x,
|
||||
pinY: y,
|
||||
anchorSelector: anchor?.selector ?? null,
|
||||
anchorDx: anchor?.dx ?? null,
|
||||
anchorDy: anchor?.dy ?? null,
|
||||
};
|
||||
state.comments.push(c);
|
||||
api('POST', c as unknown as Record<string, unknown>);
|
||||
return c;
|
||||
}
|
||||
|
||||
export function submitPinComment(content: string, priority: Priority): void {
|
||||
if (!state.pinPopupPos) return;
|
||||
addPin(state.pinPopupPos.x, state.pinPopupPos.y, content, priority, pendingAnchor);
|
||||
pendingAnchor = null;
|
||||
state.pinPopupPos = null;
|
||||
renderPins();
|
||||
}
|
||||
|
||||
export function cancelPinPopup(): void {
|
||||
state.pinPopupPos = null;
|
||||
}
|
||||
|
||||
/** ピン(と返信)を削除。DBにも反映。 */
|
||||
export function deletePin(id: string): void {
|
||||
state.comments = state.comments.filter((c) => c.id !== id && c.parentId !== id);
|
||||
api('DELETE', { id });
|
||||
renderPins();
|
||||
}
|
||||
|
||||
/** ピンの位置を更新(ドラッグ移動)。アンカーも取り直して DB に反映。 */
|
||||
export function movePin(id: string, x: number, y: number, anchor?: PinAnchor | null): void {
|
||||
const c = state.comments.find((p) => p.id === id);
|
||||
if (!c) return;
|
||||
c.pinX = x;
|
||||
c.pinY = y;
|
||||
c.anchorSelector = anchor?.selector ?? null;
|
||||
c.anchorDx = anchor?.dx ?? null;
|
||||
c.anchorDy = anchor?.dy ?? null;
|
||||
api('PUT', { id, action: 'move', pinX: x, pinY: y, anchorSelector: c.anchorSelector, anchorDx: c.anchorDx, anchorDy: c.anchorDy });
|
||||
renderPins();
|
||||
}
|
||||
|
||||
/** ピンの本文・優先度を更新(編集ポップアップの保存)。DBにも反映。 */
|
||||
export function updatePin(id: string, content: string, priority: Priority): void {
|
||||
const c = state.comments.find((p) => p.id === id);
|
||||
if (!c) return;
|
||||
c.content = content.trim() || '(コメントなし)';
|
||||
c.priority = priority;
|
||||
api('PUT', { id, action: 'edit', content: c.content, priority });
|
||||
renderPins();
|
||||
}
|
||||
|
||||
/** ピンをクリックしたとき、その場に編集ポップアップ(本文・優先度・削除)を開く。 */
|
||||
export function openPinEditor(id: string, onRender: () => void): void {
|
||||
const c = getPins().find((p) => p.id === id);
|
||||
if (!c) return;
|
||||
state.editingPinId = id;
|
||||
const pos = resolvePinPosition(c); // アンカーで再計算した現在位置にポップアップを出す
|
||||
state.pinPopupPos = { x: pos.xPct, y: pos.yPx };
|
||||
renderPinPopup(onRender);
|
||||
}
|
||||
|
||||
/**
|
||||
* ピンのドラッグ移動をセットアップする(委譲方式)。
|
||||
* sidebar のリサイズハンドル実装と同じ mousedown→mousemove→mouseup パターン。
|
||||
* ピン自体を掴む操作はフィードバックモードに関係なく受け付ける。
|
||||
* 音声コメントはモードを ON にしなくても打てるため、モードで縛ると
|
||||
* 「ピンは出たのに動かせない」状態になる。
|
||||
*/
|
||||
export function setupPinDrag(onRender: () => void): void {
|
||||
document.addEventListener('mousedown', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const marker = target.closest('.fb-pin-marker') as HTMLElement | null;
|
||||
if (!marker) return;
|
||||
const id = marker.dataset.pinId;
|
||||
if (!id) return;
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
let moved = false;
|
||||
|
||||
function onMove(ev: MouseEvent) {
|
||||
if (!moved && Math.hypot(ev.clientX - startX, ev.clientY - startY) < 6) return;
|
||||
if (!moved) {
|
||||
moved = true;
|
||||
document.body.classList.add('fb-dragging-pin');
|
||||
}
|
||||
ev.preventDefault();
|
||||
const container = getContainer();
|
||||
const cRect = container.getBoundingClientRect();
|
||||
let xPct = ((ev.clientX - cRect.left) / cRect.width) * 100;
|
||||
if (xPct < 5) xPct = 5;
|
||||
if (xPct > 95) xPct = 95;
|
||||
marker!.style.left = xPct + '%';
|
||||
marker!.style.top = (ev.clientY - cRect.top) + 'px';
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
window.removeEventListener('blur', onCancel);
|
||||
document.body.classList.remove('fb-dragging-pin');
|
||||
}
|
||||
|
||||
// ウィンドウ外でボタンを離すなどして mouseup を取り逃すと、body に
|
||||
// user-select:none が残ってページ全体が選択できなくなる。
|
||||
function onCancel() {
|
||||
const wasMoving = moved;
|
||||
cleanup();
|
||||
if (wasMoving) renderPins(); // 保存せず元の位置に戻す
|
||||
}
|
||||
|
||||
function onUp(ev: MouseEvent) {
|
||||
const wasMoving = moved;
|
||||
cleanup();
|
||||
if (!wasMoving) {
|
||||
openPinEditor(id!, onRender); // 動かさずに離した = クリック → 編集
|
||||
return;
|
||||
}
|
||||
// サイドバーやトグルの上で離した場合は移動をキャンセルして元に戻す。
|
||||
// その座標にピンは置けないので、端に丸めて別の場所へ飛ばすより予測しやすい。
|
||||
if (droppedOnWidgetUi(ev.clientX, ev.clientY)) {
|
||||
renderPins();
|
||||
return;
|
||||
}
|
||||
const container = getContainer();
|
||||
const cRect = container.getBoundingClientRect();
|
||||
let xPct = ((ev.clientX - cRect.left) / cRect.width) * 100;
|
||||
if (xPct < 5) xPct = 5;
|
||||
if (xPct > 95) xPct = 95;
|
||||
const yPx = ev.clientY - cRect.top;
|
||||
const anchor = captureAnchorAtPoint(ev.clientX, ev.clientY); // 落とした先の要素を新しい追従基準に
|
||||
movePin(id!, xPct, yPx, anchor);
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
window.addEventListener('blur', onCancel);
|
||||
});
|
||||
}
|
||||
|
||||
export function renderPins(): void {
|
||||
document.querySelectorAll('.fb-pin-marker').forEach((el) => el.remove());
|
||||
|
||||
const container = getContainer();
|
||||
if (getComputedStyle(container).position === 'static') {
|
||||
container.style.position = 'relative';
|
||||
}
|
||||
|
||||
// まず各ピンの表示座標を解決(アンカー追従。無ければ pinX/pinY フォールバック)
|
||||
const pins = getPins();
|
||||
const pos = pins.map((p) => resolvePinPosition(p));
|
||||
|
||||
// 近接ピンをクラスタ化し、重なって見えないよう横に扇状オフセットする(データは変更しない)。
|
||||
const FAN_STEP = 26; // px
|
||||
const FAN_EDGE = 16; // px。マーカーの半径ぶんコンテナの内側に留める
|
||||
const cWidth = container.getBoundingClientRect().width;
|
||||
const clusters: { x: number; y: number; items: number[] }[] = [];
|
||||
pos.forEach((p, i) => {
|
||||
let cl = clusters.find((c) => Math.abs(c.x - p.xPct) < 2 && Math.abs(c.y - p.yPx) < 24);
|
||||
if (!cl) { cl = { x: p.xPct, y: p.yPx, items: [] }; clusters.push(cl); }
|
||||
cl.items.push(i);
|
||||
});
|
||||
const fanOffset: Record<number, number> = {};
|
||||
clusters.forEach((c) => {
|
||||
const n = c.items.length;
|
||||
c.items.forEach((idx, k) => { fanOffset[idx] = (k - (n - 1) / 2) * FAN_STEP; });
|
||||
});
|
||||
|
||||
pins.forEach((comment, idx) => {
|
||||
const pin = document.createElement('div');
|
||||
pin.className = 'fb-pin-marker' + (comment.resolved ? ' resolved' : '');
|
||||
pin.dataset.pinId = comment.id;
|
||||
|
||||
const pc = PRIORITY_COLORS[comment.priority] || PRIORITY_COLORS.want;
|
||||
const xPct = pos[idx].xPct;
|
||||
// 扇状オフセットでコンテナの外にはみ出すと、狭い画面ではピンが画面外に出て
|
||||
// 触れなくなる。はみ出す分は内側に押し戻す。
|
||||
const basePx = (xPct / 100) * cWidth;
|
||||
const spread = Math.min(Math.max(basePx + (fanOffset[idx] || 0), FAN_EDGE), cWidth - FAN_EDGE);
|
||||
const off = Math.round(spread - basePx);
|
||||
// 横オフセットは calc で left に加算(transform は維持されるのでホバー拡大も効く)
|
||||
pin.style.left = off ? 'calc(' + xPct + '% + ' + off + 'px)' : xPct + '%';
|
||||
pin.style.top = pos[idx].yPx + 'px';
|
||||
pin.style.setProperty('--pin-color', pc.bg);
|
||||
|
||||
pin.innerHTML = '<div class="fb-pin-icon" style="background:' + pc.bg + '">' + (idx + 1) + '</div>';
|
||||
|
||||
// ホバー吹き出しは「内容の確認専用」。編集・削除・優先度はピンをクリックして開く編集ポップアップで。
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = 'fb-pin-tooltip';
|
||||
const priLabel = comment.priority.charAt(0).toUpperCase() + comment.priority.slice(1);
|
||||
const badge = '<span style="display:inline-block;padding:1px 6px;border-radius:4px;font-size:10px;font-weight:700;color:#fff;background:' + pc.bg + '">' + priLabel + '</span>';
|
||||
const hint = '<div style="font-size:11px;color:#a3a3a3;margin-top:6px">クリックで編集 / ドラッグで移動</div>';
|
||||
tooltip.innerHTML = '<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">' + badge + '<span style="font-size:11px;color:#737373">' + esc(comment.author) + '</span></div><div style="font-size:13px;color:#0a0a0a;white-space:pre-wrap">' + esc(comment.content) + '</div>' + hint;
|
||||
|
||||
pin.appendChild(tooltip);
|
||||
container.appendChild(pin);
|
||||
});
|
||||
}
|
||||
|
||||
export function renderPinPopup(onRender: () => void): void {
|
||||
let popup = document.getElementById('fb-pin-popup');
|
||||
if (!popup) {
|
||||
popup = document.createElement('div');
|
||||
popup.id = 'fb-pin-popup';
|
||||
popup.className = 'fb-pin-popup';
|
||||
document.body.appendChild(popup);
|
||||
}
|
||||
|
||||
if (!state.pinPopupPos) {
|
||||
popup.classList.remove('show');
|
||||
return;
|
||||
}
|
||||
|
||||
popup.classList.add('show');
|
||||
|
||||
// 編集対象ピン(あれば編集モード、なければ新規追加モード)
|
||||
const editing = state.editingPinId
|
||||
? getPins().find((p) => p.id === state.editingPinId) || null
|
||||
: null;
|
||||
|
||||
const container = getContainer();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const absX = (state.pinPopupPos.x / 100) * containerRect.width + containerRect.left;
|
||||
const absY = state.pinPopupPos.y + containerRect.top + window.scrollY;
|
||||
|
||||
let h = '<div class="fb-pin-popup-head">' + (editing ? 'コメントを編集' : 'コメントを追加') + '</div>';
|
||||
h += '<textarea id="fb-pin-textarea" placeholder="コメントを入力...(優先度を押すと' + (editing ? '保存' : '送信') + ')"></textarea>';
|
||||
h += '<div class="fb-pin-popup-pri">';
|
||||
(['must', 'better', 'want'] as const).forEach((p) => {
|
||||
const pc = PRIORITY_COLORS[p];
|
||||
h += '<button class="fb-pin-pri-btn" data-pri="' + p + '" style="background:' + pc.bg + ';color:#fff;border:none">' + p.charAt(0).toUpperCase() + p.slice(1) + '</button>';
|
||||
});
|
||||
h += '</div>';
|
||||
// 送信/キャンセルは撤去(優先度ボタンで送信、ESC/外側クリックでキャンセル)。編集時のみ削除ボタンを残す。
|
||||
if (editing) {
|
||||
h += '<div class="fb-pin-popup-actions"><button class="fb-pin-delete-btn">削除</button></div>';
|
||||
}
|
||||
|
||||
popup.innerHTML = h;
|
||||
|
||||
const pw = 300, m = 8;
|
||||
let top = absY + 24 - window.scrollY;
|
||||
if (top + 220 > window.innerHeight) top = absY - 220 - m - window.scrollY;
|
||||
let left = absX - pw / 2;
|
||||
if (left < m) left = m;
|
||||
if (left + pw > window.innerWidth - m) left = window.innerWidth - pw - m;
|
||||
|
||||
popup.style.position = 'fixed';
|
||||
popup.style.top = top + 'px';
|
||||
popup.style.left = left + 'px';
|
||||
|
||||
const textarea = popup.querySelector('#fb-pin-textarea') as HTMLTextAreaElement;
|
||||
textarea.value = editing ? editing.content : '';
|
||||
setTimeout(() => textarea?.focus(), 50);
|
||||
|
||||
const priBtns = popup.querySelectorAll('.fb-pin-pri-btn');
|
||||
let selectedPri: Priority = editing ? editing.priority : 'better';
|
||||
const paintPri = () => priBtns.forEach((b) => {
|
||||
(b as HTMLElement).style.opacity = (b as HTMLElement).dataset.pri === selectedPri ? '1' : '0.4';
|
||||
});
|
||||
priBtns.forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
selectedPri = (btn as HTMLElement).dataset.pri as Priority;
|
||||
commit(); // 優先度を押した時点で即送信/保存(ワンクリック)
|
||||
});
|
||||
});
|
||||
paintPri();
|
||||
|
||||
const close = () => {
|
||||
state.editingPinId = null;
|
||||
state.pinPopupPos = null;
|
||||
popup!.classList.remove('show');
|
||||
onRender();
|
||||
};
|
||||
|
||||
const commit = () => {
|
||||
if (editing) {
|
||||
updatePin(editing.id, textarea.value, selectedPri);
|
||||
} else {
|
||||
submitPinComment(textarea.value, selectedPri);
|
||||
}
|
||||
close();
|
||||
};
|
||||
|
||||
if (editing) {
|
||||
popup.querySelector('.fb-pin-delete-btn')!.addEventListener('click', () => {
|
||||
deletePin(editing.id);
|
||||
close();
|
||||
});
|
||||
}
|
||||
|
||||
textarea.addEventListener('keydown', (ev) => {
|
||||
if ((ev.metaKey || ev.ctrlKey) && ev.key === 'Enter') commit();
|
||||
});
|
||||
}
|
||||
83
src/widget/render/card.ts
Normal file
83
src/widget/render/card.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { PRIORITY_COLORS } from '../../shared/constants';
|
||||
import { fmtTime } from '../../shared/time';
|
||||
import { esc } from '../dom';
|
||||
import { icon } from '../icons';
|
||||
import { state, type FbComment } from '../state';
|
||||
import { pinNumber } from '../pin';
|
||||
|
||||
export function renderCard(c: FbComment): string {
|
||||
const isOwn = c.author === state.username;
|
||||
const pc = PRIORITY_COLORS[c.priority] || PRIORITY_COLORS.want;
|
||||
|
||||
let h = '<div class="fb-card' + (c.resolved ? ' resolved' : '') + '" style="border-left-color:' + pc.bg + '" data-id="' + c.id + '">';
|
||||
|
||||
h += '<div class="fb-card-head"><div class="fb-card-head-left">';
|
||||
h += '<div class="fb-avatar">' + esc(c.author.charAt(0)) + '</div>';
|
||||
h += '<span class="fb-author">' + esc(c.author) + '</span>';
|
||||
h += '<span class="fb-time">' + fmtTime(c.timestamp) + '</span>';
|
||||
if (c.resolved) h += '<span class="fb-resolved-mark">' + icon('check', 12) + ' 解決済</span>';
|
||||
h += '</div>';
|
||||
h += '<span class="fb-badge-p' + (isOwn ? ' own' : '') + '" style="background:' + pc.bg + '" data-action="cycle" data-id="' + c.id + '">' + esc(c.priority.charAt(0).toUpperCase() + c.priority.slice(1)) + '</span>';
|
||||
h += '</div>';
|
||||
|
||||
if (c.type === 'pin') {
|
||||
// ピンは引用の代わりに「📍 #N」インジケータ(クリックで該当ピンへスクロール)
|
||||
h += '<div class="fb-pin-ref" data-action="scroll-pin" data-id="' + c.id + '"><span class="fb-pin-ref-badge" style="background:' + pc.bg + '">' + pinNumber(c.id) + '</span>ピンを表示</div>';
|
||||
} else if (c.quote) {
|
||||
const q = c.quote.length > 100 ? c.quote.substring(0, 100) + '...' : c.quote;
|
||||
h += '<div class="fb-quote" style="border-left-color:' + pc.bg + '" data-action="scroll-quote" data-id="' + c.id + '">' + esc(q) + '</div>';
|
||||
}
|
||||
|
||||
if (state.editingId === c.id) {
|
||||
h += '<div class="fb-edit-area">';
|
||||
h += '<div class="fb-edit-pri">';
|
||||
(['must', 'better', 'want'] as const).forEach((p) => {
|
||||
const sel = state.editPriority === p;
|
||||
const pc2 = PRIORITY_COLORS[p];
|
||||
h += '<button data-action="set-edit-pri" data-pri="' + p + '" style="' + (sel ? 'background:' + pc2.bg + ';color:#fff;border-color:' + pc2.bg : '') + '">' + p.charAt(0).toUpperCase() + p.slice(1) + '</button>';
|
||||
});
|
||||
h += '</div>';
|
||||
h += '<textarea data-action="edit-textarea">' + esc(state.editContent) + '</textarea>';
|
||||
h += '<div class="fb-edit-btns"><button data-action="cancel-edit">キャンセル</button><button class="save" data-action="save-edit" data-id="' + c.id + '">保存</button></div>';
|
||||
h += '</div>';
|
||||
} else {
|
||||
h += '<div class="fb-body">' + esc(c.content) + '</div>';
|
||||
}
|
||||
|
||||
if (state.editingId !== c.id) {
|
||||
h += '<div class="fb-actions">';
|
||||
h += '<button class="fb-act" data-action="reply" data-id="' + c.id + '">' + icon('message', 12) + '返信</button>';
|
||||
h += '<button class="fb-act res" data-action="resolve" data-id="' + c.id + '">' + (c.resolved ? icon('rotateCcw', 12) : icon('check', 12)) + (c.resolved ? '戻す' : '解決') + '</button>';
|
||||
if (isOwn) h += '<button class="fb-act" data-action="edit" data-id="' + c.id + '">' + icon('pencil', 12) + '編集</button>';
|
||||
if (isOwn) h += '<button class="fb-act del" data-action="delete" data-id="' + c.id + '">' + icon('trash', 12) + '削除</button>';
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
const replies = state.comments.filter((r) => r.parentId === c.id).sort((a, b) => a.timestamp - b.timestamp);
|
||||
if (replies.length > 0) {
|
||||
h += '<div class="fb-replies">';
|
||||
replies.forEach((r) => {
|
||||
const isOwnReply = r.author === state.username;
|
||||
h += '<div class="fb-reply-item"><div class="fb-reply-avatar">' + esc(r.author.charAt(0)) + '</div><div style="flex:1;min-width:0"><div class="fb-reply-meta"><strong>' + esc(r.author) + '</strong> · ' + fmtTime(r.timestamp) + '</div>';
|
||||
if (state.editingId === r.id) {
|
||||
h += '<div class="fb-edit-area"><textarea data-action="edit-textarea">' + esc(state.editContent) + '</textarea><div class="fb-edit-btns"><button data-action="cancel-edit">キャンセル</button><button class="save" data-action="save-edit" data-id="' + r.id + '">保存</button></div></div>';
|
||||
} else {
|
||||
h += '<div class="fb-reply-text">' + esc(r.content) + '</div>';
|
||||
h += '<div class="fb-actions" style="margin-top:4px">';
|
||||
h += '<button class="fb-act" data-action="reply" data-id="' + c.id + '">' + icon('message', 12) + '返信</button>';
|
||||
if (isOwnReply) h += '<button class="fb-act" data-action="edit" data-id="' + r.id + '">' + icon('pencil', 12) + '編集</button>';
|
||||
if (isOwnReply) h += '<button class="fb-act del" data-action="delete-reply" data-id="' + r.id + '">' + icon('trash', 12) + '削除</button>';
|
||||
h += '</div>';
|
||||
}
|
||||
h += '</div></div>';
|
||||
});
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
if (state.replyingTo === c.id) {
|
||||
h += '<div class="fb-reply-input"><textarea placeholder="返信を入力..." data-action="reply-textarea">' + esc(state.replyText) + '</textarea><button data-action="submit-reply" data-id="' + c.id + '"' + (state.replyText.trim() ? '' : ' disabled') + '>送信</button></div>';
|
||||
}
|
||||
|
||||
h += '</div>';
|
||||
return h;
|
||||
}
|
||||
22
src/widget/render/index.ts
Normal file
22
src/widget/render/index.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import type { Priority } from '../../shared/types';
|
||||
import { renderToggle } from './toggle';
|
||||
import { renderSidebar, toggleSidebar } from './sidebar';
|
||||
import { renderPopup } from './popup';
|
||||
import { renderNameDialog } from './name-dialog';
|
||||
|
||||
let _closePopup: (() => void) | null = null;
|
||||
let _submitComment: ((priority: Priority) => void) | null = null;
|
||||
|
||||
export function setRenderDeps(closePopup: () => void, submitComment: (priority: Priority) => void): void {
|
||||
_closePopup = closePopup;
|
||||
_submitComment = submitComment;
|
||||
}
|
||||
|
||||
export function render(): void {
|
||||
renderToggle(toggleSidebar);
|
||||
renderSidebar(render);
|
||||
renderPopup(render, _closePopup!, _submitComment!);
|
||||
renderNameDialog(render);
|
||||
}
|
||||
|
||||
export { toggleSidebar };
|
||||
26
src/widget/render/name-dialog.ts
Normal file
26
src/widget/render/name-dialog.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { USERNAME_KEY } from '../../shared/constants';
|
||||
import { el } from '../dom';
|
||||
import { state } from '../state';
|
||||
|
||||
export function renderNameDialog(onRender: () => void): void {
|
||||
const existing = document.getElementById('fb-name-overlay');
|
||||
if (state.username) { if (existing) existing.remove(); return; }
|
||||
if (existing) return;
|
||||
const overlay = el('div', { id: 'fb-name-overlay', className: 'fb-name-overlay' });
|
||||
overlay.innerHTML = '<div class="fb-name-box"><h2>ようこそ</h2><p>コメントに表示される名前を入力してください。</p><input placeholder="例: 田中太郎" id="fb-name-field"><button id="fb-name-submit" disabled>始める</button></div>';
|
||||
document.body.appendChild(overlay);
|
||||
const input = document.getElementById('fb-name-field') as HTMLInputElement;
|
||||
const btn = document.getElementById('fb-name-submit') as HTMLButtonElement;
|
||||
input.addEventListener('input', () => { btn.disabled = !input.value.trim(); });
|
||||
input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && input.value.trim()) { setName(input.value.trim(), onRender); } });
|
||||
btn.addEventListener('click', () => { if (input.value.trim()) setName(input.value.trim(), onRender); });
|
||||
setTimeout(() => { input.focus(); }, 100);
|
||||
}
|
||||
|
||||
function setName(name: string, onRender: () => void): void {
|
||||
state.username = name;
|
||||
localStorage.setItem(USERNAME_KEY, name);
|
||||
const overlay = document.getElementById('fb-name-overlay');
|
||||
if (overlay) overlay.remove();
|
||||
onRender();
|
||||
}
|
||||
54
src/widget/render/popup.ts
Normal file
54
src/widget/render/popup.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import type { Priority } from '../../shared/types';
|
||||
import { PRIORITY_COLORS } from '../../shared/constants';
|
||||
import { el, esc } from '../dom';
|
||||
import { state } from '../state';
|
||||
|
||||
export function renderPopup(onRender: () => void, closePopup: () => void, submitComment: (priority: Priority) => void): void {
|
||||
let popup = document.getElementById('fb-popup');
|
||||
const isNew = !popup;
|
||||
if (isNew) {
|
||||
popup = el('div', { id: 'fb-popup', className: 'fb-popup' });
|
||||
document.body.appendChild(popup);
|
||||
popup.addEventListener('click', (e) => {
|
||||
const t = (e.target as HTMLElement).closest('[data-action]') as HTMLElement | null;
|
||||
if (!t) return;
|
||||
if (t.dataset.action === 'cancel-popup') { closePopup(); }
|
||||
// 優先度ボタンを押した時点で、その優先度で即送信(ワンクリック)
|
||||
else if (t.dataset.action === 'set-popup-pri') { state.popupPriority = t.dataset.pri as Priority; submitComment(state.popupPriority); }
|
||||
else if (t.dataset.action === 'submit-popup') { submitComment(state.popupPriority); }
|
||||
});
|
||||
popup.addEventListener('input', (e) => {
|
||||
if ((e.target as HTMLElement).dataset.action === 'popup-textarea') state.popupContent = (e.target as HTMLTextAreaElement).value;
|
||||
});
|
||||
popup.addEventListener('keydown', (e) => {
|
||||
if ((e.target as HTMLElement).dataset.action === 'popup-textarea' && (e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
submitComment(state.popupPriority);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!state.selectedRect) { popup!.classList.remove('show'); return; }
|
||||
popup!.classList.add('show');
|
||||
|
||||
const q = state.selectedText.length > 120 ? state.selectedText.substring(0, 120) + '...' : state.selectedText;
|
||||
let h = '<div class="fb-popup-head"><span>コメントを追加</span></div>';
|
||||
h += '<div class="fb-popup-quote">' + esc(q) + '</div>';
|
||||
h += '<textarea placeholder="フィードバックを入力...(優先度を押すと送信)" data-action="popup-textarea">' + esc(state.popupContent) + '</textarea>';
|
||||
// ワンポチモードと同じ「ソリッド色=押すと送信」の優先度ボタン
|
||||
h += '<div class="fb-popup-pri">';
|
||||
(['must', 'better', 'want'] as const).forEach((p) => {
|
||||
const pc = PRIORITY_COLORS[p];
|
||||
h += '<button class="fb-pin-pri-btn" style="background:' + pc.bg + ';color:#fff;border:none" data-action="set-popup-pri" data-pri="' + p + '">' + p.charAt(0).toUpperCase() + p.slice(1) + '</button>';
|
||||
});
|
||||
h += '</div>';
|
||||
popup!.innerHTML = h;
|
||||
|
||||
const rect = state.selectedRect;
|
||||
const pw = 320, ph = 230, m = 12;
|
||||
let top = rect.bottom + m;
|
||||
if (top + ph > window.innerHeight) top = rect.top - ph - m;
|
||||
if (top < m) top = Math.max(m, (window.innerHeight - ph) / 2);
|
||||
const availW = state.sidebarOpen ? window.innerWidth - state.sidebarWidth : window.innerWidth;
|
||||
const left = Math.max(m, Math.min(rect.left, availW - pw - m));
|
||||
popup!.style.top = top + 'px';
|
||||
popup!.style.left = left + 'px';
|
||||
}
|
||||
201
src/widget/render/sidebar.ts
Normal file
201
src/widget/render/sidebar.ts
Normal file
@ -0,0 +1,201 @@
|
||||
import type { Priority, FilterMode } from '../../shared/types';
|
||||
import { SIDEBAR_WIDTH_KEY } from '../../shared/constants';
|
||||
import { el, esc } from '../dom';
|
||||
import { icon } from '../icons';
|
||||
import { state } from '../state';
|
||||
import { renderCard } from './card';
|
||||
|
||||
function applySidebarWidth(): void {
|
||||
const sb = document.getElementById('fb-sidebar');
|
||||
if (!sb) return;
|
||||
const w = state.sidebarWidth;
|
||||
sb.style.width = w + 'px';
|
||||
sb.style.right = state.sidebarOpen ? '0px' : (-(w + 20)) + 'px';
|
||||
document.body.style.marginRight = state.sidebarOpen ? w + 'px' : '';
|
||||
}
|
||||
|
||||
export function toggleSidebar(): void {
|
||||
state.sidebarOpen = !state.sidebarOpen;
|
||||
const btn = document.getElementById('fb-toggle');
|
||||
if (btn) btn.style.display = state.sidebarOpen ? 'none' : '';
|
||||
document.body.style.transition = 'margin-right 0.3s ease';
|
||||
applySidebarWidth();
|
||||
}
|
||||
|
||||
function topLevel() { return state.comments.filter((c) => !c.parentId); }
|
||||
|
||||
function filtered() {
|
||||
const tl = topLevel();
|
||||
if (state.filter === 'resolved') return tl.filter((c) => c.resolved);
|
||||
if (state.filter === 'all') return tl;
|
||||
return tl.filter((c) => !c.resolved);
|
||||
}
|
||||
|
||||
export function renderSidebar(onRender: () => void): void {
|
||||
let sb = document.getElementById('fb-sidebar');
|
||||
const isNew = !sb;
|
||||
if (isNew) {
|
||||
sb = el('div', { id: 'fb-sidebar' });
|
||||
document.body.appendChild(sb);
|
||||
sb.addEventListener('mouseover', (e) => {
|
||||
const card = (e.target as HTMLElement).closest('.fb-card') as HTMLElement | null;
|
||||
if (!card) return;
|
||||
const mark = document.querySelector('.fb-highlight[data-comment-id="' + card.dataset.id + '"]');
|
||||
if (mark) mark.classList.add('fb-pulse');
|
||||
});
|
||||
sb.addEventListener('mouseout', (e) => {
|
||||
const card = (e.target as HTMLElement).closest('.fb-card') as HTMLElement | null;
|
||||
if (!card) return;
|
||||
if (e.relatedTarget && card.contains(e.relatedTarget as Node)) return;
|
||||
const mark = document.querySelector('.fb-highlight[data-comment-id="' + card.dataset.id + '"]');
|
||||
if (mark) mark.classList.remove('fb-pulse');
|
||||
});
|
||||
}
|
||||
applySidebarWidth();
|
||||
|
||||
const tl = topLevel();
|
||||
const counts = {
|
||||
unresolved: tl.filter((c) => !c.resolved).length,
|
||||
resolved: tl.filter((c) => c.resolved).length,
|
||||
all: tl.length,
|
||||
};
|
||||
|
||||
let html = '<div class="fb-resize-handle"></div>';
|
||||
|
||||
html += '<div class="fb-header">';
|
||||
html += '<div class="fb-header-left"><span class="fb-header-title">コメント</span><span class="fb-header-count">' + counts.all + '</span></div>';
|
||||
html += '<div class="fb-header-actions">';
|
||||
html += '<button class="fb-hdr-btn" data-action="close" title="閉じる">' + icon('x', 18) + '</button>';
|
||||
html += '</div></div>';
|
||||
|
||||
if (state.username) {
|
||||
if (state.editingName) {
|
||||
html += '<div class="fb-user-row"><input class="fb-name-input" value="' + esc(state.nameInput) + '" data-action="name-input" autofocus></div>';
|
||||
} else {
|
||||
html += '<div class="fb-user-row" data-action="edit-name">' + icon('user', 14) + esc(state.username) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
html += '<div class="fb-filters">';
|
||||
(['unresolved', 'resolved', 'all'] as const).forEach((f) => {
|
||||
const label = f === 'unresolved' ? '未解決' : f === 'resolved' ? '解決済' : 'すべて';
|
||||
html += '<button class="fb-filter' + (state.filter === f ? ' active' : '') + '" data-filter="' + f + '">' + label + '<span class="cnt">' + counts[f] + '</span></button>';
|
||||
});
|
||||
html += '</div>';
|
||||
|
||||
const items = filtered().sort((a, b) => b.timestamp - a.timestamp);
|
||||
html += '<div class="fb-list">';
|
||||
if (items.length === 0) {
|
||||
html += '<div class="fb-empty">' + icon('message', 40) + 'コメントはまだありません<br><span style="font-size:11px">テキストを選択してコメントを追加</span></div>';
|
||||
} else {
|
||||
items.forEach((c) => { html += renderCard(c); });
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
sb!.innerHTML = html;
|
||||
if (isNew) bindSidebarEvents(sb!, onRender);
|
||||
}
|
||||
|
||||
export interface SidebarActions {
|
||||
toggleSidebar: () => void;
|
||||
cyclePriority: (id: string) => void;
|
||||
scrollToQuote: (id: string) => void;
|
||||
scrollToPin: (id: string) => void;
|
||||
resolveComment: (id: string) => void;
|
||||
deleteComment: (id: string) => void;
|
||||
deleteReply: (id: string) => void;
|
||||
saveEdit: (id: string) => void;
|
||||
submitReply: (id: string) => void;
|
||||
finishNameEdit: () => void;
|
||||
}
|
||||
|
||||
let _actions: SidebarActions | null = null;
|
||||
|
||||
export function setSidebarActions(actions: SidebarActions): void {
|
||||
_actions = actions;
|
||||
}
|
||||
|
||||
function bindSidebarEvents(sb: HTMLElement, onRender: () => void): void {
|
||||
sb.addEventListener('click', (e) => {
|
||||
const t = (e.target as HTMLElement).closest('[data-action]') as HTMLElement | null;
|
||||
if (t) {
|
||||
const action = t.dataset.action;
|
||||
const id = t.dataset.id;
|
||||
|
||||
if (action === 'close') { _actions?.toggleSidebar(); }
|
||||
else if (action === 'edit-name') { state.editingName = true; state.nameInput = state.username; onRender(); }
|
||||
else if (action === 'cycle' && id) { _actions?.cyclePriority(id); }
|
||||
else if (action === 'scroll-quote' && id) { _actions?.scrollToQuote(id); }
|
||||
else if (action === 'scroll-pin' && id) { _actions?.scrollToPin(id); }
|
||||
else if (action === 'reply' && id) { state.replyingTo = state.replyingTo === id ? null : id; state.replyText = ''; onRender(); }
|
||||
else if (action === 'resolve' && id) { _actions?.resolveComment(id); }
|
||||
else if (action === 'edit' && id) { const c = state.comments.find((x) => x.id === id); if (c) { state.editingId = id; state.editContent = c.content; state.editPriority = c.priority; onRender(); } }
|
||||
else if (action === 'delete' && id) { _actions?.deleteComment(id); }
|
||||
else if (action === 'delete-reply' && id) { _actions?.deleteReply(id); }
|
||||
else if (action === 'cancel-edit') { state.editingId = null; onRender(); }
|
||||
else if (action === 'save-edit' && id) { _actions?.saveEdit(id); }
|
||||
else if (action === 'submit-reply' && id) { _actions?.submitReply(id); }
|
||||
else if (action === 'set-edit-pri') { state.editPriority = t.dataset.pri as Priority; onRender(); }
|
||||
return;
|
||||
}
|
||||
const filterBtn = (e.target as HTMLElement).closest('[data-filter]') as HTMLElement | null;
|
||||
if (filterBtn) { state.filter = filterBtn.dataset.filter as FilterMode; onRender(); return; }
|
||||
|
||||
const card = (e.target as HTMLElement).closest('.fb-card') as HTMLElement | null;
|
||||
if (card && card.dataset.id) {
|
||||
const cm = state.comments.find((x) => x.id === card.dataset.id);
|
||||
if (cm?.type === 'pin') _actions?.scrollToPin(card.dataset.id);
|
||||
else _actions?.scrollToQuote(card.dataset.id);
|
||||
}
|
||||
});
|
||||
|
||||
sb.addEventListener('mousedown', (e) => {
|
||||
if (!(e.target as HTMLElement).closest('.fb-resize-handle')) return;
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startWidth = state.sidebarWidth;
|
||||
const handle = (e.target as HTMLElement).closest('.fb-resize-handle')!;
|
||||
document.body.classList.add('fb-resizing');
|
||||
handle.classList.add('active');
|
||||
function onMove(ev: MouseEvent) {
|
||||
state.sidebarWidth = Math.min(800, Math.max(300, startWidth + (startX - ev.clientX)));
|
||||
const s = document.getElementById('fb-sidebar');
|
||||
if (s) s.style.width = state.sidebarWidth + 'px';
|
||||
document.body.style.transition = 'none';
|
||||
document.body.style.marginRight = state.sidebarWidth + 'px';
|
||||
}
|
||||
function onUp() {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
document.body.classList.remove('fb-resizing');
|
||||
handle.classList.remove('active');
|
||||
document.body.style.transition = '';
|
||||
localStorage.setItem(SIDEBAR_WIDTH_KEY, String(state.sidebarWidth));
|
||||
}
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
});
|
||||
|
||||
sb.addEventListener('input', (e) => {
|
||||
const t = e.target as HTMLInputElement;
|
||||
if (t.dataset.action === 'name-input') { state.nameInput = t.value; }
|
||||
else if (t.dataset.action === 'edit-textarea') { state.editContent = t.value; }
|
||||
else if (t.dataset.action === 'reply-textarea') { state.replyText = t.value; const btn = sb.querySelector('[data-action="submit-reply"]') as HTMLButtonElement | null; if (btn) btn.disabled = !state.replyText.trim(); }
|
||||
});
|
||||
|
||||
sb.addEventListener('keydown', (e) => {
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.dataset.action === 'name-input') {
|
||||
if (e.key === 'Enter') { _actions?.finishNameEdit(); }
|
||||
else if (e.key === 'Escape') { state.editingName = false; onRender(); }
|
||||
}
|
||||
if (t.dataset.action === 'reply-textarea' && (e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
const id = (sb.querySelector('[data-action="submit-reply"]') as HTMLElement | null)?.dataset.id;
|
||||
if (id) _actions?.submitReply(id);
|
||||
}
|
||||
});
|
||||
|
||||
sb.addEventListener('blur', (e) => {
|
||||
if ((e.target as HTMLElement).dataset?.action === 'name-input') { _actions?.finishNameEdit(); }
|
||||
}, true);
|
||||
}
|
||||
53
src/widget/render/toggle.ts
Normal file
53
src/widget/render/toggle.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { el } from '../dom';
|
||||
import { icon } from '../icons';
|
||||
import { state } from '../state';
|
||||
|
||||
let _onToggleArea: (() => void) | null = null;
|
||||
let _onVoice: (() => void) | null = null;
|
||||
|
||||
export function setToggleAreaHandler(handler: () => void): void {
|
||||
_onToggleArea = handler;
|
||||
}
|
||||
|
||||
export function setToggleVoiceHandler(handler: () => void): void {
|
||||
_onVoice = handler;
|
||||
}
|
||||
|
||||
export function renderToggle(toggleSidebar: () => void): void {
|
||||
let btn = document.getElementById('fb-toggle');
|
||||
if (!btn) {
|
||||
btn = el('button', { id: 'fb-toggle' });
|
||||
btn.addEventListener('click', (e) => {
|
||||
const target = (e.target as HTMLElement).closest('[data-action]') as HTMLElement | null;
|
||||
if (target?.dataset.action === 'area-mode') {
|
||||
_onToggleArea?.();
|
||||
} else if (target?.dataset.action === 'voice') {
|
||||
_onVoice?.();
|
||||
} else {
|
||||
toggleSidebar();
|
||||
}
|
||||
});
|
||||
document.body.appendChild(btn);
|
||||
}
|
||||
const unresolvedCount = state.comments.filter((c) => !c.parentId && !c.resolved).length;
|
||||
const pinCount = state.comments.filter((c) => c.type === 'pin' && !c.parentId).length;
|
||||
let h = '<span class="fb-toggle-icon">' + icon('panelRight', 16);
|
||||
if (unresolvedCount > 0) h += '<span class="fb-badge">' + unresolvedCount + '</span>';
|
||||
h += '</span>';
|
||||
const pinStyle = state.pinMode ? 'color:var(--fb-accent);background:rgba(59,130,246,0.1);border-radius:4px' : '';
|
||||
const pinTitle = state.pinMode
|
||||
? 'フィードバックモード ON(クリックで終了)/クリック=ピン・ドラッグ=引用'
|
||||
: 'フィードバックモード OFF(クリックで開始)';
|
||||
h += '<span class="fb-toggle-pin" data-action="area-mode" style="' + pinStyle + '" title="' + pinTitle + '">'
|
||||
+ '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v10"/><circle cx="12" cy="14" r="2"/><path d="M12 16v6"/></svg>';
|
||||
if (pinCount > 0) h += '<span style="font-size:9px;font-weight:700;color:var(--fb-accent);margin-left:1px">' + pinCount + '</span>';
|
||||
h += '</span>';
|
||||
const voiceCls = state.voiceRecording ? ' recording' : state.voiceProcessing ? ' processing' : '';
|
||||
const voiceTitle = state.voiceRecording ? '録音中(クリックで停止して解析)' : state.voiceProcessing ? '解析中...' : '音声でフィードバック';
|
||||
// 解析中はマイクではなくスピナー(円形ローダー)を回す
|
||||
const voiceIcon = state.voiceProcessing ? icon('loader', 14) : icon('mic', 14);
|
||||
h += '<span class="fb-toggle-voice' + voiceCls + '" data-action="voice" title="' + voiceTitle + '">' + voiceIcon + '</span>';
|
||||
h += '<span class="fb-toggle-label">コメント</span>';
|
||||
btn.innerHTML = h;
|
||||
btn.style.display = state.sidebarOpen ? 'none' : '';
|
||||
}
|
||||
40
src/widget/scroll.ts
Normal file
40
src/widget/scroll.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import type { Priority } from '../shared/types';
|
||||
import { state } from './state';
|
||||
|
||||
const PRIORITY_FLASH: Record<Priority, string> = {
|
||||
must: 'rgba(239,68,68,0.4)',
|
||||
better: 'rgba(245,158,11,0.4)',
|
||||
want: 'rgba(34,197,94,0.4)',
|
||||
};
|
||||
|
||||
export function scrollToQuote(id: string): void {
|
||||
const mark = document.querySelector('.fb-highlight[data-comment-id="' + id + '"]') as HTMLElement | null;
|
||||
if (!mark) return;
|
||||
mark.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
const c = state.comments.find((x) => x.id === id);
|
||||
const flashColor = c ? (PRIORITY_FLASH[c.priority] || PRIORITY_FLASH.want) : PRIORITY_FLASH.want;
|
||||
const orig = mark.style.backgroundColor;
|
||||
mark.style.backgroundColor = flashColor;
|
||||
mark.style.transition = 'background-color 0.3s';
|
||||
setTimeout(() => { mark.style.backgroundColor = orig; }, 1500);
|
||||
}
|
||||
|
||||
export function scrollToPin(id: string): void {
|
||||
const marker = document.querySelector('.fb-pin-marker[data-pin-id="' + id + '"]') as HTMLElement | null;
|
||||
if (!marker) return;
|
||||
marker.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
marker.classList.add('fb-pin-flash');
|
||||
setTimeout(() => { marker.classList.remove('fb-pin-flash'); }, 1500);
|
||||
}
|
||||
|
||||
export function scrollToCard(id: string, toggleSidebar: () => void): void {
|
||||
const wasClosed = !state.sidebarOpen;
|
||||
if (wasClosed) { toggleSidebar(); }
|
||||
setTimeout(() => {
|
||||
const card = document.querySelector('.fb-card[data-id="' + id + '"]') as HTMLElement | null;
|
||||
if (!card) return;
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
card.classList.add('fb-focused');
|
||||
setTimeout(() => { card.classList.remove('fb-focused'); }, 1500);
|
||||
}, wasClosed ? 350 : 0);
|
||||
}
|
||||
36
src/widget/selection.ts
Normal file
36
src/widget/selection.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { state, type QuoteContext } from './state';
|
||||
|
||||
export function getQuoteContext(range: Range): QuoteContext {
|
||||
let before = '', after = '';
|
||||
try {
|
||||
const br = document.createRange();
|
||||
br.setStart(document.body, 0);
|
||||
br.setEnd(range.startContainer, range.startOffset);
|
||||
before = br.toString().slice(-50).replace(/[\s\u00A0]+/g, ' ').trim();
|
||||
const ar = document.createRange();
|
||||
ar.setStart(range.endContainer, range.endOffset);
|
||||
ar.setEnd(document.body, document.body.childNodes.length);
|
||||
after = ar.toString().slice(0, 50).replace(/[\s\u00A0]+/g, ' ').trim();
|
||||
} catch (_) { /* ignore */ }
|
||||
return { beforeText: before, afterText: after };
|
||||
}
|
||||
|
||||
export function setupTextSelection(onRender: () => void, closePopup: () => void): void {
|
||||
document.addEventListener('mouseup', (e) => {
|
||||
// テキスト選択は FB モードに関係なく常に有効(ドラッグ選択は意図的なので邪魔にならない)
|
||||
if ((e.target as HTMLElement).closest('#fb-sidebar,#fb-toggle,#fb-popup,#fb-name-overlay')) return;
|
||||
const sel = window.getSelection();
|
||||
const text = sel?.toString().trim();
|
||||
if (!text || !sel || sel.rangeCount === 0) return; // 名前未設定でも可(送信時に匿名で扱う)
|
||||
const range = sel.getRangeAt(0);
|
||||
state.selectedText = text.replace(/[\s\u00A0]+/g, ' ').substring(0, 200);
|
||||
state.selectedRect = range.getBoundingClientRect();
|
||||
state.selectedQuoteContext = getQuoteContext(range);
|
||||
state.popupContent = '';
|
||||
onRender();
|
||||
});
|
||||
document.addEventListener('mousedown', (e) => {
|
||||
if ((e.target as HTMLElement).closest('#fb-popup')) return;
|
||||
if (state.selectedRect) closePopup();
|
||||
});
|
||||
}
|
||||
85
src/widget/state.ts
Normal file
85
src/widget/state.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import type { Priority, FilterMode } from '../shared/types';
|
||||
import { USERNAME_KEY, SIDEBAR_WIDTH_KEY } from '../shared/constants';
|
||||
import { slugify } from '../shared/slug';
|
||||
|
||||
export interface QuoteContext {
|
||||
beforeText: string;
|
||||
afterText: string;
|
||||
}
|
||||
|
||||
export interface FbComment {
|
||||
id: string;
|
||||
author: string;
|
||||
type: string; // 'comment' | 'pin' など
|
||||
quote: string;
|
||||
quoteContext: QuoteContext;
|
||||
content: string;
|
||||
priority: Priority;
|
||||
parentId: string | null;
|
||||
resolved: boolean;
|
||||
resolvedBy: string | null;
|
||||
resolvedAt: number | null;
|
||||
timestamp: number;
|
||||
updatedAt: number | null;
|
||||
pageUrl: string;
|
||||
projectSlug?: string;
|
||||
pinX?: number | null; // type==='pin' のときコンテナ幅に対する%(フォールバック)
|
||||
pinY?: number | null; // type==='pin' のときコンテナ上端からのpx(フォールバック)
|
||||
anchorSelector?: string | null; // 追従基準の最寄り要素セレクタ
|
||||
anchorDx?: number | null; // 基準要素左上からの相対px(横)
|
||||
anchorDy?: number | null; // 基準要素左上からの相対px(縦)
|
||||
}
|
||||
|
||||
export interface WidgetState {
|
||||
username: string;
|
||||
comments: FbComment[];
|
||||
filter: FilterMode;
|
||||
sidebarOpen: boolean;
|
||||
selectedText: string;
|
||||
selectedQuoteContext: QuoteContext;
|
||||
selectedRect: DOMRect | null;
|
||||
popupContent: string;
|
||||
editingId: string | null;
|
||||
editContent: string;
|
||||
editPriority: Priority;
|
||||
replyingTo: string | null;
|
||||
replyText: string;
|
||||
editingName: boolean;
|
||||
nameInput: string;
|
||||
popupPriority: Priority;
|
||||
sidebarWidth: number;
|
||||
pinMode: boolean;
|
||||
pinPopupPos: { x: number; y: number } | null; // クリック位置(%,px)
|
||||
editingPinId: string | null; // 編集中ピンのID(null=新規ピン追加)
|
||||
voiceRecording: boolean; // 録音中
|
||||
voiceProcessing: boolean; // 音声をGeminiで解析中
|
||||
voiceError: string | null; // 直近のエラーメッセージ
|
||||
}
|
||||
|
||||
export const state: WidgetState = {
|
||||
username: localStorage.getItem(USERNAME_KEY) || '',
|
||||
comments: [],
|
||||
filter: 'unresolved',
|
||||
sidebarOpen: false,
|
||||
selectedText: '',
|
||||
selectedQuoteContext: { beforeText: '', afterText: '' },
|
||||
selectedRect: null,
|
||||
popupContent: '',
|
||||
editingId: null,
|
||||
editContent: '',
|
||||
editPriority: 'want',
|
||||
replyingTo: null,
|
||||
replyText: '',
|
||||
editingName: false,
|
||||
nameInput: '',
|
||||
popupPriority: 'must',
|
||||
sidebarWidth: parseInt(localStorage.getItem(SIDEBAR_WIDTH_KEY) || '', 10) || 400,
|
||||
pinMode: false,
|
||||
pinPopupPos: null,
|
||||
editingPinId: null,
|
||||
voiceRecording: false,
|
||||
voiceProcessing: false,
|
||||
voiceError: null,
|
||||
};
|
||||
|
||||
export const slug = slugify(window.location.href);
|
||||
202
src/widget/styles.ts
Normal file
202
src/widget/styles.ts
Normal file
@ -0,0 +1,202 @@
|
||||
export function injectStyles(): void {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'fb-widget-styles';
|
||||
// ピン型(涙型マーカー)カーソル。先端(下端中央)=ホットスポット 14,26
|
||||
const pinCursor =
|
||||
`url('data:image/svg+xml,%3Csvg%20xmlns="http://www.w3.org/2000/svg"%20width="28"%20height="28"%20viewBox="0%200%2028%2028"%3E%3Cpath%20d="M14%202a8%208%200%200%200-8%208c0%205.5%208%2016%208%2016s8-10.5%208-16a8%208%200%200%200-8-8Z"%20fill="%233b82f6"%20stroke="%23ffffff"%20stroke-width="1.6"%20stroke-linejoin="round"/%3E%3Ccircle%20cx="14"%20cy="10"%20r="3"%20fill="%23ffffff"/%3E%3C/svg%3E') 14 26, auto`;
|
||||
style.textContent = [
|
||||
':root{--fb-bg:#ffffff;--fb-fg:#0a0a0a;--fb-muted:#f5f5f5;--fb-muted-fg:#737373;--fb-border:#e5e5e5;--fb-primary:#171717;--fb-primary-fg:#fafafa;--fb-accent:#3b82f6;--fb-destructive:#ef4444;--fb-font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Hiragino Sans",sans-serif}',
|
||||
|
||||
'#fb-toggle{position:fixed;right:0;top:50%;transform:translateY(-50%);width:36px;background:var(--fb-bg);border:1px solid var(--fb-border);border-right:none;border-radius:8px 0 0 8px;cursor:pointer;z-index:99999;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;font-family:var(--fb-font);transition:all .15s;box-shadow:-2px 0 8px rgba(0,0,0,0.06);padding:8px 0}',
|
||||
'#fb-toggle:hover{background:var(--fb-muted);box-shadow:-2px 0 12px rgba(0,0,0,0.1)}',
|
||||
'#fb-toggle .fb-toggle-icon{color:var(--fb-muted-fg);display:flex;align-items:center;justify-content:center;position:relative}',
|
||||
'#fb-toggle .fb-toggle-label{font-size:10px;color:var(--fb-accent);font-weight:700;writing-mode:vertical-rl;letter-spacing:1px;line-height:1;white-space:nowrap}',
|
||||
'#fb-toggle .fb-badge{position:absolute;top:-6px;left:-6px;background:var(--fb-destructive);color:#fff;font-size:9px;font-weight:700;min-width:16px;height:16px;border-radius:8px;display:flex;align-items:center;justify-content:center;padding:0 3px;line-height:1}',
|
||||
|
||||
'#fb-sidebar{position:fixed;top:0;height:100vh;background:rgba(245,245,245,0.5);border-left:1px solid var(--fb-border);z-index:99998;transition:right .3s ease;display:flex;flex-direction:column;font-family:var(--fb-font);font-size:14px;color:var(--fb-fg)}',
|
||||
'#fb-sidebar *{box-sizing:border-box}',
|
||||
|
||||
'.fb-resize-handle{position:absolute;left:-3px;top:0;width:6px;height:100%;cursor:col-resize;z-index:1}',
|
||||
'.fb-resize-handle:hover{background:rgba(59,130,246,0.15)}',
|
||||
'.fb-resize-handle.active{background:rgba(59,130,246,0.3)}',
|
||||
'body.fb-resizing{cursor:col-resize !important;-webkit-user-select:none !important;user-select:none !important}',
|
||||
'body.fb-resizing *{cursor:col-resize !important}',
|
||||
|
||||
'.fb-header{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--fb-border)}',
|
||||
'.fb-header-left{display:flex;align-items:center;gap:8px}',
|
||||
'.fb-header-title{font-size:14px;font-weight:700;color:var(--fb-accent)}',
|
||||
'.fb-header-count{background:var(--fb-muted);color:var(--fb-muted-fg);font-size:11px;padding:1px 7px;border-radius:10px}',
|
||||
'.fb-header-actions{display:flex;align-items:center;gap:2px}',
|
||||
'.fb-hdr-btn{background:none;border:none;cursor:pointer;padding:6px;border-radius:6px;color:var(--fb-muted-fg);transition:all .15s;display:inline-flex;align-items:center;gap:4px;font-family:var(--fb-font);font-size:12px}',
|
||||
'.fb-hdr-btn:hover{background:var(--fb-muted);color:var(--fb-fg)}',
|
||||
|
||||
'.fb-user-row{display:flex;align-items:center;padding:8px 16px;border-bottom:1px solid var(--fb-border);font-size:13px;color:var(--fb-muted-fg);cursor:pointer;transition:color .15s;gap:6px}',
|
||||
'.fb-user-row:hover{color:var(--fb-fg)}',
|
||||
|
||||
'.fb-filters{display:flex;border-bottom:1px solid var(--fb-border)}',
|
||||
'.fb-filter{flex:1;padding:10px 0;text-align:center;font-size:13px;color:var(--fb-muted-fg);border:none;background:none;cursor:pointer;border-bottom:2px solid transparent;transition:all .15s;font-family:var(--fb-font)}',
|
||||
'.fb-filter:hover{color:var(--fb-fg)}',
|
||||
'.fb-filter.active{color:var(--fb-fg);border-bottom-color:var(--fb-accent)}',
|
||||
'.fb-filter .cnt{font-size:11px;margin-left:4px;padding:1px 5px;border-radius:8px;background:var(--fb-muted);color:var(--fb-muted-fg)}',
|
||||
'.fb-filter.active .cnt{background:rgba(59,130,246,0.1);color:var(--fb-accent)}',
|
||||
|
||||
'.fb-list{flex:1;overflow-y:auto;padding:8px}',
|
||||
'.fb-empty{text-align:center;padding:60px 20px;color:var(--fb-muted-fg);font-size:13px}',
|
||||
'.fb-empty svg{margin:0 auto 12px;display:block;color:var(--fb-border)}',
|
||||
|
||||
'.fb-card{background:var(--fb-bg);border:1px solid var(--fb-border);border-left:3px solid var(--fb-border);border-radius:12px;padding:14px;margin-bottom:6px;transition:box-shadow .3s,background .3s;cursor:pointer}',
|
||||
'.fb-card:hover{box-shadow:0 1px 3px rgba(0,0,0,0.05)}',
|
||||
'.fb-card.resolved{opacity:.5}',
|
||||
|
||||
'.fb-card-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}',
|
||||
'.fb-card-head-left{display:flex;align-items:center;gap:6px}',
|
||||
'.fb-avatar{width:22px;height:22px;border-radius:50%;background:var(--fb-primary);color:var(--fb-primary-fg);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;flex-shrink:0}',
|
||||
'.fb-author{font-size:13px;font-weight:700;color:var(--fb-fg)}',
|
||||
'.fb-time{font-size:11px;color:var(--fb-muted-fg)}',
|
||||
'.fb-resolved-mark{font-size:11px;color:#22c55e;margin-left:4px;display:inline-flex;align-items:center;gap:2px}',
|
||||
|
||||
'.fb-badge-p{font-size:11px;font-weight:700;padding:2px 8px;border-radius:4px;color:#fff;cursor:default;transition:all .15s}',
|
||||
'.fb-badge-p.own{cursor:pointer}',
|
||||
'.fb-badge-p.own:hover{transform:scale(1.1);box-shadow:0 0 0 2px rgba(0,0,0,0.08)}',
|
||||
|
||||
'.fb-quote{font-size:12px;color:var(--fb-muted-fg);padding:6px 10px;background:var(--fb-muted);border-left:2px solid var(--fb-primary);border-radius:0 4px 4px 0;margin-bottom:8px;font-style:italic;line-height:1.5;cursor:pointer;transition:background .15s}',
|
||||
'.fb-quote:hover{background:var(--fb-border)}',
|
||||
|
||||
'.fb-body{font-size:13px;color:var(--fb-muted-fg);line-height:1.6;margin-bottom:6px;white-space:pre-wrap}',
|
||||
|
||||
'.fb-actions{display:flex;gap:2px;flex-wrap:wrap}',
|
||||
'.fb-act{font-size:12px;color:#a3a3a3;background:none;border:none;padding:4px 8px;border-radius:4px;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:3px;font-family:var(--fb-font)}',
|
||||
'.fb-act:hover{color:var(--fb-fg)}',
|
||||
'.fb-act.del:hover{color:var(--fb-destructive)}',
|
||||
'.fb-act.res:hover{color:#22c55e}',
|
||||
|
||||
'.fb-replies{margin-top:8px;padding-top:8px;border-top:1px solid var(--fb-muted)}',
|
||||
'.fb-reply-item{display:flex;gap:8px;padding:6px 0}',
|
||||
'.fb-reply-item+.fb-reply-item{border-top:1px solid var(--fb-muted)}',
|
||||
'.fb-reply-avatar{width:20px;height:20px;border-radius:50%;background:var(--fb-primary);color:var(--fb-primary-fg);display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:700;flex-shrink:0;margin-top:2px}',
|
||||
'.fb-reply-meta{font-size:12px;color:var(--fb-muted-fg);margin-bottom:2px}',
|
||||
'.fb-reply-meta strong{color:#525252;font-weight:700}',
|
||||
'.fb-reply-text{font-size:13px;color:var(--fb-muted-fg);line-height:1.5}',
|
||||
|
||||
'.fb-reply-input{display:flex;gap:8px;margin-top:8px}',
|
||||
'.fb-reply-input textarea{flex:1;padding:8px 10px;border:1px solid var(--fb-border);border-radius:6px;font-size:13px;font-family:var(--fb-font);resize:none;outline:none;min-height:36px;color:var(--fb-fg)}',
|
||||
'.fb-reply-input textarea:focus{border-color:var(--fb-accent)}',
|
||||
'.fb-reply-input button{padding:6px 14px;background:var(--fb-primary);color:var(--fb-primary-fg);border:none;border-radius:6px;font-size:12px;cursor:pointer;font-family:var(--fb-font);align-self:flex-end}',
|
||||
'.fb-reply-input button:disabled{opacity:.4;cursor:default}',
|
||||
|
||||
'.fb-edit-area textarea{width:100%;padding:8px 10px;border:1px solid var(--fb-border);border-radius:6px;font-size:13px;font-family:var(--fb-font);resize:vertical;outline:none;min-height:50px;margin-bottom:6px;color:var(--fb-fg)}',
|
||||
'.fb-edit-area textarea:focus{border-color:var(--fb-accent)}',
|
||||
'.fb-edit-btns{display:flex;gap:6px}',
|
||||
'.fb-edit-btns button{padding:4px 12px;border-radius:6px;font-size:12px;cursor:pointer;border:1px solid var(--fb-border);background:var(--fb-bg);color:var(--fb-muted-fg);font-family:var(--fb-font)}',
|
||||
'.fb-edit-btns button.save{background:var(--fb-primary);color:var(--fb-primary-fg);border-color:var(--fb-primary)}',
|
||||
'.fb-edit-pri{display:flex;gap:4px;margin-bottom:6px}',
|
||||
'.fb-edit-pri button{padding:2px 10px;border-radius:4px;font-size:11px;font-weight:700;cursor:pointer;border:1px solid var(--fb-border);background:var(--fb-bg);color:var(--fb-muted-fg);font-family:var(--fb-font);transition:all .15s}',
|
||||
|
||||
'.fb-popup{position:fixed;z-index:100000;width:400px;background:var(--fb-bg);border:1px solid var(--fb-border);border-radius:12px;padding:16px;box-shadow:0 10px 25px rgba(0,0,0,0.1);font-family:var(--fb-font);display:none}',
|
||||
'.fb-popup.show{display:block}',
|
||||
'.fb-popup-head{margin-bottom:10px}',
|
||||
'.fb-popup-head span{font-size:14px;font-weight:700;color:var(--fb-fg)}',
|
||||
'.fb-popup-quote{font-size:13px;color:var(--fb-muted-fg);padding:8px 12px;background:var(--fb-muted);border-left:2px solid var(--fb-accent);border-radius:0 6px 6px 0;margin-bottom:10px;font-style:italic;line-height:1.5}',
|
||||
'.fb-popup textarea{width:100%;min-height:70px;padding:10px 12px;border:1px solid var(--fb-border);border-radius:8px;font-size:14px;font-family:var(--fb-font);resize:vertical;outline:none;margin-bottom:10px;color:var(--fb-fg)}',
|
||||
'.fb-popup textarea:focus{border-color:var(--fb-accent)}',
|
||||
'.fb-popup textarea::placeholder{color:var(--fb-muted-fg)}',
|
||||
'.fb-popup-pri{display:flex;gap:6px;margin-bottom:10px}',
|
||||
'.fb-popup-pri button{flex:1;padding:7px 8px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;border:2px solid transparent;transition:all .15s;font-family:var(--fb-font)}',
|
||||
'.fb-popup-actions{display:flex;gap:8px;justify-content:flex-end}',
|
||||
'.fb-popup-actions button{padding:8px 18px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;transition:all .15s;font-family:var(--fb-font)}',
|
||||
'.fb-popup-actions .cancel{background:none;border:1px solid var(--fb-border);color:var(--fb-muted-fg)}',
|
||||
'.fb-popup-actions .cancel:hover{background:var(--fb-muted);color:var(--fb-fg)}',
|
||||
'.fb-popup-actions .submit{border:none;color:#fff}',
|
||||
|
||||
'.fb-name-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:100001}',
|
||||
'.fb-name-box{background:var(--fb-bg);border-radius:12px;padding:28px;width:360px;box-shadow:0 20px 40px rgba(0,0,0,0.15)}',
|
||||
'.fb-name-box h2{font-size:18px;font-weight:700;margin:0 0 6px;color:var(--fb-fg)}',
|
||||
'.fb-name-box p{font-size:14px;color:var(--fb-muted-fg);margin:0 0 16px}',
|
||||
'.fb-name-box input{width:100%;padding:10px 14px;border:1px solid var(--fb-border);border-radius:8px;font-size:15px;outline:none;margin-bottom:12px;font-family:var(--fb-font);color:var(--fb-fg)}',
|
||||
'.fb-name-box input:focus{border-color:var(--fb-accent)}',
|
||||
'.fb-name-box input::placeholder{color:var(--fb-muted-fg)}',
|
||||
'.fb-name-box button{width:100%;padding:10px;background:var(--fb-primary);color:var(--fb-primary-fg);border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:var(--fb-font)}',
|
||||
'.fb-name-box button:disabled{opacity:.4;cursor:default}',
|
||||
|
||||
'.fb-name-input{width:100%;padding:4px 8px;border:1px solid var(--fb-border);border-radius:4px;font-size:12px;outline:none;font-family:var(--fb-font);color:var(--fb-fg)}',
|
||||
'.fb-name-input:focus{border-color:var(--fb-accent)}',
|
||||
|
||||
'.fb-highlight{padding:1px 0;cursor:pointer;transition:background .15s}',
|
||||
'.fb-highlight-must{background:rgba(239,68,68,0.15);border-bottom:2px solid #ef4444}',
|
||||
'.fb-highlight-must:hover{background:rgba(239,68,68,0.25)}',
|
||||
'.fb-highlight-better{background:rgba(245,158,11,0.15);border-bottom:2px solid #f59e0b}',
|
||||
'.fb-highlight-better:hover{background:rgba(245,158,11,0.25)}',
|
||||
'.fb-highlight-want{background:rgba(34,197,94,0.15);border-bottom:2px solid #22c55e}',
|
||||
'.fb-highlight-want:hover{background:rgba(34,197,94,0.25)}',
|
||||
|
||||
'@keyframes fb-pulse{0%,100%{opacity:1}50%{opacity:0.4}}',
|
||||
'.fb-highlight.fb-pulse{animation:fb-pulse 1s ease-in-out infinite}',
|
||||
'.fb-card.fb-focused{box-shadow:0 0 0 2px var(--fb-accent);background:rgba(59,130,246,0.04)}',
|
||||
|
||||
'#fb-sidebar svg,#fb-toggle svg,.fb-popup svg{pointer-events:none}',
|
||||
|
||||
// Pin mode styles
|
||||
'body.fb-pin-mode{cursor:' + pinCursor + '}',
|
||||
'body.fb-pin-mode *:not(#fb-toggle):not(#fb-toggle *):not(#fb-sidebar):not(#fb-sidebar *):not(#fb-pin-popup):not(#fb-pin-popup *):not(.fb-pin-marker):not(.fb-pin-marker *){cursor:' + pinCursor + '}',
|
||||
|
||||
'.fb-toggle-pin{display:flex;align-items:center;justify-content:center;padding:4px;cursor:pointer;transition:all .15s}',
|
||||
'.fb-toggle-pin:hover{color:var(--fb-accent)}',
|
||||
|
||||
// Voice (mic) button
|
||||
'.fb-toggle-voice{display:flex;align-items:center;justify-content:center;padding:4px;cursor:pointer;color:var(--fb-muted-fg);border-radius:4px;transition:all .15s}',
|
||||
'.fb-toggle-voice:hover{color:var(--fb-accent)}',
|
||||
'.fb-toggle-voice.recording{color:#ef4444;background:rgba(239,68,68,0.12);animation:fb-mic-pulse 1.1s ease-in-out infinite}',
|
||||
'.fb-toggle-voice.processing{color:var(--fb-accent)}',
|
||||
'.fb-toggle-voice.processing svg{animation:fb-mic-spin .8s linear infinite;transform-origin:center}',
|
||||
'@keyframes fb-mic-pulse{0%,100%{box-shadow:0 0 0 0 rgba(239,68,68,0.5)}50%{box-shadow:0 0 0 5px rgba(239,68,68,0)}}',
|
||||
'@keyframes fb-mic-spin{to{transform:rotate(360deg)}}',
|
||||
|
||||
// 音声解析の失敗を知らせるトースト(黙って先頭にピンが溜まるのを防ぐ)
|
||||
'.fb-voice-toast{position:fixed;right:16px;bottom:70px;z-index:100001;max-width:320px;padding:10px 14px;border-radius:8px;background:var(--fb-fg);color:var(--fb-bg);font-family:var(--fb-font);font-size:13px;line-height:1.5;box-shadow:0 6px 20px rgba(0,0,0,0.2);opacity:0;transform:translateY(6px);transition:opacity .2s,transform .2s;pointer-events:none}',
|
||||
'.fb-voice-toast.show{opacity:1;transform:translateY(0)}',
|
||||
|
||||
// 掴んで移動できることを示す。フィードバックモードの ON/OFF に関わらず操作できる
|
||||
'.fb-pin-marker{position:absolute;transform:translate(-50%,-100%);cursor:grab;z-index:99990;transition:transform .15s}',
|
||||
'.fb-pin-marker:hover{transform:translate(-50%,-100%) scale(1.15)}',
|
||||
// 解決済みピンは淡色表示
|
||||
'.fb-pin-marker.resolved{opacity:.4}',
|
||||
// サイドバーのカードから飛んだとき、マーカーを点滅させる
|
||||
'.fb-pin-marker.fb-pin-flash .fb-pin-icon{animation:fb-pin-ring 1.4s ease}',
|
||||
'@keyframes fb-pin-ring{0%,100%{box-shadow:0 2px 6px rgba(0,0,0,0.25)}30%,60%{box-shadow:0 0 0 8px rgba(59,130,246,0.45)}}',
|
||||
// サイドバーのピンカード内インジケータ
|
||||
'.fb-pin-ref{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--fb-muted-fg);padding:6px 10px;background:var(--fb-muted);border-radius:6px;margin-bottom:8px;cursor:pointer;transition:background .15s}',
|
||||
'.fb-pin-ref:hover{background:var(--fb-border)}',
|
||||
'.fb-pin-ref-badge{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;color:#fff;font-size:10px;font-weight:700;flex-shrink:0}',
|
||||
'body.fb-dragging-pin,body.fb-dragging-pin *{cursor:grabbing !important;-webkit-user-select:none !important;user-select:none !important}',
|
||||
// ドラッグ中はホバー拡大アニメを止めてカクつきを防ぐ
|
||||
'body.fb-dragging-pin .fb-pin-marker{transition:none}',
|
||||
'body.fb-dragging-pin .fb-pin-tooltip{display:none !important}',
|
||||
// ホバー吹き出しの優先度バッジ(クリックで循環)
|
||||
'.fb-pin-badge{cursor:pointer;transition:transform .1s}',
|
||||
'.fb-pin-badge:hover{transform:scale(1.08)}',
|
||||
'.fb-pin-badge.readonly{cursor:default}',
|
||||
'.fb-pin-icon{width:24px;height:24px;border-radius:50%;color:#fff;font-size:11px;font-weight:700;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 6px rgba(0,0,0,0.25);position:relative}',
|
||||
'.fb-pin-icon::after{content:"";position:absolute;bottom:-5px;left:50%;transform:translateX(-50%);width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;border-top:6px solid var(--pin-color,#3b82f6)}',
|
||||
|
||||
'.fb-pin-tooltip{display:none;position:absolute;top:8px;left:28px;background:var(--fb-bg);border:1px solid var(--fb-border);border-radius:8px;padding:10px 12px;box-shadow:0 4px 12px rgba(0,0,0,0.1);min-width:180px;max-width:280px;z-index:99995;font-family:var(--fb-font)}',
|
||||
'.fb-pin-marker:hover .fb-pin-tooltip{display:block}',
|
||||
'.fb-pin-delete{position:absolute;top:4px;right:4px;width:18px;height:18px;border-radius:50%;background:rgba(0,0,0,0.5);color:#fff;border:none;font-size:13px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity .15s}',
|
||||
'.fb-pin-tooltip:hover .fb-pin-delete{opacity:1}',
|
||||
|
||||
'.fb-pin-popup{position:fixed;z-index:100000;width:300px;background:var(--fb-bg);border:1px solid var(--fb-border);border-radius:12px;padding:16px;box-shadow:0 10px 25px rgba(0,0,0,0.12);font-family:var(--fb-font);display:none}',
|
||||
'.fb-pin-popup.show{display:block}',
|
||||
'.fb-pin-popup-head{font-size:14px;font-weight:700;color:var(--fb-fg);margin-bottom:10px}',
|
||||
'.fb-pin-popup textarea{width:100%;min-height:60px;padding:10px 12px;border:1px solid var(--fb-border);border-radius:8px;font-size:14px;font-family:var(--fb-font);resize:vertical;outline:none;margin-bottom:10px;color:var(--fb-fg);box-sizing:border-box}',
|
||||
'.fb-pin-popup textarea:focus{border-color:var(--fb-accent)}',
|
||||
'.fb-pin-popup textarea::placeholder{color:var(--fb-muted-fg)}',
|
||||
'.fb-pin-popup-pri{display:flex;gap:6px;margin-bottom:10px}',
|
||||
'.fb-pin-pri-btn{flex:1;padding:6px 8px;border-radius:6px;font-size:12px;font-weight:700;cursor:pointer;border:none;transition:opacity .15s}',
|
||||
'.fb-pin-popup-actions{display:flex;gap:8px;justify-content:flex-end}',
|
||||
'.fb-pin-popup-actions button{padding:8px 16px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;transition:all .15s;font-family:var(--fb-font)}',
|
||||
'.fb-pin-cancel{background:none;border:1px solid var(--fb-border);color:var(--fb-muted-fg)}',
|
||||
'.fb-pin-cancel:hover{background:var(--fb-muted);color:var(--fb-fg)}',
|
||||
'.fb-pin-submit{background:var(--fb-accent);border:none;color:#fff}',
|
||||
'.fb-pin-submit:hover{background:#2563eb}',
|
||||
].join('\n');
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
326
src/widget/voice.ts
Normal file
326
src/widget/voice.ts
Normal file
@ -0,0 +1,326 @@
|
||||
import type { Priority } from '../shared/types';
|
||||
import { state } from './state';
|
||||
import { addPin, renderPins, elementToPinCoords, anchorFromElement, type PinAnchor } from './pin';
|
||||
import { voiceApi, type VoiceResult } from './api';
|
||||
|
||||
interface Target {
|
||||
index: number;
|
||||
text: string; // その要素に表示されている文字
|
||||
heading: string; // 直前の見出し(Gemini に渡す文脈)
|
||||
isHeading: boolean; // 見出し要素そのものか
|
||||
x: number; // ピン座標系(%)
|
||||
y: number; // ピン座標系(px)
|
||||
anchor: PinAnchor; // 要素アンカー(レスポンシブ追従)
|
||||
}
|
||||
|
||||
let mediaRecorder: MediaRecorder | null = null;
|
||||
let chunks: BlobPart[] = [];
|
||||
let stream: MediaStream | null = null;
|
||||
let onChange: () => void = () => {};
|
||||
|
||||
/** 録音状態が変わったときに UI を再描画させるためのコールバックを登録する。 */
|
||||
export function setVoiceOnChange(cb: () => void): void {
|
||||
onChange = cb;
|
||||
}
|
||||
|
||||
function getContainer(): HTMLElement {
|
||||
return document.querySelector('main') || document.body;
|
||||
}
|
||||
|
||||
const BLOCK_SELECTOR = 'div,p,li,td,th,dt,dd,figcaption,blockquote,h1,h2,h3,h4,h5,h6';
|
||||
const HEADING_TAGS = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6'];
|
||||
const WIDGET_UI = '#fb-sidebar,#fb-toggle,#fb-pin-popup,.fb-pin-marker,.fb-pin-tooltip,.fb-voice-toast';
|
||||
const MIN_TEXT = 2; // 「1」のような装飾番号を除く
|
||||
const MAX_TEXT = 120; // これより長いものは「まとまり」ではなく本文の塊とみなす
|
||||
const MAX_TARGETS = 600;
|
||||
|
||||
/**
|
||||
* 音声の指摘を紐づける候補要素を抽出する。
|
||||
*
|
||||
* 見出しだけを候補にすると、図解の中身(カードのラベルや短い説明文)を
|
||||
* 名指しされても一番近い見出しにしか置けない。そこで「テキストを持つ最小の
|
||||
* ブロック」=それ以上分解できない表示単位を候補にする。
|
||||
* 各候補には直前の見出しを添えて、Gemini がどのセクションの話か判断できるようにする。
|
||||
*/
|
||||
export function extractTargets(): Target[] {
|
||||
const container = getContainer();
|
||||
|
||||
// テキストを持つブロックのうち、内側に別のブロックを含まないもの=最小単位
|
||||
const blocks = (Array.from(container.querySelectorAll(BLOCK_SELECTOR)) as HTMLElement[]).filter(
|
||||
(el) => (el.textContent || '').trim() && !el.closest(WIDGET_UI),
|
||||
);
|
||||
const leaves = new Set(blocks.filter((el) => !blocks.some((o) => o !== el && el.contains(o))));
|
||||
|
||||
const targets: Target[] = [];
|
||||
let heading = '';
|
||||
|
||||
// 文書順に走査して「直前の見出し」を引き継ぎながら候補を積む
|
||||
(Array.from(container.querySelectorAll('*')) as HTMLElement[]).forEach((el) => {
|
||||
const isHeading = HEADING_TAGS.includes(el.tagName);
|
||||
if (isHeading) {
|
||||
const t = normalize(el.textContent || '');
|
||||
if (t) heading = t;
|
||||
}
|
||||
if (!leaves.has(el) || targets.length >= MAX_TARGETS) return;
|
||||
|
||||
const text = normalize(el.textContent || '');
|
||||
if (text.length < MIN_TEXT || text.length > MAX_TEXT) return;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width < 4 || rect.height < 4) return; // 非表示・つぶれた要素
|
||||
|
||||
const { x, y } = elementToPinCoords(el);
|
||||
targets.push({
|
||||
index: targets.length,
|
||||
text,
|
||||
// 見出し自身は自分の文言と重複するので文脈を持たせない
|
||||
heading: isHeading ? '' : heading,
|
||||
isHeading,
|
||||
x,
|
||||
y,
|
||||
anchor: anchorFromElement(el),
|
||||
});
|
||||
});
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
function normalize(s: string): string {
|
||||
return s.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
export function toggleRecording(): void {
|
||||
if (state.voiceProcessing) return;
|
||||
if (state.voiceRecording) {
|
||||
stopRecording();
|
||||
} else {
|
||||
void startRecording();
|
||||
}
|
||||
}
|
||||
|
||||
export async function startRecording(): Promise<void> {
|
||||
if (state.voiceRecording || state.voiceProcessing) return;
|
||||
state.voiceError = null;
|
||||
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
} catch {
|
||||
state.voiceError = 'マイクへのアクセスが許可されませんでした';
|
||||
onChange();
|
||||
return;
|
||||
}
|
||||
|
||||
chunks = [];
|
||||
const mime = pickMime();
|
||||
mediaRecorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunks.push(e.data);
|
||||
};
|
||||
mediaRecorder.onstop = () => {
|
||||
void handleStop();
|
||||
};
|
||||
mediaRecorder.start();
|
||||
|
||||
state.voiceRecording = true;
|
||||
onChange();
|
||||
}
|
||||
|
||||
export function stopRecording(): void {
|
||||
if (!state.voiceRecording || !mediaRecorder) return;
|
||||
mediaRecorder.stop();
|
||||
state.voiceRecording = false;
|
||||
state.voiceProcessing = true;
|
||||
onChange();
|
||||
}
|
||||
|
||||
async function handleStop(): Promise<void> {
|
||||
// マイクを解放
|
||||
stream?.getTracks().forEach((t) => t.stop());
|
||||
stream = null;
|
||||
|
||||
const blob = new Blob(chunks, { type: mediaRecorder?.mimeType || 'audio/webm' });
|
||||
chunks = [];
|
||||
|
||||
const targets = extractTargets();
|
||||
if (targets.length === 0) {
|
||||
state.voiceError = 'ピンを置ける要素が見つかりませんでした';
|
||||
state.voiceProcessing = false;
|
||||
onChange();
|
||||
return;
|
||||
}
|
||||
if (targets.length >= MAX_TARGETS) {
|
||||
// 上限で打ち切ると後半にピンを置けない。黙って効かないより伝える。
|
||||
state.voiceError = `ページが大きいため、先頭${MAX_TARGETS}箇所のみを対象にしています`;
|
||||
}
|
||||
|
||||
try {
|
||||
// 録音(webm/mp4等) を Gemini が確実に扱える 16kHz モノラル WAV に変換して送る
|
||||
const base64 = await blobToWavBase64(blob);
|
||||
const resp = await voiceApi(
|
||||
base64,
|
||||
'audio/wav',
|
||||
targets.map((t) => ({ index: t.index, text: t.text, heading: t.heading, isHeading: t.isHeading })),
|
||||
);
|
||||
if (resp.error) {
|
||||
state.voiceError = resp.error;
|
||||
} else {
|
||||
placePins(resp.results || [], targets);
|
||||
}
|
||||
} catch {
|
||||
state.voiceError = '音声の解析に失敗しました';
|
||||
} finally {
|
||||
state.voiceProcessing = false;
|
||||
onChange();
|
||||
}
|
||||
}
|
||||
|
||||
function placePins(results: VoiceResult[], targets: Target[]): void {
|
||||
if (!Array.isArray(results) || results.length === 0) {
|
||||
state.voiceError = 'フィードバックを聞き取れませんでした。もう一度お試しください';
|
||||
return;
|
||||
}
|
||||
let misplaced = 0;
|
||||
results.forEach((r) => {
|
||||
let target = targets.find((t) => t.index === r.target_index);
|
||||
if (!target) {
|
||||
// Gemini が該当なし(-1)や範囲外を返した場合。発言に含まれる語句と
|
||||
// 要素のテキストを突き合わせて置き場所を探す。
|
||||
target = findByText(`${r.quote || ''} ${r.comment || ''}`, targets);
|
||||
}
|
||||
if (!target) {
|
||||
// それでも決まらなければ捨てずに先頭へ。位置は当てにならないと伝える。
|
||||
target = targets[0];
|
||||
misplaced++;
|
||||
}
|
||||
if (!target) return;
|
||||
addPin(target.x, target.y, r.comment, normalizePriority(r.priority), target.anchor);
|
||||
});
|
||||
if (misplaced > 0) {
|
||||
state.voiceError = `${misplaced}件は場所を特定できませんでした(先頭に置いています。ドラッグで移動できます)`;
|
||||
}
|
||||
renderPins();
|
||||
}
|
||||
|
||||
const MATCH_THRESHOLD = 0.34;
|
||||
const MATCH_MIN_LEN = 4;
|
||||
|
||||
/**
|
||||
* 発言テキストに最も近い要素を、2文字単位の一致率で探す。
|
||||
*
|
||||
* 一致率は短い方を分母にするため、「0点」「講義」のような2〜3文字の要素は
|
||||
* たまたま含まれるだけで満点になってしまう。照合対象はある程度の長さを持つ
|
||||
* 要素に絞り、同点なら情報量の多い(長い)方を選ぶ。
|
||||
*/
|
||||
function findByText(query: string, targets: Target[]): Target | undefined {
|
||||
const q = bigrams(query);
|
||||
if (q.size === 0) return undefined;
|
||||
let best: Target | undefined;
|
||||
let bestScore = MATCH_THRESHOLD;
|
||||
targets.forEach((t) => {
|
||||
if (t.text.length < MATCH_MIN_LEN) return;
|
||||
const score = overlap(q, bigrams(t.text));
|
||||
if (score > bestScore || (best && score === bestScore && t.text.length > best.text.length)) {
|
||||
bestScore = score;
|
||||
best = t;
|
||||
}
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
function bigrams(s: string): Set<string> {
|
||||
const t = s.replace(/\s+/g, '').toLowerCase();
|
||||
const out = new Set<string>();
|
||||
for (let i = 0; i < t.length - 1; i++) out.add(t.slice(i, i + 2));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 短い方に対する一致率。要素テキストは短いので Jaccard より当たりやすい。 */
|
||||
function overlap(a: Set<string>, b: Set<string>): number {
|
||||
if (a.size === 0 || b.size === 0) return 0;
|
||||
let hit = 0;
|
||||
a.forEach((g) => { if (b.has(g)) hit++; });
|
||||
return hit / Math.min(a.size, b.size);
|
||||
}
|
||||
|
||||
function normalizePriority(p: string): Priority {
|
||||
return p === 'must' || p === 'better' || p === 'want' ? p : 'better';
|
||||
}
|
||||
|
||||
/** MediaRecorder が対応する音声形式を優先順に選ぶ。 */
|
||||
function pickMime(): string {
|
||||
const cands = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/ogg', 'audio/mp4'];
|
||||
for (const c of cands) {
|
||||
if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported?.(c)) return c;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 録音 Blob を 16kHz モノラルの WAV (base64) に変換する。
|
||||
* Gemini の対応音声形式(wav/mp3/aiff/aac/ogg/flac)に確実に乗せ、
|
||||
* かつ 16kHz モノラルに落としてペイロードを小さくする。
|
||||
*/
|
||||
async function blobToWavBase64(blob: Blob): Promise<string> {
|
||||
const arrayBuf = await blob.arrayBuffer();
|
||||
const AudioCtx: typeof AudioContext =
|
||||
window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||
const ctx = new AudioCtx();
|
||||
let decoded: AudioBuffer;
|
||||
try {
|
||||
decoded = await ctx.decodeAudioData(arrayBuf);
|
||||
} finally {
|
||||
void ctx.close();
|
||||
}
|
||||
|
||||
const targetRate = 16000;
|
||||
const length = Math.max(1, Math.ceil(decoded.duration * targetRate));
|
||||
// チャンネル数1の出力に繋ぐと自動でモノラルにダウンミックスされる
|
||||
const offline = new OfflineAudioContext(1, length, targetRate);
|
||||
const src = offline.createBufferSource();
|
||||
src.buffer = decoded;
|
||||
src.connect(offline.destination);
|
||||
src.start();
|
||||
const rendered = await offline.startRendering();
|
||||
|
||||
const wav = encodeWav(rendered.getChannelData(0), targetRate);
|
||||
return arrayBufferToBase64(wav);
|
||||
}
|
||||
|
||||
/** Float32 PCM を 16bit モノラル WAV (ArrayBuffer) にエンコードする。 */
|
||||
function encodeWav(samples: Float32Array, sampleRate: number): ArrayBuffer {
|
||||
const buffer = new ArrayBuffer(44 + samples.length * 2);
|
||||
const view = new DataView(buffer);
|
||||
const writeStr = (off: number, s: string) => {
|
||||
for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i));
|
||||
};
|
||||
writeStr(0, 'RIFF');
|
||||
view.setUint32(4, 36 + samples.length * 2, true);
|
||||
writeStr(8, 'WAVE');
|
||||
writeStr(12, 'fmt ');
|
||||
view.setUint32(16, 16, true); // PCM チャンクサイズ
|
||||
view.setUint16(20, 1, true); // PCM
|
||||
view.setUint16(22, 1, true); // モノラル
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * 2, true); // byteRate = rate * blockAlign
|
||||
view.setUint16(32, 2, true); // blockAlign = ch * bytesPerSample
|
||||
view.setUint16(34, 16, true); // bitsPerSample
|
||||
writeStr(36, 'data');
|
||||
view.setUint32(40, samples.length * 2, true);
|
||||
let off = 44;
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const s = Math.max(-1, Math.min(1, samples[i]));
|
||||
view.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7fff, true);
|
||||
off += 2;
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buf: ArrayBuffer): string {
|
||||
let binary = '';
|
||||
const bytes = new Uint8Array(buf);
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
129
tests/shared.unit.test.ts
Normal file
129
tests/shared.unit.test.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { slugify, generateId } from '../src/shared/slug';
|
||||
import { fmtTime } from '../src/shared/time';
|
||||
import { PRIORITY_CYCLE, PRIORITY_COLORS, HIGHLIGHT_COLORS } from '../src/shared/constants';
|
||||
import { authHeaders } from '../src/shared/api-client';
|
||||
import { mapNormToOrig } from '../src/widget/highlight';
|
||||
|
||||
describe('slugify', () => {
|
||||
test('HTTP プレフィックスを除去する', () => {
|
||||
expect(slugify('https://example.com/page')).toBe('example_com_page');
|
||||
});
|
||||
|
||||
test('日本語文字を保持する', () => {
|
||||
const result = slugify('https://example.com/テスト');
|
||||
expect(result).toContain('テスト');
|
||||
});
|
||||
|
||||
test('100文字で切り詰める', () => {
|
||||
const long = 'https://example.com/' + 'a'.repeat(200);
|
||||
expect(slugify(long).length).toBe(100);
|
||||
});
|
||||
|
||||
test('記号をアンダースコアに変換する', () => {
|
||||
expect(slugify('https://a.b/c?d=e&f=g')).toBe('a_b_c_d_e_f_g');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateId', () => {
|
||||
test('文字列を返す', () => {
|
||||
expect(typeof generateId()).toBe('string');
|
||||
});
|
||||
|
||||
test('一意性がある(100回生成してユニーク)', () => {
|
||||
const ids = new Set(Array.from({ length: 100 }, () => generateId()));
|
||||
expect(ids.size).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fmtTime', () => {
|
||||
let realNow: () => number;
|
||||
|
||||
beforeEach(() => {
|
||||
realNow = Date.now;
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1700000000000);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('1分未満は「たった今」', () => {
|
||||
expect(fmtTime(1700000000000 - 30000)).toBe('たった今');
|
||||
});
|
||||
|
||||
test('1時間未満は「N分前」', () => {
|
||||
expect(fmtTime(1700000000000 - 300000)).toBe('5分前');
|
||||
});
|
||||
|
||||
test('24時間未満は「N時間前」', () => {
|
||||
expect(fmtTime(1700000000000 - 7200000)).toBe('2時間前');
|
||||
});
|
||||
|
||||
test('24時間以上は日時文字列', () => {
|
||||
const result = fmtTime(1700000000000 - 86400000 * 2);
|
||||
expect(result).toMatch(/\d+月/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PRIORITY_CYCLE', () => {
|
||||
test('must → better → want → must の循環', () => {
|
||||
expect(PRIORITY_CYCLE.must).toBe('better');
|
||||
expect(PRIORITY_CYCLE.better).toBe('want');
|
||||
expect(PRIORITY_CYCLE.want).toBe('must');
|
||||
});
|
||||
|
||||
test('3つの優先度が全て定義されている', () => {
|
||||
expect(Object.keys(PRIORITY_CYCLE)).toEqual(['must', 'better', 'want']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PRIORITY_COLORS', () => {
|
||||
test('3つの優先度が全て定義されている', () => {
|
||||
expect(Object.keys(PRIORITY_COLORS)).toEqual(['must', 'better', 'want']);
|
||||
});
|
||||
|
||||
test('各色に bg, text, light, border がある', () => {
|
||||
for (const p of ['must', 'better', 'want'] as const) {
|
||||
expect(PRIORITY_COLORS[p]).toHaveProperty('bg');
|
||||
expect(PRIORITY_COLORS[p]).toHaveProperty('text');
|
||||
expect(PRIORITY_COLORS[p]).toHaveProperty('light');
|
||||
expect(PRIORITY_COLORS[p]).toHaveProperty('border');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('HIGHLIGHT_COLORS', () => {
|
||||
test('3つの優先度が全て定義されている', () => {
|
||||
expect(Object.keys(HIGHLIGHT_COLORS)).toEqual(['must', 'better', 'want']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authHeaders', () => {
|
||||
test('トークンありの場合 Authorization を含む', () => {
|
||||
const h = authHeaders('my-token');
|
||||
expect(h['Authorization']).toBe('Bearer my-token');
|
||||
expect(h['Content-Type']).toBe('application/json');
|
||||
});
|
||||
|
||||
test('トークンなしの場合 Authorization を含まない', () => {
|
||||
const h = authHeaders('');
|
||||
expect(h['Authorization']).toBeUndefined();
|
||||
expect(h['Content-Type']).toBe('application/json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapNormToOrig', () => {
|
||||
test('空白なしの単純マッチ', () => {
|
||||
expect(mapNormToOrig('hello world', 0, 5)).toEqual([0, 5]);
|
||||
});
|
||||
|
||||
test('余分な空白がある場合の正規化マッチ', () => {
|
||||
// 正規化後のインデックス 6-11 は orig で 6-12 に対応(余分な空白を含む)
|
||||
expect(mapNormToOrig('hello world', 6, 11)).toEqual([6, 12]);
|
||||
});
|
||||
|
||||
test('範囲外の normStart は null を返す', () => {
|
||||
expect(mapNormToOrig('short', 100, 105)).toBeNull();
|
||||
});
|
||||
});
|
||||
168
tests/widget.integration.test.ts
Normal file
168
tests/widget.integration.test.ts
Normal file
@ -0,0 +1,168 @@
|
||||
import { describe, test, expect, beforeEach, vi } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const WIDGET_JS = readFileSync(
|
||||
resolve(__dirname, '../public/widget.js'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
function loadWidget() {
|
||||
document.head.innerHTML = '';
|
||||
document.body.innerHTML = '<p>テストテキスト</p>';
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://my-app.vercel.app/widget.js';
|
||||
script.dataset.token = 'test-token-abc';
|
||||
Object.defineProperty(document, 'currentScript', {
|
||||
value: script,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
window.localStorage.clear();
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() =>
|
||||
Promise.resolve({
|
||||
json: () => Promise.resolve([]),
|
||||
ok: true,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// eslint-disable-next-line no-eval
|
||||
eval(WIDGET_JS);
|
||||
}
|
||||
|
||||
describe('widget.js 初期化', () => {
|
||||
beforeEach(() => {
|
||||
loadWidget();
|
||||
});
|
||||
|
||||
test('トグルボタンが DOM に追加される', () => {
|
||||
const toggle = document.getElementById('fb-toggle');
|
||||
expect(toggle).toBeTruthy();
|
||||
expect(toggle!.tagName).toBe('BUTTON');
|
||||
});
|
||||
|
||||
test('サイドバーが DOM に追加される', () => {
|
||||
const sidebar = document.getElementById('fb-sidebar');
|
||||
expect(sidebar).toBeTruthy();
|
||||
});
|
||||
|
||||
test('スタイルが注入される', () => {
|
||||
const style = document.getElementById('fb-widget-styles');
|
||||
expect(style).toBeTruthy();
|
||||
expect(style!.tagName).toBe('STYLE');
|
||||
});
|
||||
|
||||
test('CSS 変数が定義されている', () => {
|
||||
const style = document.getElementById('fb-widget-styles');
|
||||
const css = style!.textContent || '';
|
||||
expect(css).toContain('--fb-bg');
|
||||
expect(css).toContain('--fb-fg');
|
||||
expect(css).toContain('--fb-accent');
|
||||
expect(css).toContain('--fb-border');
|
||||
expect(css).toContain('--fb-muted');
|
||||
expect(css).toContain('--fb-primary');
|
||||
expect(css).toContain('--fb-destructive');
|
||||
});
|
||||
|
||||
test('トグルボタンに「コメント」ラベルがある', () => {
|
||||
const toggle = document.getElementById('fb-toggle');
|
||||
expect(toggle!.innerHTML).toContain('コメント');
|
||||
});
|
||||
|
||||
test('サイドバーにフィルタボタンがある', () => {
|
||||
const sidebar = document.getElementById('fb-sidebar');
|
||||
const html = sidebar!.innerHTML;
|
||||
expect(html).toContain('未解決');
|
||||
expect(html).toContain('解決済');
|
||||
expect(html).toContain('すべて');
|
||||
});
|
||||
|
||||
test('サイドバーにリサイズハンドルがある', () => {
|
||||
const sidebar = document.getElementById('fb-sidebar');
|
||||
const handle = sidebar!.querySelector('.fb-resize-handle');
|
||||
expect(handle).toBeTruthy();
|
||||
});
|
||||
|
||||
test('サイドバーは初期状態で非表示(右にオフセット)', () => {
|
||||
const sidebar = document.getElementById('fb-sidebar') as HTMLElement;
|
||||
const right = parseInt(sidebar.style.right, 10);
|
||||
expect(right).toBeLessThan(0);
|
||||
});
|
||||
|
||||
test('名前未入力時に名前入力ダイアログが表示される', () => {
|
||||
const overlay = document.getElementById('fb-name-overlay');
|
||||
expect(overlay).toBeTruthy();
|
||||
expect(overlay!.innerHTML).toContain('ようこそ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('widget.js ハイライト CSS', () => {
|
||||
beforeEach(() => {
|
||||
loadWidget();
|
||||
});
|
||||
|
||||
test('ハイライトスタイルが優先度別に3色定義されている', () => {
|
||||
const style = document.getElementById('fb-widget-styles');
|
||||
const css = style!.textContent || '';
|
||||
expect(css).toContain('.fb-highlight-must');
|
||||
expect(css).toContain('.fb-highlight-better');
|
||||
expect(css).toContain('.fb-highlight-want');
|
||||
});
|
||||
|
||||
test('カードスタイルが定義されている', () => {
|
||||
const style = document.getElementById('fb-widget-styles');
|
||||
const css = style!.textContent || '';
|
||||
expect(css).toContain('.fb-card');
|
||||
expect(css).toContain('.fb-card-head');
|
||||
expect(css).toContain('.fb-badge-p');
|
||||
});
|
||||
|
||||
test('ポップアップスタイルが定義されている', () => {
|
||||
const style = document.getElementById('fb-widget-styles');
|
||||
const css = style!.textContent || '';
|
||||
expect(css).toContain('.fb-popup');
|
||||
expect(css).toContain('.fb-popup-pri');
|
||||
expect(css).toContain('.fb-popup-actions');
|
||||
});
|
||||
});
|
||||
|
||||
describe('widget.js コメントなし状態', () => {
|
||||
beforeEach(() => {
|
||||
loadWidget();
|
||||
});
|
||||
|
||||
test('コメントゼロ時にサイドバーに空状態メッセージが表示される', () => {
|
||||
const sidebar = document.getElementById('fb-sidebar');
|
||||
const html = sidebar!.innerHTML;
|
||||
expect(html).toContain('コメントはまだありません');
|
||||
});
|
||||
|
||||
test('コメントゼロ時にバッジが表示されない', () => {
|
||||
const toggle = document.getElementById('fb-toggle');
|
||||
expect(toggle!.querySelector('.fb-badge')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('widget.js SVG アイコン', () => {
|
||||
beforeEach(() => {
|
||||
loadWidget();
|
||||
});
|
||||
|
||||
test('トグルボタンに SVG アイコンが含まれる', () => {
|
||||
const toggle = document.getElementById('fb-toggle');
|
||||
expect(toggle!.innerHTML).toContain('<svg');
|
||||
});
|
||||
|
||||
test('サイドバーの閉じるボタンに SVG アイコンが含まれる', () => {
|
||||
const sidebar = document.getElementById('fb-sidebar');
|
||||
const closeBtn = sidebar!.querySelector('[data-action="close"]');
|
||||
expect(closeBtn).toBeTruthy();
|
||||
expect(closeBtn!.innerHTML).toContain('<svg');
|
||||
});
|
||||
});
|
||||
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules", "src/widget", "tests"]
|
||||
}
|
||||
3
vercel.json
Normal file
3
vercel.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"framework": "nextjs"
|
||||
}
|
||||
8
vitest.config.ts
Normal file
8
vitest.config.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user