first commit
Made-with: Cursor
This commit is contained in:
commit
bc8a281e4c
224
.claude/skills/creating-visual-explainers/SKILL.md
Normal file
224
.claude/skills/creating-visual-explainers/SKILL.md
Normal file
@ -0,0 +1,224 @@
|
||||
---
|
||||
name: creating-visual-explainers
|
||||
description: Generates an illustrated HTML page about any topic and deploys it to surge.sh. 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 未含(デプロイ時に自動注入されるため)
|
||||
|
||||
## ワークフロー
|
||||
|
||||
### Step 0: 前提確認
|
||||
|
||||
1. `references/base.html` が存在するか確認する。存在しない場合、以下を伝えて終了:
|
||||
|
||||
> テンプレートファイルが見つかりません。スキルのフォルダ構成が壊れている可能性があります。運営に連絡してください。
|
||||
|
||||
2. プロジェクトルート直下の `fb-tool-url.txt` が存在するか確認する。存在しない場合、以下を伝えて終了:
|
||||
|
||||
> FBツールのセットアップがまだ完了していません。
|
||||
> 先にチャット欄で「セットアップして」と伝えてください。
|
||||
|
||||
### 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
|
||||
$apiToken = Get-Content fb-api-token.txt
|
||||
(Get-Content output/{スラッグ}.html -Raw) -replace '</body>', "<script src=`"$fbUrl/widget.js`" data-token=`"$apiToken`"></script></body>" | Set-Content "$env:TEMP\index.html"
|
||||
npx --yes surge "$env:TEMP\index.html" --domain 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` を実行してメールアドレスとパスワードを入力する
|
||||
- **その他** — エラーの内容を読み、「何が問題で」「次に何をすればいいか」を平易に説明する
|
||||
|
||||
### 図解の削除
|
||||
|
||||
ユーザーが「この図解を削除して」と依頼した場合:
|
||||
|
||||
1. `deploy-history.log` を読み、直近のデプロイURLを特定する
|
||||
- ログが存在しない場合 → ユーザーに削除したいURLを聞く
|
||||
2. `npx surge teardown [ドメイン]` を実行する
|
||||
3. 削除完了をユーザーに伝える
|
||||
|
||||
### Step 7: 完了報告
|
||||
|
||||
#### 公開に成功した場合
|
||||
|
||||
```
|
||||
完成・公開完了: 【図解のタイトル】
|
||||
|
||||
(図解の内容を1〜2文で要約)
|
||||
|
||||
公開URL:
|
||||
https://diagram-スラッグ.surge.sh
|
||||
|
||||
図解の主なポイント:
|
||||
- (主要トピックを3〜5個)
|
||||
|
||||
この図解を削除したいとき:
|
||||
チャット欄で「この図解を削除して」と伝えてください。
|
||||
```
|
||||
|
||||
#### 公開できなかった場合
|
||||
|
||||
```
|
||||
完成: 【図解のタイトル】
|
||||
|
||||
(図解の内容を1〜2文で要約)
|
||||
|
||||
ファイルの保存先:
|
||||
output/{スラッグ}.html(ブラウザにドラッグ&ドロップすると表示できます)
|
||||
|
||||
図解の主なポイント:
|
||||
- (主要トピックを3〜5個)
|
||||
|
||||
URLで共有したいとき:
|
||||
チャット欄で「この図解を公開して」と伝えてください。
|
||||
```
|
||||
|
||||
## 守ること(禁止事項)
|
||||
|
||||
- **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 インストールガイド
|
||||
|
||||
SKILL.md の Step 5 で 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 を再起動してからもう一度試すよう案内する。
|
||||
@ -0,0 +1,95 @@
|
||||
#!/bin/bash
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
sed "s|</body>|<script src=\"${FB_URL}/widget.js\" data-token=\"${API_TOKEN}\"></script></body>|" "$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 deploy-history.log
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') | https://${DOMAIN}" >> 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}"
|
||||
175
.claude/skills/setup-fb-tool/SKILL.md
Normal file
175
.claude/skills/setup-fb-tool/SKILL.md
Normal file
@ -0,0 +1,175 @@
|
||||
---
|
||||
name: setup-fb-tool
|
||||
description: 図解コメントツールのセットアップを対話的にガイドするスキル。「FBツールをセットアップして」「フィードバック機能を設定して」「FBツールを使えるようにして」「セットアップして」と依頼された際に使用する。
|
||||
---
|
||||
|
||||
# Setup FB Tool
|
||||
|
||||
図解に対するフィードバック機能を使えるようにする初期セットアップ。Vercel(ホスティング)とNeon Postgres(データベース)の設定を対話的にガイドし、図解テンプレートにwidgetスクリプトを埋め込む。
|
||||
|
||||
**実行するのは1回だけ。** セットアップ完了後は、図解を作ってSurgeにデプロイするだけで自動的にフィードバック機能が付く。
|
||||
|
||||
## 前提確認
|
||||
|
||||
### Node.js
|
||||
|
||||
```bash
|
||||
node --version
|
||||
```
|
||||
|
||||
バージョン番号が表示された → 次に進む。
|
||||
`command not found` → `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: APIトークンの生成
|
||||
|
||||
APIを保護するためのトークンを生成し、Vercelの環境変数に設定する。
|
||||
|
||||
```bash
|
||||
openssl rand -hex 16
|
||||
```
|
||||
|
||||
表示された文字列がトークン。これを2つの環境変数として Vercel に設定する:
|
||||
|
||||
```bash
|
||||
echo "生成したトークン" | vercel env add API_TOKEN production
|
||||
echo "生成したトークン" | vercel env add NEXT_PUBLIC_API_TOKEN production
|
||||
```
|
||||
|
||||
`echo` の「生成したトークン」は実際に `openssl` で生成した値に置き換える。`vercel env add` が対話式プロンプトを出さずに値を受け取るよう、パイプで渡す。
|
||||
|
||||
同じトークンをルート直下の `fb-api-token.txt` に保存する(1行、トークンのみ)。
|
||||
|
||||
**Windows(PowerShell)の場合:**
|
||||
|
||||
```powershell
|
||||
[System.Guid]::NewGuid().ToString("N")
|
||||
```
|
||||
|
||||
表示された値を使って:
|
||||
|
||||
```powershell
|
||||
echo "生成したトークン" | vercel env add API_TOKEN production
|
||||
echo "生成したトークン" | vercel env add NEXT_PUBLIC_API_TOKEN production
|
||||
```
|
||||
|
||||
同様に `fb-api-token.txt` に保存する。
|
||||
|
||||
### Step 4: Vercelにデプロイ
|
||||
|
||||
```bash
|
||||
vercel --yes --prod
|
||||
```
|
||||
|
||||
デプロイが完了するとURLが表示される。このURLを控えておく。
|
||||
|
||||
### Step 5: データベースの追加
|
||||
|
||||
ユーザーにブラウザでの操作を案内する。以下をそのまま伝える:
|
||||
|
||||
> データベースを追加します。ブラウザで以下の操作をしてください。
|
||||
>
|
||||
> 1. Vercelのダッシュボード(https://vercel.com)を開く
|
||||
> 2. デプロイしたプロジェクトをクリック
|
||||
> 3. 上部タブの「Storage」をクリック
|
||||
> 4. 「Create Database」をクリック
|
||||
> 5. 「Neon Postgres」を選択
|
||||
> 6. プランは「Free」を選択(無料、クレジットカード不要)
|
||||
> 7. Resource Nameを `fb-tool-db` に変更
|
||||
> 8. 「Create」をクリック
|
||||
> 9. 次の画面で:
|
||||
> - 「Search Projects」からプロジェクトを選択
|
||||
> - 「Custom Prefix」の欄を `DATABASE` に変更
|
||||
> - 「Connect」をクリック
|
||||
>
|
||||
> 完了したら教えてください。
|
||||
|
||||
### Step 6: 環境変数の取得とマイグレーション
|
||||
|
||||
```bash
|
||||
vercel env pull .env.local
|
||||
```
|
||||
|
||||
テーブルを作成:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/migrate.ts
|
||||
```
|
||||
|
||||
`Migration complete.` と表示されれば成功。
|
||||
|
||||
### Step 7: 再デプロイ
|
||||
|
||||
```bash
|
||||
vercel --prod
|
||||
```
|
||||
|
||||
### Step 8: URLを保存する
|
||||
|
||||
Step 4またはStep 7で表示されたVercelのURL(`https://xxx.vercel.app` 形式)を、ルート直下の `fb-tool-url.txt` に書き出す。URLのみを1行で保存する。
|
||||
|
||||
### Step 9: 完了報告
|
||||
|
||||
`fb-tool-url.txt` と `fb-api-token.txt` の存在を確認し、以下を伝える:
|
||||
|
||||
```
|
||||
セットアップ完了
|
||||
|
||||
あなたの FB ツール URL:
|
||||
https://xxx.vercel.app
|
||||
|
||||
APIはトークンで保護されています。
|
||||
図解のデプロイ時にトークンが自動で埋め込まれるため、追加の操作は不要です。
|
||||
|
||||
以降「図解を作って」と伝えるだけで、FB 機能付きの図解が公開されます。
|
||||
|
||||
フィードバック画面を直接開く場合:
|
||||
https://xxx.vercel.app?url=公開ページのURL
|
||||
```
|
||||
|
||||
## エラー対応
|
||||
|
||||
エラーメッセージをそのまま見せず、何が起きていて何をすれば解決するかを平易に説明する。
|
||||
|
||||
- **`vercel: command not found`** → `npm install -g vercel` を実行
|
||||
- **`DATABASE_URL is not set`** → Step 5のデータベース追加が完了しているか確認。完了していれば `vercel env pull .env.local` を再実行
|
||||
- **マイグレーション失敗** → `.env.local` に `DATABASE_URL` が含まれているか確認
|
||||
|
||||
## 依存
|
||||
|
||||
- `package.json` — FBツールの依存関係
|
||||
- `scripts/migrate.ts` — DBマイグレーションスクリプト
|
||||
- `references/node-install-guide.md` — Node.jsインストール手順
|
||||
@ -0,0 +1,66 @@
|
||||
# Node.js インストールガイド
|
||||
|
||||
SKILL.md の前提確認で Node.js が未インストールだった場合に参照する。
|
||||
|
||||
## 説明と許可
|
||||
|
||||
以下をユーザーに伝え、インストールの許可を求める:
|
||||
|
||||
> フィードバックツールのセットアップには「Node.js」のインストールが必要です。
|
||||
> Node.js は、パソコン上でプログラムを動かすための土台で、世界中で使われている安全な道具です。
|
||||
> **AI-Driven School 運営が確認済みですので安心してください。**
|
||||
>
|
||||
> 今からインストールしてもよいですか?
|
||||
|
||||
ユーザーが許可しなかった場合 → セットアップを中断する旨を伝えて終了。
|
||||
|
||||
## 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 を再起動してからもう一度試すよう案内する。
|
||||
48
.gitignore
vendored
Normal file
48
.gitignore
vendored
Normal file
@ -0,0 +1,48 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# 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 (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.env*.local
|
||||
|
||||
# diagram output
|
||||
output/*.html
|
||||
deploy-history.log
|
||||
fb-tool-url.txt
|
||||
fb-api-token.txt
|
||||
240
README.md
Normal file
240
README.md
Normal file
@ -0,0 +1,240 @@
|
||||
# 図解+フィードバックツール
|
||||
|
||||
わからない言葉やしくみを AI に伝えるだけで、初めて聞く人にもわかるように噛み砕いた図解ページを自動で作り、フィードバック機能付きで公開できるツールです。
|
||||
|
||||
- **前提知識がなくても読める** — 専門用語は出てきた場所で必ずわかりやすく解説されます
|
||||
- **文字の壁にならない** — カード・ステップ図・比較表など、目で見てわかる構成です
|
||||
- **テキストを選んでコメント** — 公開した図解で、気になる箇所をマウスで選択するとコメント入力欄が出ます
|
||||
- **優先度が色でわかる** — Must(赤)・Better(黄)・Want(緑)の 3 段階で重要度を伝えられます
|
||||
- **セットアップは 1 回だけ** — 一度設定すれば、以降は図解を公開するたびにフィードバック機能が自動で付きます
|
||||
- **無料で使える** — Vercel と Surge の無料プランだけで動きます
|
||||
|
||||
## しくみ
|
||||
|
||||
```
|
||||
「○○を図解して」と AI に依頼
|
||||
│
|
||||
▼
|
||||
AI が図解 HTML を output/ に生成
|
||||
│
|
||||
▼
|
||||
deploy-diagram.sh が自動で実行
|
||||
├─ fb-tool-url.txt から FB ツール URL を取得
|
||||
├─ </body> の直前に widget.js の <script> を注入
|
||||
└─ Surge にデプロイ
|
||||
│
|
||||
▼
|
||||
公開 URL をブラウザで開く(FB 機能付き)
|
||||
├─ widget.js … 画面右のサイドバー・コメント入力・ハイライト表示
|
||||
└─ /api/comments … コメントの保存・取得
|
||||
│
|
||||
▼
|
||||
Neon Postgres(無料DB)
|
||||
└─ comments テーブル … コメントデータを永続化
|
||||
```
|
||||
|
||||
## セキュリティについて
|
||||
|
||||
API はトークンで保護されています。セットアップ時に自動で生成・設定されるため、追加の操作は不要です。
|
||||
|
||||
- コメント API(`/api/comments`)とページ取得 API(`/api/fetch-page`)はトークンなしではアクセスできません
|
||||
- 図解のデプロイ時に、トークンが自動で埋め込まれます
|
||||
- `fb-api-token.txt` はトークンを保存するファイルです。Git にはコミットされません
|
||||
|
||||
## セットアップ
|
||||
|
||||
初回だけ必要な設定です。AI が順番に案内してくれます。
|
||||
|
||||
```
|
||||
① Node.js を確認
|
||||
↓
|
||||
② Vercel CLI をインストール
|
||||
↓
|
||||
③ 依存関係をインストール
|
||||
↓
|
||||
④ Vercel にログイン
|
||||
↓
|
||||
⑤ API トークンを生成
|
||||
↓
|
||||
⑥ Vercel にデプロイ
|
||||
↓
|
||||
⑦ データベースを追加(ブラウザで操作)
|
||||
↓
|
||||
⑧ マイグレーション実行
|
||||
↓
|
||||
⑨ 完了(fb-tool-url.txt と fb-api-token.txt が生成される)
|
||||
```
|
||||
|
||||
### やり方
|
||||
|
||||
Cursor のチャット欄で次のように伝えてください。
|
||||
|
||||
```
|
||||
セットアップして
|
||||
```
|
||||
|
||||
AI がステップごとに案内してくれます。
|
||||
|
||||
### 必要なもの
|
||||
|
||||
| 必要なもの | 説明 | 費用 |
|
||||
|-----------|------|------|
|
||||
| Node.js | パソコン上でプログラムを動かすための土台。AI が自動でインストールを案内します | 無料 |
|
||||
| Vercel アカウント | フィードバックツールを動かすサーバー。メールアドレスで登録できます | 無料(Hobby プラン) |
|
||||
| Neon Postgres | コメントデータを保存するデータベース。Vercel の画面から追加します | 無料 |
|
||||
| Surge アカウント | 図解を公開するサービス。初回にメールアドレスとパスワードで登録します | 無料 |
|
||||
|
||||
クレジットカードの登録は不要です。
|
||||
|
||||
## 使い方
|
||||
|
||||
### 図解を作る
|
||||
|
||||
Cursor のチャット欄に、知りたいことを書いて送信します。
|
||||
|
||||
```
|
||||
APIについて図解して
|
||||
```
|
||||
|
||||
AI が図解を作り、フィードバック機能付きで自動デプロイします。
|
||||
|
||||
### コメントを付ける
|
||||
|
||||
```
|
||||
① 図解ページで気になるテキストをマウスで選択
|
||||
↓
|
||||
② ポップアップが表示される
|
||||
↓
|
||||
③ 優先度を選ぶ(Must / Better / Want)
|
||||
↓
|
||||
④ コメントを入力して「送信」
|
||||
```
|
||||
|
||||
初回だけ名前の入力を求められます(次回以降は自動で記憶されます)。
|
||||
|
||||
### 優先度の意味
|
||||
|
||||
| 優先度 | 色 | 使いどころ |
|
||||
|--------|-----|-----------|
|
||||
| Must | 赤 | 必ず直すべき箇所(事実の間違い、重大なわかりにくさ) |
|
||||
| Better | 黄 | 直すとよくなる箇所(表現の改善、構成の入れ替え) |
|
||||
| Want | 緑 | 余裕があれば対応(細かい表現、好みの範囲) |
|
||||
|
||||
### コメントを確認する
|
||||
|
||||
画面右端の小さなボタンをクリックすると、サイドバーが開きます。
|
||||
|
||||
- **未解決 / 解決済 / すべて** のフィルタで絞り込めます
|
||||
- コメントカードをクリックすると、本文の該当箇所にジャンプします
|
||||
- 本文のハイライトをクリックすると、サイドバーの対応カードにジャンプします
|
||||
|
||||
### コメントを管理する
|
||||
|
||||
| 操作 | できること |
|
||||
|------|-----------|
|
||||
| 返信 | コメントにスレッド形式で返信 |
|
||||
| 解決 | 対応済みのコメントを解決済みにする(再開もできます) |
|
||||
| 編集 | 自分のコメントの内容・優先度を変更 |
|
||||
| 削除 | 自分のコメントを削除 |
|
||||
| 優先度変更 | 自分のコメントのバッジをクリックで Must → Better → Want を切り替え |
|
||||
|
||||
### コメントをダウンロードする
|
||||
|
||||
サイドバーのヘッダーにあるボタンでエクスポートできます。
|
||||
|
||||
- **JSON** — プログラムで処理したいとき
|
||||
- **MD** — Markdown 形式。優先度ごとに分類されて出力されます
|
||||
|
||||
### 図解の削除
|
||||
|
||||
チャット欄で「この図解を削除して」と伝えてください。AI がデプロイ履歴から URL を特定し、削除を実行します。
|
||||
|
||||
## 以前の図解ツールとの違い
|
||||
|
||||
以前は「図解ツール」と「フィードバックツール」が別々のフォルダでした。
|
||||
|
||||
| | 旧(別々のツール) | 新(このツール) |
|
||||
|---|---|---|
|
||||
| フォルダ | 図解ツール + fb-tool-v2 の 2 つ | 図解コメントツールの 1 つだけ |
|
||||
| FB 機能 | 図解ツールの base.html に手動で widget を埋め込み | デプロイ時に自動注入(base.html はクリーン) |
|
||||
| 再 DL 時 | base.html が上書きされて widget 設定が消える | fb-tool-url.txt で管理。base.html に影響なし |
|
||||
| セットアップ | 図解ツール側で base.html を編集する手順が必要 | 「セットアップして」だけで完了 |
|
||||
|
||||
旧図解ツールをすでに使っている場合は、このフォルダに切り替えるだけで移行できます。`output/` 内の既存の図解はそのまま使えます(再デプロイすれば FB 機能が自動で付きます)。
|
||||
|
||||
## フォルダの中身
|
||||
|
||||
```
|
||||
図解コメントツール/
|
||||
├── .claude/skills/
|
||||
│ ├── creating-visual-explainers/ ← 図解生成スキル
|
||||
│ │ ├── SKILL.md
|
||||
│ │ ├── references/
|
||||
│ │ │ ├── base.html ← 図解テンプレート(widget なし)
|
||||
│ │ │ ├── model-answer.html ← 模範回答
|
||||
│ │ │ └── node-install-guide.md
|
||||
│ │ └── scripts/
|
||||
│ │ └── deploy-diagram.sh ← デプロイ+widget 注入
|
||||
│ └── setup-fb-tool/ ← セットアップスキル
|
||||
│ ├── SKILL.md
|
||||
│ └── references/
|
||||
│ └── node-install-guide.md
|
||||
├── output/ ← 図解の保存先
|
||||
├── public/
|
||||
│ └── widget.js ← フィードバック UI 本体
|
||||
├── src/
|
||||
│ ├── app/api/comments/ ← コメント CRUD API
|
||||
│ └── lib/
|
||||
│ ├── db.ts ← データベース接続
|
||||
│ └── types.ts ← 型定義
|
||||
├── scripts/
|
||||
│ └── migrate.ts ← DB テーブル作成
|
||||
├── fb-tool-url.txt ← セットアップ完了後に生成
|
||||
├── fb-api-token.txt ← API トークン(セットアップ完了後に生成)
|
||||
├── deploy-history.log ← 図解デプロイ履歴
|
||||
├── package.json
|
||||
└── README.md ← この説明書
|
||||
```
|
||||
|
||||
| ファイル / フォルダ | 説明 | 触る必要 |
|
||||
|---|---|---|
|
||||
| `output/` | 図解の保存先 | 見るだけ |
|
||||
| `public/widget.js` | フィードバック UI 本体 | なし |
|
||||
| `src/app/api/comments/` | コメントの CRUD API | なし |
|
||||
| `scripts/migrate.ts` | DB テーブル作成スクリプト(セットアップ時に 1 回だけ実行) | なし |
|
||||
| `fb-tool-url.txt` | FB ツールの URL(セットアップ完了後に自動生成) | なし |
|
||||
| `fb-api-token.txt` | API トークン(セットアップ完了後に自動生成) | なし |
|
||||
| `deploy-history.log` | 図解の公開履歴(自動生成) | なし |
|
||||
|
||||
あなたが直接触る必要があるファイルはありません。
|
||||
セットアップも図解の作成も、AI がすべて案内してくれます。
|
||||
|
||||
## 困ったとき
|
||||
|
||||
### 「セットアップして」と言っても反応しない
|
||||
|
||||
このフォルダ(`図解コメントツール`)を Cursor で開いているか確認してください。
|
||||
フォルダの中の個別ファイルではなく、フォルダごと開く必要があります。
|
||||
|
||||
### Vercel へのデプロイが失敗する
|
||||
|
||||
チャット欄で「デプロイが失敗しました」と伝えてください。AI がエラーの内容を読み取り、対処法を案内してくれます。
|
||||
|
||||
### コメントが保存されない
|
||||
|
||||
データベースのセットアップが完了しているか確認してください。
|
||||
チャット欄で「コメントが保存されません」と伝えると、AI が原因を調べてくれます。
|
||||
|
||||
### フィードバック欄が図解に表示されない
|
||||
|
||||
セットアップが完了していれば、新しく作る図解には自動で付きます。
|
||||
すでに公開済みの図解には反映されないので、再デプロイしてください。
|
||||
|
||||
### その他のトラブル
|
||||
|
||||
何が起きても、まずはチャット欄で AI に状況を伝えてください。
|
||||
AI がエラーの内容を読み取り、次にやるべきことを教えてくれます。
|
||||
|
||||
---
|
||||
|
||||
Mac / Windows どちらでも使えます。AI-Driven School 運営が動作確認済みです。
|
||||
25
components.json
Normal file
25
components.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@ -0,0 +1,18 @@
|
||||
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",
|
||||
]),
|
||||
]);
|
||||
|
||||
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;
|
||||
0
output/.gitkeep
Normal file
0
output/.gitkeep
Normal file
9958
package-lock.json
generated
Normal file
9958
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": "diagram-comment-tool",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"db:migrate": "npx tsx scripts/migrate.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@neondatabase/serverless": "^1.0.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"shadcn": "^4.0.6",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"dotenv": "^17.3.1",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
969
public/test-diagram.html
Normal file
969
public/test-diagram.html
Normal file
@ -0,0 +1,969 @@
|
||||
<!-- このファイルの<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>
|
||||
<script src="/widget.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
30
public/test.html
Normal file
30
public/test.html
Normal file
@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>FB Tool Test</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Hiragino Sans", sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; line-height: 1.8; color: #333; }
|
||||
h1 { border-bottom: 2px solid #e5e5e5; padding-bottom: 8px; }
|
||||
h2 { color: #555; margin-top: 32px; }
|
||||
p { color: #555; margin: 12px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>FBツール テストページ</h1>
|
||||
<p>このページはFBツールの動作確認用です。テキストを選択してコメントを追加できます。</p>
|
||||
|
||||
<h2>サンプルテキスト</h2>
|
||||
<p>AI-Driven Schoolは、AI時代に最適化した企画スキルと開発スキルを実践的に学ぶ6ヶ月間のオンラインプログラムです。講義設計・運営ドキュメント・CS・マーケティング・ツール開発をこのリポジトリで管理しています。</p>
|
||||
<p>受講生はチームワークを通じて、実践的なプロジェクトに取り組みます。フィードバックは成長の鍵であり、適切なタイミングで的確なコメントを届けることが重要です。</p>
|
||||
<p>カリキュラムは全12回で構成され、毎月のテーマに沿って段階的にスキルを積み上げていきます。各回の講義では座学とワークを組み合わせ、すぐに使える知識と経験を提供します。</p>
|
||||
|
||||
<h2>フォーカス連動テスト</h2>
|
||||
<p>サイドバーのカードをクリックすると、対応するハイライト箇所にスクロールします。逆に、本文のハイライトをクリックするとサイドバーのカードにスクロールします。カードにホバーするとハイライトがパルスで点滅します。</p>
|
||||
|
||||
<h2>リサイズテスト</h2>
|
||||
<p>サイドバーの左端にマウスを当てるとカーソルが左右矢印に変わります。ドラッグで幅を変更でき、300pxから800pxの範囲で調整可能です。変更した幅はページをリロードしても維持されます。</p>
|
||||
|
||||
<script src="/widget.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
926
public/widget.js
Normal file
926
public/widget.js
Normal file
@ -0,0 +1,926 @@
|
||||
/**
|
||||
* Icons: Lucide (https://lucide.dev)
|
||||
* ISC License - Copyright (c) Lucide Contributors 2026
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var SCRIPT = document.currentScript;
|
||||
var API_BASE = SCRIPT ? SCRIPT.src.replace(/\/widget\.js.*$/, '') : '';
|
||||
var API_TOKEN = SCRIPT ? (SCRIPT.dataset.token || '') : '';
|
||||
var USERNAME_KEY = 'fb-username';
|
||||
var SIDEBAR_WIDTH_KEY = 'fb-sidebar-width';
|
||||
var PRIORITY_CYCLE = { must: 'better', better: 'want', want: 'must' };
|
||||
var PRIORITY_COLORS = {
|
||||
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)' },
|
||||
};
|
||||
|
||||
var SVG = {
|
||||
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>',
|
||||
};
|
||||
|
||||
function icon(name, size, strokeWidth) {
|
||||
size = size || 14;
|
||||
strokeWidth = strokeWidth || 2;
|
||||
return (SVG[name] || '').replace(/SIZE/g, size).replace(/SW/g, strokeWidth);
|
||||
}
|
||||
|
||||
var state = {
|
||||
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,
|
||||
};
|
||||
|
||||
var slug = (function () {
|
||||
return window.location.href
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/[^a-zA-Z0-9\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF]/g, '_')
|
||||
.substring(0, 100);
|
||||
})();
|
||||
|
||||
function genId() {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substring(2, 8);
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtTime(ts) {
|
||||
var 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' });
|
||||
}
|
||||
|
||||
// ===== API =====
|
||||
function api(method, params) {
|
||||
var url = API_BASE + '/api/comments';
|
||||
var hdrs = { 'Content-Type': 'application/json' };
|
||||
if (API_TOKEN) hdrs['Authorization'] = 'Bearer ' + API_TOKEN;
|
||||
var opts = { method: method, headers: hdrs };
|
||||
if (method === 'GET') {
|
||||
url += '?slug=' + encodeURIComponent(params.slug);
|
||||
} else if (method === 'DELETE') {
|
||||
url += '?id=' + encodeURIComponent(params.id);
|
||||
} else {
|
||||
opts.body = JSON.stringify(params);
|
||||
}
|
||||
return fetch(url, opts).then(function (r) { return r.json(); });
|
||||
}
|
||||
|
||||
function loadComments() {
|
||||
return api('GET', { slug: slug }).then(function (c) {
|
||||
if (Array.isArray(c)) state.comments = c;
|
||||
render();
|
||||
applyHighlights();
|
||||
}).catch(function () {
|
||||
render();
|
||||
applyHighlights();
|
||||
});
|
||||
}
|
||||
|
||||
// ===== CSS =====
|
||||
function injectStyles() {
|
||||
var style = document.createElement('style');
|
||||
style.id = 'fb-widget-styles';
|
||||
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}',
|
||||
|
||||
/* Toggle button */
|
||||
'#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}',
|
||||
|
||||
/* Sidebar */
|
||||
'#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}',
|
||||
|
||||
/* Resize handle */
|
||||
'.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}',
|
||||
|
||||
/* Header */
|
||||
'.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)}',
|
||||
|
||||
/* User row */
|
||||
'.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)}',
|
||||
|
||||
/* Filters */
|
||||
'.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)}',
|
||||
|
||||
/* List */
|
||||
'.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)}',
|
||||
|
||||
/* Card */
|
||||
'.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}',
|
||||
|
||||
/* Card header */
|
||||
'.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}',
|
||||
|
||||
/* Priority badge */
|
||||
'.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)}',
|
||||
|
||||
/* Quote */
|
||||
'.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)}',
|
||||
|
||||
/* Body */
|
||||
'.fb-body{font-size:13px;color:var(--fb-muted-fg);line-height:1.6;margin-bottom:6px;white-space:pre-wrap}',
|
||||
|
||||
/* Actions */
|
||||
'.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}',
|
||||
|
||||
/* Replies */
|
||||
'.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}',
|
||||
|
||||
/* Reply input */
|
||||
'.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}',
|
||||
|
||||
/* Edit area */
|
||||
'.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}',
|
||||
|
||||
/* Popup */
|
||||
'.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}',
|
||||
|
||||
/* Name dialog */
|
||||
'.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}',
|
||||
|
||||
/* Inline name edit */
|
||||
'.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)}',
|
||||
|
||||
/* Highlights */
|
||||
'.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}',
|
||||
].join('\n');
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// ===== DOM =====
|
||||
function el(tag, attrs, children) {
|
||||
var e = document.createElement(tag);
|
||||
if (attrs) Object.keys(attrs).forEach(function (k) {
|
||||
if (k === 'className') e.className = attrs[k];
|
||||
else if (k === 'innerHTML') e.innerHTML = attrs[k];
|
||||
else if (k.startsWith('on')) e.addEventListener(k.substring(2).toLowerCase(), attrs[k]);
|
||||
else e.setAttribute(k, attrs[k]);
|
||||
});
|
||||
if (children) {
|
||||
if (typeof children === 'string') e.textContent = children;
|
||||
else if (Array.isArray(children)) children.forEach(function (c) { if (c) e.appendChild(c); });
|
||||
else e.appendChild(children);
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
// ===== Render =====
|
||||
function render() {
|
||||
renderToggle();
|
||||
renderSidebar();
|
||||
renderPopup();
|
||||
renderNameDialog();
|
||||
}
|
||||
|
||||
function renderToggle() {
|
||||
var btn = document.getElementById('fb-toggle');
|
||||
if (!btn) {
|
||||
btn = el('button', { id: 'fb-toggle', onClick: toggleSidebar });
|
||||
document.body.appendChild(btn);
|
||||
}
|
||||
var unresolvedCount = state.comments.filter(function (c) { return !c.parentId && !c.resolved; }).length;
|
||||
var h = '<span class="fb-toggle-icon">' + icon('panelRight', 16);
|
||||
if (unresolvedCount > 0) h += '<span class="fb-badge">' + unresolvedCount + '</span>';
|
||||
h += '</span>';
|
||||
h += '<span class="fb-toggle-label">コメント</span>';
|
||||
btn.innerHTML = h;
|
||||
btn.style.display = state.sidebarOpen ? 'none' : '';
|
||||
}
|
||||
|
||||
function applySidebarWidth() {
|
||||
var sb = document.getElementById('fb-sidebar');
|
||||
if (!sb) return;
|
||||
var 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' : '';
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
state.sidebarOpen = !state.sidebarOpen;
|
||||
var 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(function (c) { return !c.parentId; }); }
|
||||
function getReplies(id) { return state.comments.filter(function (c) { return c.parentId === id; }).sort(function (a, b) { return a.timestamp - b.timestamp; }); }
|
||||
|
||||
function filtered() {
|
||||
var tl = topLevel();
|
||||
if (state.filter === 'resolved') return tl.filter(function (c) { return c.resolved; });
|
||||
if (state.filter === 'all') return tl;
|
||||
return tl.filter(function (c) { return !c.resolved; });
|
||||
}
|
||||
|
||||
function renderSidebar() {
|
||||
var sb = document.getElementById('fb-sidebar');
|
||||
var isNew = !sb;
|
||||
if (isNew) {
|
||||
sb = el('div', { id: 'fb-sidebar' });
|
||||
document.body.appendChild(sb);
|
||||
sb.addEventListener('mouseover', function (e) {
|
||||
var card = e.target.closest('.fb-card');
|
||||
if (!card) return;
|
||||
var mark = document.querySelector('.fb-highlight[data-comment-id="' + card.dataset.id + '"]');
|
||||
if (mark) mark.classList.add('fb-pulse');
|
||||
});
|
||||
sb.addEventListener('mouseout', function (e) {
|
||||
var card = e.target.closest('.fb-card');
|
||||
if (!card) return;
|
||||
if (e.relatedTarget && card.contains(e.relatedTarget)) return;
|
||||
var mark = document.querySelector('.fb-highlight[data-comment-id="' + card.dataset.id + '"]');
|
||||
if (mark) mark.classList.remove('fb-pulse');
|
||||
});
|
||||
}
|
||||
applySidebarWidth();
|
||||
|
||||
var tl = topLevel();
|
||||
var counts = {
|
||||
unresolved: tl.filter(function (c) { return !c.resolved; }).length,
|
||||
resolved: tl.filter(function (c) { return c.resolved; }).length,
|
||||
all: tl.length,
|
||||
};
|
||||
|
||||
var html = '<div class="fb-resize-handle"></div>';
|
||||
|
||||
// Header
|
||||
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>';
|
||||
|
||||
// User
|
||||
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>';
|
||||
}
|
||||
}
|
||||
|
||||
// Filters
|
||||
html += '<div class="fb-filters">';
|
||||
['unresolved', 'resolved', 'all'].forEach(function (f) {
|
||||
var 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>';
|
||||
|
||||
// List
|
||||
var items = filtered().sort(function (a, b) { return 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(function (c) { html += renderCard(c); });
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
sb.innerHTML = html;
|
||||
if (isNew) bindSidebarEvents(sb);
|
||||
}
|
||||
|
||||
function renderCard(c) {
|
||||
var isOwn = c.author === state.username;
|
||||
var pc = PRIORITY_COLORS[c.priority] || PRIORITY_COLORS.want;
|
||||
|
||||
var h = '<div class="fb-card' + (c.resolved ? ' resolved' : '') + '" style="border-left-color:' + pc.bg + '" data-id="' + c.id + '">';
|
||||
|
||||
// Header
|
||||
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>';
|
||||
|
||||
// Quote
|
||||
if (c.quote) {
|
||||
var 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>';
|
||||
}
|
||||
|
||||
// Body / Edit
|
||||
if (state.editingId === c.id) {
|
||||
h += '<div class="fb-edit-area">';
|
||||
h += '<div class="fb-edit-pri">';
|
||||
['must', 'better', 'want'].forEach(function (p) {
|
||||
var sel = state.editPriority === p;
|
||||
var 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>';
|
||||
}
|
||||
|
||||
// Actions
|
||||
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>';
|
||||
}
|
||||
|
||||
// Replies
|
||||
var replies = getReplies(c.id);
|
||||
if (replies.length > 0) {
|
||||
h += '<div class="fb-replies">';
|
||||
replies.forEach(function (r) {
|
||||
var 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>';
|
||||
}
|
||||
|
||||
// Reply input
|
||||
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;
|
||||
}
|
||||
|
||||
function bindSidebarEvents(sb) {
|
||||
sb.addEventListener('click', function (e) {
|
||||
var t = e.target.closest('[data-action]');
|
||||
if (t) {
|
||||
var action = t.dataset.action;
|
||||
var id = t.dataset.id;
|
||||
|
||||
if (action === 'close') { toggleSidebar(); }
|
||||
else if (action === 'edit-name') { state.editingName = true; state.nameInput = state.username; render(); }
|
||||
else if (action === 'cycle' && id) { cyclePriority(id); }
|
||||
else if (action === 'scroll-quote' && id) { scrollToQuote(id); }
|
||||
else if (action === 'reply' && id) { state.replyingTo = state.replyingTo === id ? null : id; state.replyText = ''; render(); }
|
||||
else if (action === 'resolve' && id) { resolveComment(id); }
|
||||
else if (action === 'edit' && id) { var c = state.comments.find(function (x) { return x.id === id; }); if (c) { state.editingId = id; state.editContent = c.content; state.editPriority = c.priority; render(); } }
|
||||
else if (action === 'delete' && id) { deleteComment(id); }
|
||||
else if (action === 'delete-reply' && id) { deleteReply(id); }
|
||||
else if (action === 'cancel-edit') { state.editingId = null; render(); }
|
||||
else if (action === 'save-edit' && id) { saveEdit(id); }
|
||||
else if (action === 'submit-reply' && id) { submitReply(id); }
|
||||
else if (action === 'set-edit-pri') { state.editPriority = t.dataset.pri; render(); }
|
||||
return;
|
||||
}
|
||||
var filterBtn = e.target.closest('[data-filter]');
|
||||
if (filterBtn) { state.filter = filterBtn.dataset.filter; render(); return; }
|
||||
|
||||
var card = e.target.closest('.fb-card');
|
||||
if (card && card.dataset.id) {
|
||||
scrollToQuote(card.dataset.id);
|
||||
}
|
||||
});
|
||||
|
||||
sb.addEventListener('mousedown', function (e) {
|
||||
if (!e.target.closest('.fb-resize-handle')) return;
|
||||
e.preventDefault();
|
||||
var startX = e.clientX;
|
||||
var startWidth = state.sidebarWidth;
|
||||
var handle = e.target.closest('.fb-resize-handle');
|
||||
document.body.classList.add('fb-resizing');
|
||||
handle.classList.add('active');
|
||||
function onMove(ev) {
|
||||
state.sidebarWidth = Math.min(800, Math.max(300, startWidth + (startX - ev.clientX)));
|
||||
var 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', function (e) {
|
||||
var t = e.target;
|
||||
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; var btn = sb.querySelector('[data-action="submit-reply"]'); if (btn) btn.disabled = !state.replyText.trim(); }
|
||||
});
|
||||
|
||||
sb.addEventListener('keydown', function (e) {
|
||||
var t = e.target;
|
||||
if (t.dataset.action === 'name-input') {
|
||||
if (e.key === 'Enter') { finishNameEdit(); }
|
||||
else if (e.key === 'Escape') { state.editingName = false; render(); }
|
||||
}
|
||||
if (t.dataset.action === 'reply-textarea' && (e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
var id = sb.querySelector('[data-action="submit-reply"]')?.dataset.id;
|
||||
if (id) submitReply(id);
|
||||
}
|
||||
});
|
||||
|
||||
sb.addEventListener('blur', function (e) {
|
||||
if (e.target.dataset?.action === 'name-input') { finishNameEdit(); }
|
||||
}, true);
|
||||
|
||||
}
|
||||
|
||||
// ===== Popup =====
|
||||
function renderPopup() {
|
||||
var popup = document.getElementById('fb-popup');
|
||||
var isNew = !popup;
|
||||
if (isNew) {
|
||||
popup = el('div', { id: 'fb-popup', className: 'fb-popup' });
|
||||
document.body.appendChild(popup);
|
||||
popup.addEventListener('click', function (e) {
|
||||
var t = e.target.closest('[data-action]');
|
||||
if (!t) return;
|
||||
if (t.dataset.action === 'cancel-popup') { closePopup(); }
|
||||
else if (t.dataset.action === 'set-popup-pri') { state.popupPriority = t.dataset.pri; render(); }
|
||||
else if (t.dataset.action === 'submit-popup') { submitComment(state.popupPriority); }
|
||||
});
|
||||
popup.addEventListener('input', function (e) {
|
||||
if (e.target.dataset.action === 'popup-textarea') state.popupContent = e.target.value;
|
||||
});
|
||||
popup.addEventListener('keydown', function (e) {
|
||||
if (e.target.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');
|
||||
|
||||
var q = state.selectedText.length > 120 ? state.selectedText.substring(0, 120) + '...' : state.selectedText;
|
||||
var selPc = PRIORITY_COLORS[state.popupPriority];
|
||||
var 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'].forEach(function (p) {
|
||||
var pc = PRIORITY_COLORS[p];
|
||||
var sel = state.popupPriority === p;
|
||||
var style = sel
|
||||
? 'background:' + pc.light + ';color:' + pc.bg + ';border-color:' + pc.bg
|
||||
: 'background:var(--fb-bg);color:var(--fb-muted-fg);border-color:var(--fb-border)';
|
||||
h += '<button style="' + style + '" data-action="set-popup-pri" data-pri="' + p + '">' + p.charAt(0).toUpperCase() + p.slice(1) + '</button>';
|
||||
});
|
||||
h += '</div>';
|
||||
h += '<div class="fb-popup-actions">';
|
||||
h += '<button class="cancel" data-action="cancel-popup">キャンセル</button>';
|
||||
h += '<button class="submit" style="background:' + selPc.bg + '" data-action="submit-popup">送信</button>';
|
||||
h += '</div>';
|
||||
popup.innerHTML = h;
|
||||
|
||||
var rect = state.selectedRect;
|
||||
var pw = 400, ph = 300, m = 12;
|
||||
var 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);
|
||||
var availW = state.sidebarOpen ? window.innerWidth - state.sidebarWidth : window.innerWidth;
|
||||
var left = Math.max(m, Math.min(rect.left, availW - pw - m));
|
||||
popup.style.top = top + 'px';
|
||||
popup.style.left = left + 'px';
|
||||
}
|
||||
|
||||
function closePopup() {
|
||||
state.selectedText = '';
|
||||
state.selectedRect = null;
|
||||
state.popupContent = '';
|
||||
state.popupPriority = 'must';
|
||||
render();
|
||||
}
|
||||
|
||||
function renderNameDialog() {
|
||||
var existing = document.getElementById('fb-name-overlay');
|
||||
if (state.username) { if (existing) existing.remove(); return; }
|
||||
if (existing) return;
|
||||
var 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);
|
||||
var input = document.getElementById('fb-name-field');
|
||||
var btn = document.getElementById('fb-name-submit');
|
||||
input.addEventListener('input', function () { btn.disabled = !input.value.trim(); });
|
||||
input.addEventListener('keydown', function (e) { if (e.key === 'Enter' && input.value.trim()) { setName(input.value.trim()); } });
|
||||
btn.addEventListener('click', function () { if (input.value.trim()) setName(input.value.trim()); });
|
||||
setTimeout(function () { input.focus(); }, 100);
|
||||
}
|
||||
|
||||
function setName(name) {
|
||||
state.username = name;
|
||||
localStorage.setItem(USERNAME_KEY, name);
|
||||
var overlay = document.getElementById('fb-name-overlay');
|
||||
if (overlay) overlay.remove();
|
||||
render();
|
||||
}
|
||||
|
||||
function finishNameEdit() {
|
||||
if (state.nameInput.trim() && state.nameInput.trim() !== state.username) {
|
||||
var oldName = state.username;
|
||||
state.username = state.nameInput.trim();
|
||||
localStorage.setItem(USERNAME_KEY, state.username);
|
||||
api('PUT', { id: '_rename', author: state.username, oldAuthor: oldName, projectSlug: slug }).then(loadComments);
|
||||
}
|
||||
state.editingName = false;
|
||||
render();
|
||||
}
|
||||
|
||||
// ===== Actions =====
|
||||
function submitComment(priority) {
|
||||
if (!state.username || !state.selectedText) return;
|
||||
var c = {
|
||||
id: genId(), author: state.username, type: 'comment',
|
||||
quote: state.selectedText, quoteContext: state.selectedQuoteContext,
|
||||
content: state.popupContent.trim(),
|
||||
priority: priority, parentId: null, pageUrl: window.location.href,
|
||||
projectSlug: slug, timestamp: Date.now(),
|
||||
};
|
||||
state.comments.push(c);
|
||||
closePopup();
|
||||
applyHighlights();
|
||||
api('POST', c).then(loadComments);
|
||||
}
|
||||
|
||||
function resolveComment(id) {
|
||||
var c = state.comments.find(function (x) { return x.id === id; });
|
||||
if (!c) return;
|
||||
var now = !c.resolved;
|
||||
c.resolved = now;
|
||||
c.resolvedBy = now ? state.username : null;
|
||||
c.resolvedAt = now ? Date.now() : null;
|
||||
render(); applyHighlights();
|
||||
api('PUT', { id: id, resolved: now, resolvedBy: c.resolvedBy, resolvedAt: c.resolvedAt });
|
||||
}
|
||||
|
||||
function cyclePriority(id) {
|
||||
var c = state.comments.find(function (x) { return x.id === id; });
|
||||
if (!c || c.author !== state.username) return;
|
||||
c.priority = PRIORITY_CYCLE[c.priority] || 'must';
|
||||
render(); applyHighlights();
|
||||
api('PUT', { id: id, priority: c.priority });
|
||||
}
|
||||
|
||||
function deleteComment(id) {
|
||||
state.comments = state.comments.filter(function (c) { return c.id !== id && c.parentId !== id; });
|
||||
render(); applyHighlights();
|
||||
api('DELETE', { id: id });
|
||||
}
|
||||
|
||||
function deleteReply(id) {
|
||||
state.comments = state.comments.filter(function (c) { return c.id !== id; });
|
||||
render();
|
||||
api('DELETE', { id: id });
|
||||
}
|
||||
|
||||
function saveEdit(id) {
|
||||
var c = state.comments.find(function (x) { return x.id === id; });
|
||||
if (!c) return;
|
||||
c.content = state.editContent;
|
||||
c.priority = state.editPriority;
|
||||
state.editingId = null;
|
||||
render(); applyHighlights();
|
||||
api('PUT', { id: id, content: c.content, priority: c.priority });
|
||||
}
|
||||
|
||||
function submitReply(parentId) {
|
||||
if (!state.replyText.trim() || !state.username) return;
|
||||
var r = {
|
||||
id: genId(), author: state.username, type: 'comment',
|
||||
quote: '', quoteContext: { beforeText: '', afterText: '' },
|
||||
content: state.replyText.trim(), priority: 'want',
|
||||
parentId: parentId, pageUrl: window.location.href,
|
||||
projectSlug: slug, timestamp: Date.now(),
|
||||
};
|
||||
state.comments.push(r);
|
||||
state.replyingTo = null;
|
||||
state.replyText = '';
|
||||
render();
|
||||
api('POST', r);
|
||||
}
|
||||
|
||||
// ===== Text Selection =====
|
||||
function setupTextSelection() {
|
||||
document.addEventListener('mouseup', function (e) {
|
||||
if (e.target.closest('#fb-sidebar,#fb-toggle,#fb-popup')) return;
|
||||
var sel = window.getSelection();
|
||||
var text = sel?.toString().trim();
|
||||
if (!text || !sel || sel.rangeCount === 0 || !state.username) return;
|
||||
var range = sel.getRangeAt(0);
|
||||
state.selectedText = text.replace(/[\s\u00A0]+/g, ' ').substring(0, 200);
|
||||
state.selectedRect = range.getBoundingClientRect();
|
||||
state.selectedQuoteContext = getQuoteContext(range);
|
||||
state.popupContent = '';
|
||||
render();
|
||||
});
|
||||
document.addEventListener('mousedown', function (e) {
|
||||
if (e.target.closest('#fb-popup')) return;
|
||||
if (state.selectedRect) closePopup();
|
||||
});
|
||||
}
|
||||
|
||||
function getQuoteContext(range) {
|
||||
var before = '', after = '';
|
||||
try {
|
||||
var br = document.createRange();
|
||||
br.setStart(document.body, 0);
|
||||
br.setEnd(range.startContainer, range.startOffset);
|
||||
before = br.toString().slice(-50).replace(/[\s\u00A0]+/g, ' ').trim();
|
||||
var 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 (e) { /* ignore */ }
|
||||
return { beforeText: before, afterText: after };
|
||||
}
|
||||
|
||||
// ===== Highlights =====
|
||||
function collectTextNodes() {
|
||||
var nodes = [];
|
||||
var tw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: function (n) {
|
||||
var 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;
|
||||
}
|
||||
});
|
||||
var n;
|
||||
while ((n = tw.nextNode())) nodes.push(n);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function mapNormToOrig(orig, normStart, normEnd) {
|
||||
var ws = /[\s\u00A0]+/g;
|
||||
var normalized = orig.replace(ws, ' ');
|
||||
if (normStart >= normalized.length) return null;
|
||||
var origIdx = 0, normIdx = 0;
|
||||
var 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, start, end, comment) {
|
||||
var orig = node.textContent;
|
||||
var before = document.createTextNode(orig.substring(0, start));
|
||||
var mark = document.createElement('mark');
|
||||
mark.className = 'fb-highlight fb-highlight-' + comment.priority;
|
||||
mark.dataset.commentId = comment.id;
|
||||
mark.textContent = orig.substring(start, end);
|
||||
(function (id) {
|
||||
mark.addEventListener('click', function () { scrollToCard(id); });
|
||||
})(comment.id);
|
||||
var 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);
|
||||
}
|
||||
|
||||
function applyHighlights() {
|
||||
document.querySelectorAll('.fb-highlight').forEach(function (el) {
|
||||
var t = document.createTextNode(el.textContent);
|
||||
el.parentNode.replaceChild(t, el);
|
||||
});
|
||||
document.body.normalize();
|
||||
|
||||
state.comments.filter(function (c) { return !c.parentId && !c.resolved && c.quote && c.quote.length >= 2; }).forEach(function (c) {
|
||||
var search = c.quote.replace(/[\s\u00A0]+/g, ' ').trim();
|
||||
var textNodes = collectTextNodes();
|
||||
var found = false;
|
||||
|
||||
for (var i = 0; i < textNodes.length; i++) {
|
||||
var node = textNodes[i];
|
||||
var orig = node.textContent;
|
||||
|
||||
var di = orig.indexOf(search);
|
||||
if (di !== -1) {
|
||||
try { wrapTextRange(node, di, di + search.length, c); found = true; } catch (e) {}
|
||||
break;
|
||||
}
|
||||
|
||||
var norm = orig.replace(/[\s\u00A0]+/g, ' ');
|
||||
var ni = norm.indexOf(search);
|
||||
if (ni === -1) continue;
|
||||
var range = mapNormToOrig(orig, ni, ni + search.length);
|
||||
if (range) {
|
||||
try { wrapTextRange(node, range[0], range[1], c); found = true; } catch (e) {}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
var concat = '';
|
||||
var nodeMap = [];
|
||||
for (var j = 0; j < textNodes.length; j++) {
|
||||
var s = concat.length;
|
||||
concat += textNodes[j].textContent;
|
||||
nodeMap.push({ node: textNodes[j], start: s, end: concat.length });
|
||||
}
|
||||
var concatNorm = concat.replace(/[\s\u00A0]+/g, ' ');
|
||||
var ci = concatNorm.indexOf(search);
|
||||
if (ci !== -1) {
|
||||
var range = mapNormToOrig(concat, ci, ci + search.length);
|
||||
if (range) {
|
||||
var mStart = range[0], mEnd = range[1];
|
||||
for (var k = nodeMap.length - 1; k >= 0; k--) {
|
||||
var nm = nodeMap[k];
|
||||
if (nm.end <= mStart || nm.start >= mEnd) continue;
|
||||
var ls = Math.max(0, mStart - nm.start);
|
||||
var le = Math.min(nm.node.textContent.length, mEnd - nm.start);
|
||||
try { wrapTextRange(nm.node, ls, le, c); } catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var PRIORITY_FLASH = { must: 'rgba(239,68,68,0.4)', better: 'rgba(245,158,11,0.4)', want: 'rgba(34,197,94,0.4)' };
|
||||
|
||||
function scrollToQuote(id) {
|
||||
var mark = document.querySelector('.fb-highlight[data-comment-id="' + id + '"]');
|
||||
if (!mark) return;
|
||||
mark.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
var c = state.comments.find(function (x) { return x.id === id; });
|
||||
var flashColor = c ? (PRIORITY_FLASH[c.priority] || PRIORITY_FLASH.want) : PRIORITY_FLASH.want;
|
||||
var orig = mark.style.backgroundColor;
|
||||
mark.style.backgroundColor = flashColor;
|
||||
mark.style.transition = 'background-color 0.3s';
|
||||
setTimeout(function () { mark.style.backgroundColor = orig; }, 1500);
|
||||
}
|
||||
|
||||
function scrollToCard(id) {
|
||||
var wasClosed = !state.sidebarOpen;
|
||||
if (wasClosed) { toggleSidebar(); }
|
||||
setTimeout(function () {
|
||||
var card = document.querySelector('.fb-card[data-id="' + id + '"]');
|
||||
if (!card) return;
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
card.classList.add('fb-focused');
|
||||
setTimeout(function () { card.classList.remove('fb-focused'); }, 1500);
|
||||
}, wasClosed ? 350 : 0);
|
||||
}
|
||||
|
||||
// ===== Init =====
|
||||
function init() {
|
||||
injectStyles();
|
||||
render();
|
||||
setupTextSelection();
|
||||
loadComments();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
44
scripts/migrate.ts
Normal file
44
scripts/migrate.ts
Normal file
@ -0,0 +1,44 @@
|
||||
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`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);
|
||||
});
|
||||
124
src/app/api/comments/route.ts
Normal file
124
src/app/api/comments/route.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDb } from '@/lib/db';
|
||||
|
||||
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;
|
||||
if (!token) return true; // トークン未設定時はスキップ(開発環境向け)
|
||||
|
||||
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 } = 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)
|
||||
VALUES (${id}, ${author}, ${type || 'comment'}, ${quote || ''}, ${quoteContext?.beforeText || ''}, ${quoteContext?.afterText || ''}, ${content || ''}, ${priority || 'want'}, ${parentId || null}, ${pageUrl || ''}, ${projectSlug}, ${timestamp || Date.now()})
|
||||
`;
|
||||
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
if (!verifyToken(request)) return json({ error: 'Unauthorized' }, 403);
|
||||
const body = await request.json();
|
||||
const { id, ...updates } = body;
|
||||
|
||||
if (!id) return json({ error: 'id is required' }, 400);
|
||||
|
||||
const sql = getDb();
|
||||
|
||||
if (updates.content !== undefined && updates.priority !== undefined) {
|
||||
await sql`
|
||||
UPDATE comments SET content = ${updates.content}, priority = ${updates.priority}, updated_at = ${Date.now()} WHERE id = ${id}
|
||||
`;
|
||||
} else if (updates.resolved !== undefined) {
|
||||
await sql`
|
||||
UPDATE comments SET resolved = ${updates.resolved}, resolved_by = ${updates.resolvedBy || null}, resolved_at = ${updates.resolvedAt || null} WHERE id = ${id}
|
||||
`;
|
||||
} else if (updates.priority !== undefined) {
|
||||
await sql`
|
||||
UPDATE comments SET priority = ${updates.priority} WHERE id = ${id}
|
||||
`;
|
||||
} else if (updates.author !== undefined) {
|
||||
const slug = updates.projectSlug;
|
||||
const oldAuthor = updates.oldAuthor;
|
||||
if (slug && oldAuthor) {
|
||||
await sql`
|
||||
UPDATE comments SET author = ${updates.author} WHERE project_slug = ${slug} AND author = ${oldAuthor}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
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>) {
|
||||
return {
|
||||
id: row.id as string,
|
||||
author: row.author as string,
|
||||
type: row.type as string,
|
||||
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 string,
|
||||
parentId: (row.parent_id as string) || null,
|
||||
resolved: row.resolved as boolean,
|
||||
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,
|
||||
};
|
||||
}
|
||||
92
src/app/api/fetch-page/route.ts
Normal file
92
src/app/api/fetch-page/route.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const BLOCKED_HOSTS = new Set([
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'0.0.0.0',
|
||||
'::1',
|
||||
'[::1]',
|
||||
]);
|
||||
|
||||
function isPrivateIP(hostname: string): boolean {
|
||||
if (BLOCKED_HOSTS.has(hostname)) return true;
|
||||
|
||||
const parts = hostname.split('.').map(Number);
|
||||
if (parts.length !== 4 || parts.some((p) => isNaN(p))) return false;
|
||||
|
||||
if (parts[0] === 10) return true;
|
||||
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
|
||||
if (parts[0] === 192 && parts[1] === 168) return true;
|
||||
if (parts[0] === 169 && parts[1] === 254) return true;
|
||||
if (parts[0] === 0) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function validateUrl(raw: string): { ok: true; url: URL } | { ok: false; reason: string } {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
return { ok: false, reason: 'Invalid URL' };
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'https:') {
|
||||
return { ok: false, reason: 'Only https:// URLs are allowed' };
|
||||
}
|
||||
|
||||
if (isPrivateIP(parsed.hostname)) {
|
||||
return { ok: false, reason: 'Private/reserved addresses are not allowed' };
|
||||
}
|
||||
|
||||
return { ok: true, url: parsed };
|
||||
}
|
||||
|
||||
function escapeHtmlAttr(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function verifyToken(request: NextRequest): boolean {
|
||||
const token = process.env.API_TOKEN;
|
||||
if (!token) return true;
|
||||
|
||||
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 NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
|
||||
}
|
||||
|
||||
const raw = request.nextUrl.searchParams.get('url');
|
||||
if (!raw) {
|
||||
return NextResponse.json({ error: 'url parameter is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = validateUrl(raw);
|
||||
if (!result.ok) {
|
||||
return NextResponse.json({ error: result.reason }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(result.url.href, { next: { revalidate: 60 } });
|
||||
const html = await res.text();
|
||||
|
||||
const baseTag = `<base href="${escapeHtmlAttr(result.url.href)}">`;
|
||||
const injected = html.replace('<head>', `<head>${baseTag}`);
|
||||
|
||||
return new NextResponse(injected, {
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Unknown error';
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
129
src/app/globals.css
Normal file
129
src/app/globals.css
Normal file
@ -0,0 +1,129 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
35
src/app/layout.tsx
Normal file
35
src/app/layout.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import './globals.css';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: '--font-geist-sans',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: '--font-geist-mono',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'FB Tool',
|
||||
description: 'Feedback Tool for reviewing web pages',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="ja">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
41
src/app/page.tsx
Normal file
41
src/app/page.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { StoreProvider, useStore } from '@/lib/store';
|
||||
import { ContentViewer, ContentViewerHandle } from '@/components/content-viewer';
|
||||
import { CommentSidebar } from '@/components/comment-sidebar';
|
||||
import { NameDialog } from '@/components/name-dialog';
|
||||
|
||||
function AppContent() {
|
||||
const { username } = useStore();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const viewerRef = useRef<ContentViewerHandle>(null);
|
||||
|
||||
const handleScrollToQuote = useCallback((commentId: string) => {
|
||||
viewerRef.current?.scrollToQuote(commentId);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!username && <NameDialog />}
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<div className="flex-1">
|
||||
<ContentViewer ref={viewerRef} />
|
||||
</div>
|
||||
<CommentSidebar
|
||||
open={sidebarOpen}
|
||||
onToggle={() => setSidebarOpen(!sidebarOpen)}
|
||||
onScrollToQuote={handleScrollToQuote}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<StoreProvider>
|
||||
<AppContent />
|
||||
</StoreProvider>
|
||||
);
|
||||
}
|
||||
253
src/components/comment-card.tsx
Normal file
253
src/components/comment-card.tsx
Normal file
@ -0,0 +1,253 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Comment, Priority } from '@/lib/types';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MessageSquare, Check, RotateCcw, Pencil, Trash2 } from 'lucide-react';
|
||||
|
||||
const PRIORITY_CONFIG: Record<Priority, { label: string; badgeClass: string; borderClass: string }> = {
|
||||
must: { label: 'Must', badgeClass: 'bg-red-500 text-white border-red-500', borderClass: 'border-l-red-500' },
|
||||
better: { label: 'Better', badgeClass: 'bg-amber-500 text-white border-amber-500', borderClass: 'border-l-amber-500' },
|
||||
want: { label: 'Want', badgeClass: 'bg-green-500 text-white border-green-500', borderClass: 'border-l-green-500' },
|
||||
};
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
if (diff < 60_000) return 'たった今';
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}分前`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}時間前`;
|
||||
return new Date(ts).toLocaleDateString('ja-JP', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
interface CommentCardProps {
|
||||
comment: Comment;
|
||||
replies: Comment[];
|
||||
onScrollToQuote?: (commentId: string) => void;
|
||||
}
|
||||
|
||||
export function CommentCard({ comment, replies, onScrollToQuote }: CommentCardProps) {
|
||||
const store = useStore();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editContent, setEditContent] = useState(comment.content);
|
||||
const [editPriority, setEditPriority] = useState(comment.priority);
|
||||
const [replyText, setReplyText] = useState('');
|
||||
const [showReply, setShowReply] = useState(false);
|
||||
|
||||
const isOwn = comment.author === store.username;
|
||||
const isStrikethrough = comment.type === 'strikethrough';
|
||||
const priorityCfg = PRIORITY_CONFIG[comment.priority];
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
store.editComment(comment.id, editContent, editPriority);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const handleSubmitReply = () => {
|
||||
if (!replyText.trim()) return;
|
||||
store.addComment({
|
||||
quote: '',
|
||||
quoteContext: { beforeText: '', afterText: '' },
|
||||
content: replyText.trim(),
|
||||
priority: 'want',
|
||||
parentId: comment.id,
|
||||
});
|
||||
setReplyText('');
|
||||
setShowReply(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-l-[3px] bg-background p-3.5 transition-shadow hover:shadow-sm',
|
||||
isStrikethrough ? 'border-l-muted-foreground/40' : priorityCfg.borderClass,
|
||||
comment.resolved && 'opacity-50 hover:opacity-80'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-5.5 w-5.5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
|
||||
{comment.author.charAt(0)}
|
||||
</div>
|
||||
<span className="text-[13px] font-semibold">{comment.author}</span>
|
||||
<span className="text-[11px] text-muted-foreground">{formatTime(comment.timestamp)}</span>
|
||||
{comment.resolved && (
|
||||
<span className="text-[11px] text-green-400">✓ 解決済</span>
|
||||
)}
|
||||
</div>
|
||||
{isStrikethrough ? (
|
||||
<Badge variant="outline" className="border-muted-foreground/30 text-muted-foreground text-[11px]">
|
||||
取消
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-[11px] font-bold transition-all',
|
||||
priorityCfg.badgeClass,
|
||||
isOwn && 'cursor-pointer hover:scale-110 hover:shadow-sm hover:ring-2 hover:ring-white/20'
|
||||
)}
|
||||
onClick={() => isOwn && store.cyclePriority(comment.id)}
|
||||
title={isOwn ? 'クリックで優先度を変更' : ''}
|
||||
>
|
||||
{priorityCfg.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quote */}
|
||||
{comment.quote && (
|
||||
<div
|
||||
className="mb-2 cursor-pointer rounded-r border-l-2 border-primary bg-muted px-2.5 py-1.5 text-xs italic text-muted-foreground hover:bg-muted/80"
|
||||
onClick={() => onScrollToQuote?.(comment.id)}
|
||||
>
|
||||
“{comment.quote.length > 100 ? comment.quote.substring(0, 100) + '...' : comment.quote}”
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body / Edit */}
|
||||
{editing ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-1.5">
|
||||
{(['must', 'better', 'want'] as Priority[]).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setEditPriority(p)}
|
||||
className={cn(
|
||||
'rounded px-2 py-0.5 text-xs font-bold border transition-all',
|
||||
editPriority === p ? PRIORITY_CONFIG[p].badgeClass : 'border-border text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{PRIORITY_CONFIG[p].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Textarea
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
className="min-h-[60px] text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}>
|
||||
キャンセル
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSaveEdit}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mb-2 whitespace-pre-wrap text-sm text-muted-foreground leading-relaxed">
|
||||
{comment.content}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{!editing && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 cursor-pointer gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowReply(!showReply)}
|
||||
>
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
返信
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 cursor-pointer gap-1 px-2 text-xs text-muted-foreground hover:text-green-400"
|
||||
onClick={() => store.resolveComment(comment.id)}
|
||||
>
|
||||
{comment.resolved ? <RotateCcw className="h-3 w-3" /> : <Check className="h-3 w-3" />}
|
||||
{comment.resolved ? '再開' : '解決'}
|
||||
</Button>
|
||||
{isOwn && !isStrikethrough && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 cursor-pointer gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setEditContent(comment.content);
|
||||
setEditPriority(comment.priority);
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
編集
|
||||
</Button>
|
||||
)}
|
||||
{isOwn && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 cursor-pointer gap-1 px-2 text-xs text-muted-foreground hover:text-destructive"
|
||||
onClick={() => store.deleteComment(comment.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
削除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Replies */}
|
||||
{replies.length > 0 && (
|
||||
<div className="mt-2 space-y-2 border-t pt-2">
|
||||
{replies.map((r) => (
|
||||
<div key={r.id} className="flex gap-2">
|
||||
<div className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-[9px] font-bold text-primary-foreground">
|
||||
{r.author.charAt(0)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<strong className="text-foreground/80">{r.author}</strong> · {formatTime(r.timestamp)}
|
||||
</div>
|
||||
<p className="text-[13px] text-muted-foreground leading-relaxed">{r.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reply Input */}
|
||||
{showReply && (
|
||||
<div className="mt-2 space-y-2 border-t pt-2">
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
placeholder="返信を入力..."
|
||||
className="min-h-[36px] flex-1 text-sm"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
handleSubmitReply();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-9 shrink-0 self-end"
|
||||
onClick={handleSubmitReply}
|
||||
disabled={!replyText.trim()}
|
||||
>
|
||||
送信
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
195
src/components/comment-sidebar.tsx
Normal file
195
src/components/comment-sidebar.tsx
Normal file
@ -0,0 +1,195 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { CommentCard } from './comment-card';
|
||||
import { ExportButtons } from './export-buttons';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { FilterMode } from '@/lib/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PanelRightClose, PanelRightOpen, MessageSquare, User } from 'lucide-react';
|
||||
|
||||
const FILTERS: { value: FilterMode; label: string }[] = [
|
||||
{ value: 'unresolved', label: '未解決' },
|
||||
{ value: 'resolved', label: '解決済' },
|
||||
{ value: 'all', label: 'すべて' },
|
||||
];
|
||||
|
||||
interface CommentSidebarProps {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
onScrollToQuote?: (commentId: string) => void;
|
||||
}
|
||||
|
||||
export function CommentSidebar({ open, onToggle, onScrollToQuote }: CommentSidebarProps) {
|
||||
const { username, comments, filter, setFilter, setUsername } = useStore();
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [nameInput, setNameInput] = useState('');
|
||||
|
||||
const topLevel = useMemo(
|
||||
() => comments.filter((c) => !c.parentId),
|
||||
[comments]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (filter === 'all') return topLevel;
|
||||
if (filter === 'resolved') return topLevel.filter((c) => c.resolved);
|
||||
return topLevel.filter((c) => !c.resolved);
|
||||
}, [topLevel, filter]);
|
||||
|
||||
const counts = useMemo(() => ({
|
||||
unresolved: topLevel.filter((c) => !c.resolved).length,
|
||||
resolved: topLevel.filter((c) => c.resolved).length,
|
||||
all: topLevel.length,
|
||||
}), [topLevel]);
|
||||
|
||||
const getReplies = (parentId: string) =>
|
||||
comments
|
||||
.filter((c) => c.parentId === parentId)
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
const handleNameEdit = () => {
|
||||
if (nameInput.trim()) {
|
||||
setUsername(nameInput.trim());
|
||||
}
|
||||
setEditingName(false);
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex w-10 cursor-pointer flex-col items-center gap-3 border-l bg-muted/50 py-4 transition-colors hover:bg-muted"
|
||||
>
|
||||
<PanelRightOpen className="h-4 w-4 text-muted-foreground" />
|
||||
{counts.all > 0 && (
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
|
||||
{counts.all}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-[11px] font-bold tracking-wider text-muted-foreground [writing-mode:vertical-rl]">
|
||||
FB Tool
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-[400px] flex-col border-l bg-muted/50">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[14px] font-bold text-primary">FB Tool</span>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{counts.all}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ExportButtons />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={onToggle}
|
||||
title="閉じる"
|
||||
>
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User */}
|
||||
{username && (
|
||||
<div className="flex items-center justify-between border-b px-4 py-2">
|
||||
{editingName ? (
|
||||
<Input
|
||||
value={nameInput}
|
||||
onChange={(e) => setNameInput(e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleNameEdit();
|
||||
if (e.key === 'Escape') setEditingName(false);
|
||||
}}
|
||||
onBlur={handleNameEdit}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setNameInput(username);
|
||||
setEditingName(true);
|
||||
}}
|
||||
className="flex items-center gap-2 text-[13px] text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="クリックで名前を変更"
|
||||
>
|
||||
<User className="h-3.5 w-3.5" />
|
||||
{username}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex border-b">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => setFilter(f.value)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 border-b-2 px-4 py-2.5 text-[13px] transition-colors',
|
||||
filter === f.value
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground/80'
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-1.5 py-px text-[11px]',
|
||||
filter === f.value
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{counts[f.value]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Comment List */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-2 p-3">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<MessageSquare className="mb-3 h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filter === 'unresolved'
|
||||
? 'コメントはまだありません'
|
||||
: filter === 'resolved'
|
||||
? '解決済みのコメントはありません'
|
||||
: 'コメントはまだありません'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground/60">
|
||||
左側のテキストを選択してコメントを追加
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filtered
|
||||
.sort((a, b) => b.timestamp - a.timestamp)
|
||||
.map((c) => (
|
||||
<CommentCard
|
||||
key={c.id}
|
||||
comment={c}
|
||||
replies={getReplies(c.id)}
|
||||
onScrollToQuote={onScrollToQuote}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
src/components/content-viewer.tsx
Normal file
185
src/components/content-viewer.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useEffect, useCallback, useState, forwardRef, useImperativeHandle } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { SelectionPopup } from './selection-popup';
|
||||
|
||||
export interface ContentViewerHandle {
|
||||
scrollToQuote: (commentId: string) => void;
|
||||
}
|
||||
|
||||
export const ContentViewer = forwardRef<ContentViewerHandle>(function ContentViewer(_, ref) {
|
||||
const { targetUrl, comments } = useStore();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const scrollToQuote = useCallback((commentId: string) => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe?.contentDocument) return;
|
||||
|
||||
const mark = iframe.contentDocument.querySelector(
|
||||
`.fb-highlight[data-comment-id="${commentId}"], .fb-strikethrough[data-comment-id="${commentId}"]`
|
||||
);
|
||||
if (!mark) return;
|
||||
|
||||
mark.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
|
||||
const el = mark as HTMLElement;
|
||||
const origBg = el.style.backgroundColor;
|
||||
el.style.backgroundColor = 'rgba(59, 130, 246, 0.4)';
|
||||
el.style.transition = 'background-color 0.3s';
|
||||
setTimeout(() => {
|
||||
el.style.backgroundColor = origBg;
|
||||
}, 1500);
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({ scrollToQuote }), [scrollToQuote]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!targetUrl) return;
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe) return;
|
||||
|
||||
setReady(false);
|
||||
const headers: Record<string, string> = {};
|
||||
const token = process.env.NEXT_PUBLIC_API_TOKEN;
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
fetch(`/api/fetch-page?url=${encodeURIComponent(targetUrl)}`, { headers })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.text();
|
||||
})
|
||||
.then((html) => {
|
||||
const doc = iframe.contentDocument;
|
||||
if (!doc) return;
|
||||
doc.open();
|
||||
doc.write(html);
|
||||
doc.close();
|
||||
setTimeout(() => setReady(true), 300);
|
||||
})
|
||||
.catch((e) => setError(e.message));
|
||||
}, [targetUrl]);
|
||||
|
||||
const applyHighlights = useCallback(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe?.contentDocument?.body) return;
|
||||
|
||||
const doc = iframe.contentDocument;
|
||||
doc.querySelectorAll('.fb-highlight, .fb-strikethrough').forEach((el) => {
|
||||
const text = el.textContent || '';
|
||||
const textNode = doc.createTextNode(text);
|
||||
el.parentNode?.replaceChild(textNode, el);
|
||||
});
|
||||
doc.body.normalize();
|
||||
|
||||
const style = doc.getElementById('fb-highlight-styles') || doc.createElement('style');
|
||||
style.id = 'fb-highlight-styles';
|
||||
style.textContent = `
|
||||
.fb-highlight {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
border-bottom: 2px solid rgb(59, 130, 246);
|
||||
padding: 1px 0;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.fb-highlight:hover { background: rgba(59, 130, 246, 0.25); }
|
||||
.fb-strikethrough {
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: rgb(239, 68, 68);
|
||||
text-decoration-thickness: 2px;
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
if (!doc.getElementById('fb-highlight-styles')) {
|
||||
doc.head.appendChild(style);
|
||||
}
|
||||
|
||||
const topLevel = comments.filter((c) => !c.parentId && !c.resolved && c.quote);
|
||||
topLevel.forEach((comment) => {
|
||||
const searchText = comment.quote.replace(/[\s\u00A0]+/g, ' ').trim();
|
||||
if (searchText.length < 2) return;
|
||||
|
||||
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: (node) => {
|
||||
const parent = (node as Text).parentElement;
|
||||
if (!parent) return NodeFilter.FILTER_REJECT;
|
||||
if (parent.tagName === 'SCRIPT' || parent.tagName === 'STYLE') return NodeFilter.FILTER_REJECT;
|
||||
if (parent.closest('.fb-highlight, .fb-strikethrough')) return NodeFilter.FILTER_REJECT;
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
},
|
||||
});
|
||||
|
||||
let node: Text | null;
|
||||
while ((node = walker.nextNode() as Text | null)) {
|
||||
const nodeText = node.textContent?.replace(/[\s\u00A0]+/g, ' ') || '';
|
||||
const idx = nodeText.indexOf(searchText);
|
||||
if (idx === -1) continue;
|
||||
|
||||
const originalText = node.textContent || '';
|
||||
const origIdx = originalText.indexOf(searchText.charAt(0));
|
||||
if (origIdx === -1) continue;
|
||||
|
||||
try {
|
||||
const before = doc.createTextNode(originalText.substring(0, origIdx));
|
||||
const mark = doc.createElement('mark');
|
||||
mark.className = comment.type === 'strikethrough' ? 'fb-strikethrough' : 'fb-highlight';
|
||||
mark.dataset.commentId = comment.id;
|
||||
mark.textContent = originalText.substring(origIdx, origIdx + searchText.length);
|
||||
const after = doc.createTextNode(originalText.substring(origIdx + searchText.length));
|
||||
|
||||
const parent = node.parentNode;
|
||||
if (parent) {
|
||||
parent.insertBefore(before, node);
|
||||
parent.insertBefore(mark, node);
|
||||
parent.insertBefore(after, node);
|
||||
parent.removeChild(node);
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
}, [comments]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ready) applyHighlights();
|
||||
}, [ready, applyHighlights]);
|
||||
|
||||
if (!targetUrl) {
|
||||
return (
|
||||
<div ref={containerRef} className="flex h-full items-center justify-center bg-background">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p className="mb-1 text-lg font-semibold">URLが指定されていません</p>
|
||||
<p className="text-sm opacity-60">?url=https://... をURLに追加してください</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div ref={containerRef} className="flex h-full items-center justify-center bg-background">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p className="mb-1 text-lg font-semibold">ページの読み込みに失敗しました</p>
|
||||
<p className="text-sm opacity-60">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative h-full">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className="h-full w-full border-0"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
/>
|
||||
<SelectionPopup containerRef={containerRef} iframeRef={iframeRef} ready={ready} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
91
src/components/export-buttons.tsx
Normal file
91
src/components/export-buttons.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import { useStore } from '@/lib/store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Download } from 'lucide-react';
|
||||
import { Comment, Priority } from '@/lib/types';
|
||||
|
||||
function buildMarkdown(comments: Comment[], targetUrl: string): string {
|
||||
const priorityOrder: Record<Priority, number> = { must: 1, better: 2, want: 3 };
|
||||
const topLevel = comments
|
||||
.filter((c) => !c.parentId)
|
||||
.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
|
||||
|
||||
let md = `# フィードバック\n\nURL: ${targetUrl || '不明'}\nエクスポート日時: ${new Date().toLocaleString('ja-JP')}\n\n---\n\n`;
|
||||
|
||||
const groups: Record<Priority, Comment[]> = { must: [], better: [], want: [] };
|
||||
topLevel.forEach((c) => groups[c.priority]?.push(c));
|
||||
|
||||
const labels: Record<Priority, string> = { must: 'MUST', better: 'BETTER', want: 'WANT' };
|
||||
|
||||
(['must', 'better', 'want'] as Priority[]).forEach((p) => {
|
||||
if (groups[p].length === 0) return;
|
||||
md += `## ${labels[p]}\n\n`;
|
||||
groups[p].forEach((c) => {
|
||||
md += `### ${c.content}\n\n`;
|
||||
if (c.quote) md += `> ${c.quote}\n\n`;
|
||||
md += `- 投稿者: ${c.author}\n`;
|
||||
md += `- ステータス: ${c.resolved ? '解決済み' : '未解決'}\n\n`;
|
||||
const replies = comments.filter((r) => r.parentId === c.id);
|
||||
if (replies.length > 0) {
|
||||
md += `**返信:**\n\n`;
|
||||
replies.forEach((r) => { md += `- ${r.author}: ${r.content}\n`; });
|
||||
md += '\n';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return md;
|
||||
}
|
||||
|
||||
function buildJson(comments: Comment[], targetUrl: string): string {
|
||||
return JSON.stringify({
|
||||
projectUrl: targetUrl,
|
||||
exportedAt: new Date().toISOString(),
|
||||
commentsCount: comments.filter((c) => !c.parentId).length,
|
||||
comments: comments.map((c) => ({
|
||||
id: c.id, author: c.author, type: c.type, priority: c.priority,
|
||||
content: c.content, quote: c.quote || null, parentId: c.parentId,
|
||||
resolved: c.resolved, timestamp: c.timestamp,
|
||||
})),
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
function downloadFile(content: string, filename: string, mimeType: string) {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function DownloadButton({ label, ext, mime, buildFn }: {
|
||||
label: string; ext: string; mime: string; buildFn: () => string;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => downloadFile(buildFn(), `feedback.${ext}`, mime)}
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExportButtons() {
|
||||
const { comments, targetUrl } = useStore();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<DownloadButton label="JSON" ext="json" mime="application/json" buildFn={() => buildJson(comments, targetUrl)} />
|
||||
<DownloadButton label="MD" ext="md" mime="text/markdown" buildFn={() => buildMarkdown(comments, targetUrl)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
src/components/name-dialog.tsx
Normal file
51
src/components/name-dialog.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function NameDialog() {
|
||||
const { username, setUsername } = useStore();
|
||||
const [name, setName] = useState('');
|
||||
|
||||
if (username) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={!username}>
|
||||
<DialogContent className="sm:max-w-[380px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>ようこそ</DialogTitle>
|
||||
<DialogDescription>
|
||||
コメントに表示される名前を入力してください。あとから変更できます。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 pt-2">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="例: 田中太郎"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && name.trim()) setUsername(name.trim());
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={!name.trim()}
|
||||
onClick={() => setUsername(name.trim())}
|
||||
>
|
||||
始める
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
204
src/components/selection-popup.tsx
Normal file
204
src/components/selection-popup.tsx
Normal file
@ -0,0 +1,204 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { Priority } from '@/lib/types';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { X, Strikethrough } from 'lucide-react';
|
||||
|
||||
interface SelectionState {
|
||||
text: string;
|
||||
quoteContext: { beforeText: string; afterText: string };
|
||||
rect: DOMRect;
|
||||
iframeOffset: { top: number; left: number };
|
||||
}
|
||||
|
||||
function getQuoteContext(range: Range, contextLength = 50) {
|
||||
let beforeText = '';
|
||||
let afterText = '';
|
||||
try {
|
||||
const doc = range.startContainer.ownerDocument;
|
||||
if (!doc?.body) return { beforeText, afterText };
|
||||
|
||||
const beforeRange = doc.createRange();
|
||||
beforeRange.setStart(doc.body, 0);
|
||||
beforeRange.setEnd(range.startContainer, range.startOffset);
|
||||
beforeText = beforeRange.toString().slice(-contextLength).replace(/[\s\u00A0]+/g, ' ').trim();
|
||||
|
||||
const afterRange = doc.createRange();
|
||||
afterRange.setStart(range.endContainer, range.endOffset);
|
||||
afterRange.setEnd(doc.body, doc.body.childNodes.length);
|
||||
afterText = afterRange.toString().slice(0, contextLength).replace(/[\s\u00A0]+/g, ' ').trim();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { beforeText, afterText };
|
||||
}
|
||||
|
||||
interface SelectionPopupProps {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
iframeRef: React.RefObject<HTMLIFrameElement | null>;
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
export function SelectionPopup({ containerRef, iframeRef, ready }: SelectionPopupProps) {
|
||||
const store = useStore();
|
||||
const [selection, setSelection] = useState<SelectionState | null>(null);
|
||||
const [content, setContent] = useState('');
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleIframeMouseUp = useCallback(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe?.contentWindow) return;
|
||||
|
||||
const sel = iframe.contentWindow.getSelection();
|
||||
const text = sel?.toString().trim();
|
||||
if (!text || text.length === 0 || !sel || sel.rangeCount === 0) return;
|
||||
|
||||
const range = sel.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
const ctx = getQuoteContext(range);
|
||||
|
||||
const iframeRect = iframe.getBoundingClientRect();
|
||||
|
||||
setSelection({
|
||||
text: text.replace(/[\s\u00A0]+/g, ' ').substring(0, 200),
|
||||
quoteContext: ctx,
|
||||
rect,
|
||||
iframeOffset: { top: iframeRect.top, left: iframeRect.left },
|
||||
});
|
||||
setContent('');
|
||||
}, [iframeRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
const iframe = iframeRef.current;
|
||||
const doc = iframe?.contentDocument;
|
||||
if (!doc) return;
|
||||
|
||||
const onMouseUp = () => handleIframeMouseUp();
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (popupRef.current?.contains(e.target as Node)) return;
|
||||
setSelection(null);
|
||||
};
|
||||
|
||||
doc.addEventListener('mouseup', onMouseUp);
|
||||
doc.addEventListener('mousedown', onMouseDown);
|
||||
|
||||
return () => {
|
||||
doc.removeEventListener('mouseup', onMouseUp);
|
||||
doc.removeEventListener('mousedown', onMouseDown);
|
||||
};
|
||||
}, [ready, iframeRef, handleIframeMouseUp]);
|
||||
|
||||
const submit = (priority: Priority) => {
|
||||
if (!selection) return;
|
||||
store.addComment({
|
||||
quote: selection.text,
|
||||
quoteContext: selection.quoteContext,
|
||||
content: content.trim(),
|
||||
priority,
|
||||
type: 'comment',
|
||||
});
|
||||
setSelection(null);
|
||||
setContent('');
|
||||
};
|
||||
|
||||
const submitStrikethrough = () => {
|
||||
if (!selection) return;
|
||||
store.addComment({
|
||||
quote: selection.text,
|
||||
quoteContext: selection.quoteContext,
|
||||
content: '',
|
||||
priority: 'must',
|
||||
type: 'strikethrough',
|
||||
});
|
||||
setSelection(null);
|
||||
setContent('');
|
||||
};
|
||||
|
||||
if (!selection) return null;
|
||||
|
||||
const popupHeight = 340;
|
||||
const popupWidth = 420;
|
||||
const margin = 12;
|
||||
|
||||
const selBottom = selection.iframeOffset.top + selection.rect.bottom;
|
||||
const selTop = selection.iframeOffset.top + selection.rect.top;
|
||||
|
||||
let top: number;
|
||||
if (selBottom + margin + popupHeight <= window.innerHeight) {
|
||||
top = selBottom + margin;
|
||||
} else if (selTop - margin - popupHeight >= 0) {
|
||||
top = selTop - margin - popupHeight;
|
||||
} else {
|
||||
top = Math.max(margin, (window.innerHeight - popupHeight) / 2);
|
||||
}
|
||||
|
||||
const maxLeft = (containerRef.current?.getBoundingClientRect().right ?? window.innerWidth) - popupWidth - margin;
|
||||
const left = Math.max(margin, Math.min(selection.iframeOffset.left + selection.rect.left, maxLeft));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popupRef}
|
||||
className="fixed z-50 w-[420px] rounded-xl border border-border/80 bg-popover p-5 shadow-lg"
|
||||
style={{ top, left }}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm font-semibold">コメントを追加</span>
|
||||
<button
|
||||
onClick={() => setSelection(null)}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 rounded-r border-l-2 border-primary bg-muted px-3 py-2 text-[13px] italic text-muted-foreground leading-relaxed">
|
||||
“{selection.text.length > 120 ? selection.text.substring(0, 120) + '...' : selection.text}”
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="フィードバックを入力..."
|
||||
className="mb-3 min-h-[80px] text-sm"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') submit('must');
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 flex-none border-muted-foreground/20 text-xs text-muted-foreground hover:bg-muted"
|
||||
onClick={submitStrikethrough}
|
||||
>
|
||||
<Strikethrough className="mr-1 h-3 w-3" />
|
||||
取り消す
|
||||
</Button>
|
||||
<Button
|
||||
className="h-9 flex-1 border border-red-500/30 bg-red-500/10 text-red-400 hover:bg-red-500/20"
|
||||
onClick={() => submit('must')}
|
||||
>
|
||||
Must
|
||||
</Button>
|
||||
<Button
|
||||
className="h-9 flex-1 border border-amber-500/30 bg-amber-500/10 text-amber-400 hover:bg-amber-500/20"
|
||||
onClick={() => submit('better')}
|
||||
>
|
||||
Better
|
||||
</Button>
|
||||
<Button
|
||||
className="h-9 flex-1 border border-green-500/30 bg-green-500/10 text-green-400 hover:bg-green-500/20"
|
||||
onClick={() => submit('want')}
|
||||
>
|
||||
Want
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
src/components/ui/badge.tsx
Normal file
52
src/components/ui/badge.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
60
src/components/ui/button.tsx
Normal file
60
src/components/ui/button.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
"use client"
|
||||
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
103
src/components/ui/card.tsx
Normal file
103
src/components/ui/card.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
157
src/components/ui/dialog.tsx
Normal file
157
src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-base leading-none font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
20
src/components/ui/input.tsx
Normal file
20
src/components/ui/input.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
55
src/components/ui/scroll-area.tsx
Normal file
55
src/components/ui/scroll-area.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Root.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Scrollbar.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
25
src/components/ui/separator.tsx
Normal file
25
src/components/ui/separator.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
82
src/components/ui/tabs.tsx
Normal file
82
src/components/ui/tabs.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
"use client"
|
||||
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: TabsPrimitive.Root.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Panel
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
66
src/components/ui/tooltip.tsx
Normal file
66
src/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delay = 0,
|
||||
...props
|
||||
}: TooltipPrimitive.Provider.Props) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delay={delay}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
side = "top",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: TooltipPrimitive.Popup.Props &
|
||||
Pick<
|
||||
TooltipPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<TooltipPrimitive.Popup
|
||||
data-slot="tooltip-content"
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||
</TooltipPrimitive.Popup>
|
||||
</TooltipPrimitive.Positioner>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
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);
|
||||
}
|
||||
61
src/lib/storage.ts
Normal file
61
src/lib/storage.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { Comment } from './types';
|
||||
|
||||
const USERNAME_KEY = 'fb-username';
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = process.env.NEXT_PUBLIC_API_TOKEN;
|
||||
if (!token) return {};
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export function getUsername(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem(USERNAME_KEY);
|
||||
}
|
||||
|
||||
export function setUsername(name: string): void {
|
||||
localStorage.setItem(USERNAME_KEY, name);
|
||||
}
|
||||
|
||||
export async function fetchComments(projectSlug: string): Promise<Comment[]> {
|
||||
const res = await fetch(`/api/comments?slug=${encodeURIComponent(projectSlug)}`, {
|
||||
headers: { ...authHeaders() },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createComment(comment: Comment, projectSlug: string): Promise<void> {
|
||||
await fetch('/api/comments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ ...comment, projectSlug }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateComment(id: string, updates: Record<string, unknown>): Promise<void> {
|
||||
await fetch('/api/comments', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ id, ...updates }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteComment(id: string): Promise<void> {
|
||||
await fetch(`/api/comments?id=${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { ...authHeaders() },
|
||||
});
|
||||
}
|
||||
|
||||
export async function renameAuthor(projectSlug: string, oldAuthor: string, newAuthor: string): Promise<void> {
|
||||
await fetch('/api/comments', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ id: '_rename', author: newAuthor, oldAuthor, projectSlug }),
|
||||
});
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substring(2, 8);
|
||||
}
|
||||
213
src/lib/store.tsx
Normal file
213
src/lib/store.tsx
Normal file
@ -0,0 +1,213 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { Comment, Priority, FilterMode, CommentType } from './types';
|
||||
import * as storage from './storage';
|
||||
|
||||
function slugify(url: string): string {
|
||||
return url
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/[^a-zA-Z0-9\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF]/g, '_')
|
||||
.substring(0, 100);
|
||||
}
|
||||
|
||||
interface StoreState {
|
||||
username: string | null;
|
||||
targetUrl: string;
|
||||
projectSlug: string;
|
||||
comments: Comment[];
|
||||
filter: FilterMode;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
interface StoreActions {
|
||||
setUsername: (name: string) => void;
|
||||
addComment: (params: {
|
||||
quote: string;
|
||||
quoteContext: { beforeText: string; afterText: string };
|
||||
content: string;
|
||||
priority: Priority;
|
||||
type?: CommentType;
|
||||
parentId?: string | null;
|
||||
}) => void;
|
||||
editComment: (id: string, content: string, priority: Priority) => void;
|
||||
deleteComment: (id: string) => void;
|
||||
resolveComment: (id: string) => void;
|
||||
cyclePriority: (id: string) => void;
|
||||
setFilter: (filter: FilterMode) => void;
|
||||
}
|
||||
|
||||
const StoreContext = createContext<(StoreState & StoreActions) | null>(null);
|
||||
|
||||
export function StoreProvider({ children }: { children: ReactNode }) {
|
||||
const [targetUrl, setTargetUrl] = useState('');
|
||||
const [projectSlug, setProjectSlug] = useState('');
|
||||
const [username, setUsernameState] = useState<string | null>(null);
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [filter, setFilter] = useState<FilterMode>('unresolved');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setUsernameState(storage.getUsername());
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const url = params.get('url') || '';
|
||||
if (url) {
|
||||
const slug = slugify(url);
|
||||
setTargetUrl(url);
|
||||
setProjectSlug(slug);
|
||||
storage.fetchComments(slug).then((c) => {
|
||||
setComments(c);
|
||||
setLoading(false);
|
||||
});
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshComments = useCallback(async () => {
|
||||
if (!projectSlug) return;
|
||||
const c = await storage.fetchComments(projectSlug);
|
||||
setComments(c);
|
||||
}, [projectSlug]);
|
||||
|
||||
const setUsername = useCallback(async (name: string) => {
|
||||
const oldName = storage.getUsername();
|
||||
storage.setUsername(name);
|
||||
setUsernameState(name);
|
||||
if (oldName && projectSlug) {
|
||||
await storage.renameAuthor(projectSlug, oldName, name);
|
||||
await refreshComments();
|
||||
}
|
||||
}, [projectSlug, refreshComments]);
|
||||
|
||||
const addComment = useCallback(
|
||||
async (params: {
|
||||
quote: string;
|
||||
quoteContext: { beforeText: string; afterText: string };
|
||||
content: string;
|
||||
priority: Priority;
|
||||
type?: CommentType;
|
||||
parentId?: string | null;
|
||||
}) => {
|
||||
if (!username || !projectSlug) return;
|
||||
const newComment: Comment = {
|
||||
id: storage.generateId(),
|
||||
author: username,
|
||||
type: params.type || 'comment',
|
||||
quote: params.quote,
|
||||
quoteContext: params.quoteContext,
|
||||
content: params.content,
|
||||
priority: params.priority,
|
||||
parentId: params.parentId || null,
|
||||
resolved: false,
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
timestamp: Date.now(),
|
||||
updatedAt: null,
|
||||
pageUrl: targetUrl,
|
||||
};
|
||||
setComments((prev) => [...prev, newComment]);
|
||||
await storage.createComment(newComment, projectSlug);
|
||||
},
|
||||
[username, targetUrl, projectSlug]
|
||||
);
|
||||
|
||||
const editComment = useCallback(
|
||||
async (id: string, content: string, priority: Priority) => {
|
||||
setComments((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, content, priority, updatedAt: Date.now() } : c))
|
||||
);
|
||||
await storage.updateComment(id, { content, priority });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const deleteComment = useCallback(
|
||||
async (id: string) => {
|
||||
setComments((prev) => prev.filter((c) => c.id !== id && c.parentId !== id));
|
||||
await storage.deleteComment(id);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const resolveComment = useCallback(
|
||||
async (id: string) => {
|
||||
if (!username) return;
|
||||
let updates: Record<string, unknown> | null = null;
|
||||
setComments((prev) =>
|
||||
prev.map((c) => {
|
||||
if (c.id !== id) return c;
|
||||
const nowResolved = !c.resolved;
|
||||
updates = {
|
||||
resolved: nowResolved,
|
||||
resolvedBy: nowResolved ? username : null,
|
||||
resolvedAt: nowResolved ? Date.now() : null,
|
||||
};
|
||||
return { ...c, ...updates };
|
||||
})
|
||||
);
|
||||
if (updates) {
|
||||
await storage.updateComment(id, updates);
|
||||
}
|
||||
},
|
||||
[username]
|
||||
);
|
||||
|
||||
const cyclePriority = useCallback(
|
||||
async (id: string) => {
|
||||
const cycle: Record<Priority, Priority> = {
|
||||
must: 'better',
|
||||
better: 'want',
|
||||
want: 'must',
|
||||
};
|
||||
let newPriority: Priority | null = null;
|
||||
setComments((prev) =>
|
||||
prev.map((c) => {
|
||||
if (c.id !== id) return c;
|
||||
newPriority = cycle[c.priority];
|
||||
return { ...c, priority: newPriority };
|
||||
})
|
||||
);
|
||||
if (newPriority) {
|
||||
await storage.updateComment(id, { priority: newPriority });
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<StoreContext.Provider
|
||||
value={{
|
||||
username,
|
||||
targetUrl,
|
||||
projectSlug,
|
||||
comments,
|
||||
filter,
|
||||
loading,
|
||||
setUsername,
|
||||
addComment,
|
||||
editComment,
|
||||
deleteComment,
|
||||
resolveComment,
|
||||
cyclePriority,
|
||||
setFilter,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</StoreContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useStore() {
|
||||
const ctx = useContext(StoreContext);
|
||||
if (!ctx) throw new Error('useStore must be used within StoreProvider');
|
||||
return ctx;
|
||||
}
|
||||
22
src/lib/types.ts
Normal file
22
src/lib/types.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export type Priority = 'must' | 'better' | 'want';
|
||||
|
||||
export type CommentType = 'comment' | 'strikethrough';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export type FilterMode = 'unresolved' | 'resolved' | 'all';
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
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"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user