commit bc8a281e4c4c8f1233fc7797db9ceaf4ca0b1ee9 Author: hiroki ito Date: Thu Mar 19 13:59:28 2026 +0900 first commit Made-with: Cursor diff --git a/.claude/skills/creating-visual-explainers/SKILL.md b/.claude/skills/creating-visual-explainers/SKILL.md new file mode 100644 index 0000000..90e5870 --- /dev/null +++ b/.claude/skills/creating-visual-explainers/SKILL.md @@ -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` を読み、額縁の構造を把握する: + +- `` 〜 `` のプレースホルダー位置 +- ``, `` のプレースホルダー +- 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. コピーしたファイル内のプレースホルダーをすべて置換する: + - `` → 図解のタイトル + - `` → 内容を要約した1文 + - `` 〜 `` → 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 '', "" | 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を使う +- **インタラクティブ要素を入れない** — トグル、フェードイン、アニメーション、フォーム、クリックで開閉する要素は一切禁止 +- **` + + +
+ +
+
+ + + +
+ + + + + diff --git a/.claude/skills/creating-visual-explainers/references/model-answer.html b/.claude/skills/creating-visual-explainers/references/model-answer.html new file mode 100644 index 0000000..ce24d75 --- /dev/null +++ b/.claude/skills/creating-visual-explainers/references/model-answer.html @@ -0,0 +1,968 @@ + + + + + + + + + + + + APIの仕組み + + + + + + + + + +
+ +
+
+ + + + + +
+
+ + テクノロジー +
+

+ APIの仕組み +

+

+ 「APIって何?」と聞かれて、うまく答えられない。
+ ひとことで言うと ── +

+
+ + + + + +
+
+

+ API = ソフトウェアの「注文窓口」 +

+

+ 中身を知らなくても、決まった形で頼めば結果が届く +

+
+ + +
+ +
+
+ +
+
あなたのアプリ
+
「天気を教えて」
+
+ + +
+
+ リクエスト + + +
+
+ + +
+
+ +
+
API
+
注文を届け、結果を返す
+
レストランのウェイター役
+
+ + +
+
+ 依頼 + + +
+
+ + +
+
+ +
+
サービス
+
処理して結果を返す
+
+
+ + +
+
+ + 結果(レスポンス)があなたのアプリに届く +
+
+ + +
+
+
あなたも毎日使っている
+
+
+ + +
+
+ + 天気予報 +
+
+ + Googleログイン +
+
+ + オンライン決済 +
+
+ + 地図・ナビ +
+
+ +
+ +

+ ここから先で、この仕組みをひとつずつ丁寧に解説していきます。 +

+ + + + + +
+
+
+ +
+

そもそもAPIって何?

+
+ +

+ API(エーピーアイ)は Application Programming Interface(アプリケーション・プログラミング・インターフェース)の略称です。正式名称を聞いても「何のこと?」と思いますよね。 +

+ +

+ まずは日常のたとえで考えてみましょう。レストランに行った場面を想像してください。 +

+ +

+ あなた(お客さん)は、厨房に直接入って料理を作ることはできません。厨房のルールも、調理器具の使い方も知りません。でも、ウェイターに「パスタをください」と注文すれば、厨房で作られた料理があなたのテーブルに届きます。厨房の中で何が起きているかを知る必要はありません。 +

+ + +
+

レストランで考えるAPIの役割

+ +
+ +
+
+ +
+
あなた
+
お客さん
+
「パスタください」
+
+ + +
+
+ 注文 + + +
+
+ + +
+
+ +
+
API
+
ウェイター
+
注文を伝え、料理を届ける
+
+ + +
+
+ 依頼 + + +
+
+ + +
+
+ +
+
サーバー
+
厨房
+
パスタを作って渡す
+
+
+ +
+
+ + 料理(レスポンス)があなたのテーブルに届く +
+
+
+ +

+ この比喩がAPIの本質をほぼ言い当てています。あなた(アプリ)は、厨房(サーバー)の中で何が起きているかを知る必要がありません。ウェイター(API)に決まった形式で注文を伝えれば、結果が返ってくる。これがAPIです。 +

+ + +
+
+
+ +
+
+

ここがポイント

+

+ APIは「仲介役」です。相手の内部構造を知らなくても、決まったルールで話しかければ結果が返ってくる。これがAPIの本質です。この「決まったルール」のことを、エンジニアは「インターフェース(Interface)」と呼びます。 +

+
+
+
+
+ + + + + +
+
+
+ +
+

もう少し正確に言うと

+
+ +

+ レストランのたとえで、ざっくりとしたイメージはつかめましたか? ここからもう少しだけ正確に説明します。 +

+ +

+ APIとは、ひとことで言えば「ソフトウェア同士が会話するための窓口」です。あなたが使っているアプリの裏側で、別のサービスのデータや機能を借りてくるための「取り決め」と考えてください。 +

+ +
+
技術的な定義
+

+ API = あるソフトウェアの機能を、 + 別のソフトウェアから使えるようにする仕組み +

+

出典: MDN Web Docs — Web API の紹介

+
+ +

+ これだけだとまだ抽象的に感じるかもしれません。では、APIがある世界とない世界を比べてみましょう。 +

+ + +
+ +
+
+ + BEFORE — APIがない世界 +
+
    +
  • + + 天気情報が欲しければ、自分で気象観測の仕組みを構築する +
  • +
  • + + 決済機能が欲しければ、クレジットカード処理を自前で開発する +
  • +
  • + + 地図を表示したければ、地図データを自分で作成・更新する +
  • +
  • + + ユーザー認証はパスワード管理からセキュリティ対策まで全部自前 +
  • +
+
+ + 膨大な開発コストと時間。バグのリスクも高い。 +
+
+ + +
+
+ + AFTER — APIがある世界 +
+
    +
  • + + 天気情報は天気予報APIに問い合わせるだけで取得できる +
  • +
  • + + 決済はStripe APIに任せれば数行のコードで完成 +
  • +
  • + + 地図はGoogle Maps APIで高品質な地図を即表示できる +
  • +
  • + + ログインはGoogleやAppleのAPIで安全に認証できる +
  • +
+
+ + 「自分が本当に作るべきもの」に集中できる。 +
+
+
+
+ + + + + +
+
+
+ +
+

APIの仕組み — リクエストとレスポンス

+
+ +

+ APIでのやり取りは、実はとてもシンプルです。基本は「聞く(リクエスト)」「答える(レスポンス)」の2つだけ。 +

+ +

+ リクエスト(Request)とは、「こういう情報をください」「この処理をしてください」とAPIに送るメッセージのことです。レスポンス(Response)は、APIがその要求に対して返す結果です。この2つのやり取りを分解すると、4つのステップになります。 +

+ + +
+

APIリクエスト〜レスポンスの流れ

+ +
+
+
1
+ +
リクエスト送信
+
あなたのアプリが
「こういう情報ください」
とAPIに送る
+
+ +
+ + +
+ +
+
2
+ +
APIが受け取る
+
リクエストの内容を
チェック・認証する
(門番の役割)
+
+ +
+ + +
+ +
+
3
+ +
サーバーが処理
+
データベース検索や
計算など、実際の
処理を実行する
+
+ +
+ + +
+ +
+
4
+ +
レスポンス返却
+
処理結果をあなたの
アプリに返す
(料理が届く瞬間)
+
+
+
+ +

+ 言葉だけだとまだピンとこないかもしれません。では、実際のコードで見てみましょう。たとえば、天気予報APIから東京の天気を取得するコードは、たったこれだけです。 +

+ + +
+
+ + JavaScript — 天気予報APIの呼び出し例 +
+
// 1. APIにリクエストを送る(「東京の天気を教えて」と聞く)
+const response = await fetch("https://api.weather.example.com/current?city=tokyo");
+
+// 2. レスポンスをJSON形式(データの構造)に変換する
+const data = await response.json();
+
+// 3. 必要なデータを取り出して使う
+console.log(data.temperature); // → "22°C"
+console.log(data.condition);   // → "晴れ"
+console.log(data.humidity);    // → "65%"
+
+ + +
+

+ + コードの解説(1行ずつ読み解く) +

+
+
+ 1 + fetch() は「指定したURLに問い合わせる」命令。URLの末尾にある ?city=tokyo が「東京の情報が欲しい」というリクエストの中身です。レストランのたとえで言えば「パスタください」にあたる部分。 +
+
+ 2 + 返ってきたデータは機械向けの生データなので、.json() で人間が読みやすい形(JSON = JavaScript Object Notation)に変換します。JSONは「名前: 値」の組み合わせでデータを表現する書式で、Web業界で最も広く使われています。 +
+
+ 3 + 変換したデータから data.temperature のように、ドット(.)で区切って欲しい情報を名前で取り出します。辞書で単語を引くのに似ています。 +
+
+
+ +

+ ターミナル(コマンドを入力する黒い画面)からもAPIを試せます。curl(カール)というコマンドを使うと、たった1行でAPIにリクエストを送れます。 +

+ + +
+
+
+
+
+
+
+
+ + ターミナル — curlコマンドでAPIを叩く +
+
+
$ curl https://api.weather.example.com/current?city=tokyo
+
+# 返ってくるレスポンス(JSON形式)
+{
+  "city": "東京",
+  "temperature": "22°C",
+  "condition": "晴れ",
+  "humidity": "65%"
+}
+
+ +
+
+
+ +
+
+

ちょっと補足: URLの構造

+

+ https://api.weather.example.com/current?city=tokyo のURLは、大きく3つの部分に分かれます。api.weather.example.com がAPIの住所(ベースURL)、/current が「何を」(現在の天気)、?city=tokyo が「どこの」(東京)というパラメータです。レストランで例えると「〇〇レストランの(住所)、メインメニューから(何を)、パスタを(詳細)」に対応します。 +

+
+
+
+ +

+ では、このAPIのレスポンスが実際のアプリではどう表示されるのでしょうか? あなたが見ている天気アプリの画面を覗いてみましょう。 +

+ + +
+
+
+
+
+
+
+
+ + weather-app.example.com +
+
+
+
+
現在地: 東京
+
+ + 22°C +
+
晴れ
+
+
65%
+
3m/s
+
+
+
+
+ +
+
+
+ +
+
+

APIの結果 → アプリの画面

+

+ 上の天気アプリは、裏側で temperature: "22°C"condition: "晴れ" というAPIレスポンスを受け取り、見やすいデザインに変換して表示しています。あなたが普段見ているきれいな画面の裏側では、こうしたAPIのやり取りが行われているのです。 +

+
+
+
+
+ + + + + +
+
+
+ +
+

身近なAPIの例 — 実はあなたも毎日使っている

+
+ +

+ 「API」と聞くとプログラマーの専門用語に聞こえるかもしれません。しかし、あなたがスマホで何気なくやっている日常の操作の裏側では、たくさんのAPIが動いています。「あなたが見ている画面」の裏側で、APIが何をしているのかを図解します。 +

+ +
+ +
+
+
+
東京
+
+ + 22°C +
+
晴れ
+
+
65%
+
3m/s
+
+
+
+
+

+ 天気予報アプリ +

+
裏側でAPIがやっていること
+

気象庁のサーバーに「東京の最新天気データをください」とリクエストを送り、気温・天候・湿度・風速などのデータをJSON形式で受け取っている

+
+
+ + +
+
+
アカウントにログイン
+
+
+ G +
+ Google でログイン +
+
+
+ または +
+
+
メールアドレスで登録
+
+
+

+ 「Googleでログイン」ボタン +

+
裏側でAPIがやっていること
+

GoogleのOAuth API(オーオース = 認可の仕組み)に「このユーザーの身元を確認してください」と問い合わせ、認証トークン(本人確認済みの証)を受け取っている

+
+
+ + +
+
+
+ +
+
お支払い完了
+
¥1,980
+
VISA **** 4242
+
+
+

+ オンライン決済 +

+
裏側でAPIがやっていること
+

Stripe等の決済APIが、クレジットカード会社のサーバーと暗号化通信を行い、与信確認(この人は支払える?)→ 決済処理 → 結果通知を実行している

+

出典: Stripe API 公式ドキュメント

+
+
+ + +
+
+
+
+
+
+
+ 現在地 +
+
+
+
+ + 東京駅 +
+
12分
+
+
+
+
+

+ 地図・ナビアプリ +

+
裏側でAPIがやっていること
+

Google Maps APIが地図画像の取得、現在の交通情報の取得、経路計算をそれぞれ別のAPIに問い合わせ、統合して表示している

+
+
+
+ +
+
+
+ +
+
+

気づきましたか?

+

+ 上の4つの例に共通しているのは、あなたがAPIの存在を意識していないということです。天気を確認するとき「今からAPIを呼ぶぞ」とは思いませんよね。優れたAPIは、ユーザーにその存在を感じさせません。まるで空気のように、裏側で静かに仕事をしているのです。 +

+
+
+
+
+ + + + + +
+
+
+ +
+

APIを使うとどう嬉しいか

+
+ +

+ ここまで読んで「APIは便利そうだ」と感じてもらえたと思います。では、開発者の視点から見たとき、APIを使うことで具体的にどのくらいの効果があるのか。数字と一緒に見てみましょう。 +

+ +
+
+
50回+
+
あなたが1日に
APIを使っている回数
+
+
+
24,000+
+
世界で公開されている
APIの数
+

出典: ProgrammableWeb

+
+
+
0.2秒
+
多くのAPIの
平均応答時間
+
+
+ +
+
+
+ +
+
+

開発スピードが上がる

+

決済、認証、地図、翻訳...。これらをゼロから作ると何ヶ月もかかりますが、APIを使えば数日〜数時間で実装できます。車を作りたいとき、エンジンから設計する必要はないのです。

+
+
+ +
+
+ +
+
+

品質が担保される

+

Google Maps、Stripe、AWSなど、各分野の専門企業が何千人体制で開発・運用しているAPIの品質は、個人や小さなチームで再現できるレベルではありません。その品質を「借りる」ことができます。

+
+
+ +
+
+ +
+
+

保守の手間が減る

+

API提供元がバグ修正・機能改善・セキュリティ更新を継続的に行ってくれます。あなたはAPIを「使うだけ」。自分でゼロから作った機能は、自分でずっと面倒を見続ける必要があります。

+
+
+ +
+
+ +
+
+

レゴのように拡張できる

+

APIはレゴブロックのように組み合わせられます。たとえば「翻訳API + 音声合成API」を組み合わせれば、多言語音声読み上げ機能が作れます。1つのAPIだけでは実現できない価値が、組み合わせで生まれるのです。

+
+
+
+
+ + + + + +
+
+
+ +
+

よくある誤解

+
+ +

+ APIについて学び始めると、多くの人が同じところでつまずきます。ここでは、初学者が陥りがちな3つの誤解を取り上げて、正しい理解に修正します。 +

+ +
+
+
+
+ +
+

誤解: 「APIはプログラマーだけが使うもの」

+
+
+
+
+ +
+
+

実際は:

+

+ あなたも毎日APIを使っています。朝、天気アプリを開く。SNSにログインする。電子マネーで買い物する。これらの操作はすべて、裏側でAPIが動いています。プログラマーが「APIを使う」のは、この仕組みのコードを書いている側にいるだけの話。気づかないうちにAPIの恩恵を毎日受けているのです。 +

+
+
+
+
+ +
+
+
+ +
+

誤解: 「APIって難しい技術でしょ?」

+
+
+
+
+ +
+
+

実際は:

+

+ APIの概念自体は「注文して結果を受け取る」というシンプルな仕組みです。レストランで注文できるなら、APIの概念は理解できます。先ほどのコード例のように、実際のプログラムも数行で書けることがほとんどです。難しいのはAPIそのものではなく、「APIで何を作るか」を考える部分。道具はシンプル、使いこなすセンスが問われるということです。 +

+
+
+
+
+ +
+
+
+ +
+

誤解: 「APIを使うと個人情報が漏れそうで怖い」

+
+
+
+
+ +
+
+

実際は:

+

+ 適切に設計されたAPIは、必要最小限の情報だけをやり取りします。たとえば銀行のAPIが口座残高を返す際、パスワードや暗証番号は一切含まれません。APIはデータの「窓口」であり、「何の情報を公開し、何を隠すか」を厳密に制御できます。むしろ、データベースに直接触るよりもAPIを介した方が安全なのです。レストランのたとえで言えば、お客さんが直接厨房に入るより、ウェイターを通した方が厨房の秩序が保たれるのと同じです。 +

+
+
+
+
+
+
+ + + + + +
+
+
+ +
+

まとめ — 覚えておきたい3つのこと

+
+ +

+ 長い図解を読んでいただきありがとうございます。最後に、この記事で伝えたかったことを3つに絞ってまとめます。 +

+ +
+
+
+
01
+
+

APIは「ソフトウェアの窓口」

+

+ レストランのウェイターのように、あなた(アプリ)とサーバーの間を取り持つ仲介役。相手の内部構造を知らなくても、決まったルール(インターフェース)で話しかければ結果が返ってきます。 +

+
+
+
+ +
+
+
02
+
+

あなたはすでにAPIユーザー

+

+ 天気予報、SNSログイン、地図検索、オンライン決済。気づかないうちに、あなたの日常はAPIに支えられています。APIは特別な人だけのものではなく、全員の生活を支える仕組みです。 +

+
+
+
+ +
+
+
03
+
+

APIで「車輪の再発明」がなくなる

+

+ すでにある優れた機能をAPIで借りることで、自分は「自分にしか作れない部分」に集中できます。開発スピードが上がり、品質も上がり、保守の手間も減る。これがAPIの最大の恩恵です。 +

+
+
+
+
+ +
+ +

APIは「知っている」だけで世界が広がる概念です。

+

+ 次にアプリを使うとき、「この裏側でどんなAPIが動いているんだろう?」と想像してみてください。天気予報の数字も、ログインボタンも、決済完了の画面も、すべてAPIが繋いでいます。テクノロジーの見え方が、少しだけ変わるはずです。 +

+
+
+ + +
+ + + + + diff --git a/.claude/skills/creating-visual-explainers/references/node-install-guide.md b/.claude/skills/creating-visual-explainers/references/node-install-guide.md new file mode 100644 index 0000000..7e4bf23 --- /dev/null +++ b/.claude/skills/creating-visual-explainers/references/node-install-guide.md @@ -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 を再起動してからもう一度試すよう案内する。 diff --git a/.claude/skills/creating-visual-explainers/scripts/deploy-diagram.sh b/.claude/skills/creating-visual-explainers/scripts/deploy-diagram.sh new file mode 100644 index 0000000..c9da16e --- /dev/null +++ b/.claude/skills/creating-visual-explainers/scripts/deploy-diagram.sh @@ -0,0 +1,95 @@ +#!/bin/bash +set -e + +HTML_FILE="${1:?使い方: deploy-diagram.sh [スラッグ]}" +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 '' "$HTML_FILE"; then + echo -e "${RED}エラー: $HTML_FILE に タグが見つかりません${NC}" >&2 + echo "HTML ファイルの構造が壊れている可能性があります。" >&2 + exit 1 +fi + +TEMP_DIR=$(mktemp -d) +trap 'rm -rf "$TEMP_DIR"' EXIT + +sed "s|||" "$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}" diff --git a/.claude/skills/setup-fb-tool/SKILL.md b/.claude/skills/setup-fb-tool/SKILL.md new file mode 100644 index 0000000..644a044 --- /dev/null +++ b/.claude/skills/setup-fb-tool/SKILL.md @@ -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インストール手順 diff --git a/.claude/skills/setup-fb-tool/references/node-install-guide.md b/.claude/skills/setup-fb-tool/references/node-install-guide.md new file mode 100644 index 0000000..8de1cbb --- /dev/null +++ b/.claude/skills/setup-fb-tool/references/node-install-guide.md @@ -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 を再起動してからもう一度試すよう案内する。 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd72145 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..0d39d51 --- /dev/null +++ b/README.md @@ -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 を取得 + ├─ の直前に widget.js の + + + + +
+ +
+
+ + + + + +
+
+ + テクノロジー +
+

+ APIの仕組み +

+

+ 「APIって何?」と聞かれて、うまく答えられない。
+ ひとことで言うと ── +

+
+ + + + + +
+
+

+ API = ソフトウェアの「注文窓口」 +

+

+ 中身を知らなくても、決まった形で頼めば結果が届く +

+
+ + +
+ +
+
+ +
+
あなたのアプリ
+
「天気を教えて」
+
+ + +
+
+ リクエスト + + +
+
+ + +
+
+ +
+
API
+
注文を届け、結果を返す
+
レストランのウェイター役
+
+ + +
+
+ 依頼 + + +
+
+ + +
+
+ +
+
サービス
+
処理して結果を返す
+
+
+ + +
+
+ + 結果(レスポンス)があなたのアプリに届く +
+
+ + +
+
+
あなたも毎日使っている
+
+
+ + +
+
+ + 天気予報 +
+
+ + Googleログイン +
+
+ + オンライン決済 +
+
+ + 地図・ナビ +
+
+ +
+ +

+ ここから先で、この仕組みをひとつずつ丁寧に解説していきます。 +

+ + + + + +
+
+
+ +
+

そもそもAPIって何?

+
+ +

+ API(エーピーアイ)は Application Programming Interface(アプリケーション・プログラミング・インターフェース)の略称です。正式名称を聞いても「何のこと?」と思いますよね。 +

+ +

+ まずは日常のたとえで考えてみましょう。レストランに行った場面を想像してください。 +

+ +

+ あなた(お客さん)は、厨房に直接入って料理を作ることはできません。厨房のルールも、調理器具の使い方も知りません。でも、ウェイターに「パスタをください」と注文すれば、厨房で作られた料理があなたのテーブルに届きます。厨房の中で何が起きているかを知る必要はありません。 +

+ + +
+

レストランで考えるAPIの役割

+ +
+ +
+
+ +
+
あなた
+
お客さん
+
「パスタください」
+
+ + +
+
+ 注文 + + +
+
+ + +
+
+ +
+
API
+
ウェイター
+
注文を伝え、料理を届ける
+
+ + +
+
+ 依頼 + + +
+
+ + +
+
+ +
+
サーバー
+
厨房
+
パスタを作って渡す
+
+
+ +
+
+ + 料理(レスポンス)があなたのテーブルに届く +
+
+
+ +

+ この比喩がAPIの本質をほぼ言い当てています。あなた(アプリ)は、厨房(サーバー)の中で何が起きているかを知る必要がありません。ウェイター(API)に決まった形式で注文を伝えれば、結果が返ってくる。これがAPIです。 +

+ + +
+
+
+ +
+
+

ここがポイント

+

+ APIは「仲介役」です。相手の内部構造を知らなくても、決まったルールで話しかければ結果が返ってくる。これがAPIの本質です。この「決まったルール」のことを、エンジニアは「インターフェース(Interface)」と呼びます。 +

+
+
+
+
+ + + + + +
+
+
+ +
+

もう少し正確に言うと

+
+ +

+ レストランのたとえで、ざっくりとしたイメージはつかめましたか? ここからもう少しだけ正確に説明します。 +

+ +

+ APIとは、ひとことで言えば「ソフトウェア同士が会話するための窓口」です。あなたが使っているアプリの裏側で、別のサービスのデータや機能を借りてくるための「取り決め」と考えてください。 +

+ +
+
技術的な定義
+

+ API = あるソフトウェアの機能を、 + 別のソフトウェアから使えるようにする仕組み +

+

出典: MDN Web Docs — Web API の紹介

+
+ +

+ これだけだとまだ抽象的に感じるかもしれません。では、APIがある世界とない世界を比べてみましょう。 +

+ + +
+ +
+
+ + BEFORE — APIがない世界 +
+
    +
  • + + 天気情報が欲しければ、自分で気象観測の仕組みを構築する +
  • +
  • + + 決済機能が欲しければ、クレジットカード処理を自前で開発する +
  • +
  • + + 地図を表示したければ、地図データを自分で作成・更新する +
  • +
  • + + ユーザー認証はパスワード管理からセキュリティ対策まで全部自前 +
  • +
+
+ + 膨大な開発コストと時間。バグのリスクも高い。 +
+
+ + +
+
+ + AFTER — APIがある世界 +
+
    +
  • + + 天気情報は天気予報APIに問い合わせるだけで取得できる +
  • +
  • + + 決済はStripe APIに任せれば数行のコードで完成 +
  • +
  • + + 地図はGoogle Maps APIで高品質な地図を即表示できる +
  • +
  • + + ログインはGoogleやAppleのAPIで安全に認証できる +
  • +
+
+ + 「自分が本当に作るべきもの」に集中できる。 +
+
+
+
+ + + + + +
+
+
+ +
+

APIの仕組み — リクエストとレスポンス

+
+ +

+ APIでのやり取りは、実はとてもシンプルです。基本は「聞く(リクエスト)」「答える(レスポンス)」の2つだけ。 +

+ +

+ リクエスト(Request)とは、「こういう情報をください」「この処理をしてください」とAPIに送るメッセージのことです。レスポンス(Response)は、APIがその要求に対して返す結果です。この2つのやり取りを分解すると、4つのステップになります。 +

+ + +
+

APIリクエスト〜レスポンスの流れ

+ +
+
+
1
+ +
リクエスト送信
+
あなたのアプリが
「こういう情報ください」
とAPIに送る
+
+ +
+ + +
+ +
+
2
+ +
APIが受け取る
+
リクエストの内容を
チェック・認証する
(門番の役割)
+
+ +
+ + +
+ +
+
3
+ +
サーバーが処理
+
データベース検索や
計算など、実際の
処理を実行する
+
+ +
+ + +
+ +
+
4
+ +
レスポンス返却
+
処理結果をあなたの
アプリに返す
(料理が届く瞬間)
+
+
+
+ +

+ 言葉だけだとまだピンとこないかもしれません。では、実際のコードで見てみましょう。たとえば、天気予報APIから東京の天気を取得するコードは、たったこれだけです。 +

+ + +
+
+ + JavaScript — 天気予報APIの呼び出し例 +
+
// 1. APIにリクエストを送る(「東京の天気を教えて」と聞く)
+const response = await fetch("https://api.weather.example.com/current?city=tokyo");
+
+// 2. レスポンスをJSON形式(データの構造)に変換する
+const data = await response.json();
+
+// 3. 必要なデータを取り出して使う
+console.log(data.temperature); // → "22°C"
+console.log(data.condition);   // → "晴れ"
+console.log(data.humidity);    // → "65%"
+
+ + +
+

+ + コードの解説(1行ずつ読み解く) +

+
+
+ 1 + fetch() は「指定したURLに問い合わせる」命令。URLの末尾にある ?city=tokyo が「東京の情報が欲しい」というリクエストの中身です。レストランのたとえで言えば「パスタください」にあたる部分。 +
+
+ 2 + 返ってきたデータは機械向けの生データなので、.json() で人間が読みやすい形(JSON = JavaScript Object Notation)に変換します。JSONは「名前: 値」の組み合わせでデータを表現する書式で、Web業界で最も広く使われています。 +
+
+ 3 + 変換したデータから data.temperature のように、ドット(.)で区切って欲しい情報を名前で取り出します。辞書で単語を引くのに似ています。 +
+
+
+ +

+ ターミナル(コマンドを入力する黒い画面)からもAPIを試せます。curl(カール)というコマンドを使うと、たった1行でAPIにリクエストを送れます。 +

+ + +
+
+
+
+
+
+
+
+ + ターミナル — curlコマンドでAPIを叩く +
+
+
$ curl https://api.weather.example.com/current?city=tokyo
+
+# 返ってくるレスポンス(JSON形式)
+{
+  "city": "東京",
+  "temperature": "22°C",
+  "condition": "晴れ",
+  "humidity": "65%"
+}
+
+ +
+
+
+ +
+
+

ちょっと補足: URLの構造

+

+ https://api.weather.example.com/current?city=tokyo のURLは、大きく3つの部分に分かれます。api.weather.example.com がAPIの住所(ベースURL)、/current が「何を」(現在の天気)、?city=tokyo が「どこの」(東京)というパラメータです。レストランで例えると「〇〇レストランの(住所)、メインメニューから(何を)、パスタを(詳細)」に対応します。 +

+
+
+
+ +

+ では、このAPIのレスポンスが実際のアプリではどう表示されるのでしょうか? あなたが見ている天気アプリの画面を覗いてみましょう。 +

+ + +
+
+
+
+
+
+
+
+ + weather-app.example.com +
+
+
+
+
現在地: 東京
+
+ + 22°C +
+
晴れ
+
+
65%
+
3m/s
+
+
+
+
+ +
+
+
+ +
+
+

APIの結果 → アプリの画面

+

+ 上の天気アプリは、裏側で temperature: "22°C"condition: "晴れ" というAPIレスポンスを受け取り、見やすいデザインに変換して表示しています。あなたが普段見ているきれいな画面の裏側では、こうしたAPIのやり取りが行われているのです。 +

+
+
+
+
+ + + + + +
+
+
+ +
+

身近なAPIの例 — 実はあなたも毎日使っている

+
+ +

+ 「API」と聞くとプログラマーの専門用語に聞こえるかもしれません。しかし、あなたがスマホで何気なくやっている日常の操作の裏側では、たくさんのAPIが動いています。「あなたが見ている画面」の裏側で、APIが何をしているのかを図解します。 +

+ +
+ +
+
+
+
東京
+
+ + 22°C +
+
晴れ
+
+
65%
+
3m/s
+
+
+
+
+

+ 天気予報アプリ +

+
裏側でAPIがやっていること
+

気象庁のサーバーに「東京の最新天気データをください」とリクエストを送り、気温・天候・湿度・風速などのデータをJSON形式で受け取っている

+
+
+ + +
+
+
アカウントにログイン
+
+
+ G +
+ Google でログイン +
+
+
+ または +
+
+
メールアドレスで登録
+
+
+

+ 「Googleでログイン」ボタン +

+
裏側でAPIがやっていること
+

GoogleのOAuth API(オーオース = 認可の仕組み)に「このユーザーの身元を確認してください」と問い合わせ、認証トークン(本人確認済みの証)を受け取っている

+
+
+ + +
+
+
+ +
+
お支払い完了
+
¥1,980
+
VISA **** 4242
+
+
+

+ オンライン決済 +

+
裏側でAPIがやっていること
+

Stripe等の決済APIが、クレジットカード会社のサーバーと暗号化通信を行い、与信確認(この人は支払える?)→ 決済処理 → 結果通知を実行している

+

出典: Stripe API 公式ドキュメント

+
+
+ + +
+
+
+
+
+
+
+ 現在地 +
+
+
+
+ + 東京駅 +
+
12分
+
+
+
+
+

+ 地図・ナビアプリ +

+
裏側でAPIがやっていること
+

Google Maps APIが地図画像の取得、現在の交通情報の取得、経路計算をそれぞれ別のAPIに問い合わせ、統合して表示している

+
+
+
+ +
+
+
+ +
+
+

気づきましたか?

+

+ 上の4つの例に共通しているのは、あなたがAPIの存在を意識していないということです。天気を確認するとき「今からAPIを呼ぶぞ」とは思いませんよね。優れたAPIは、ユーザーにその存在を感じさせません。まるで空気のように、裏側で静かに仕事をしているのです。 +

+
+
+
+
+ + + + + +
+
+
+ +
+

APIを使うとどう嬉しいか

+
+ +

+ ここまで読んで「APIは便利そうだ」と感じてもらえたと思います。では、開発者の視点から見たとき、APIを使うことで具体的にどのくらいの効果があるのか。数字と一緒に見てみましょう。 +

+ +
+
+
50回+
+
あなたが1日に
APIを使っている回数
+
+
+
24,000+
+
世界で公開されている
APIの数
+

出典: ProgrammableWeb

+
+
+
0.2秒
+
多くのAPIの
平均応答時間
+
+
+ +
+
+
+ +
+
+

開発スピードが上がる

+

決済、認証、地図、翻訳...。これらをゼロから作ると何ヶ月もかかりますが、APIを使えば数日〜数時間で実装できます。車を作りたいとき、エンジンから設計する必要はないのです。

+
+
+ +
+
+ +
+
+

品質が担保される

+

Google Maps、Stripe、AWSなど、各分野の専門企業が何千人体制で開発・運用しているAPIの品質は、個人や小さなチームで再現できるレベルではありません。その品質を「借りる」ことができます。

+
+
+ +
+
+ +
+
+

保守の手間が減る

+

API提供元がバグ修正・機能改善・セキュリティ更新を継続的に行ってくれます。あなたはAPIを「使うだけ」。自分でゼロから作った機能は、自分でずっと面倒を見続ける必要があります。

+
+
+ +
+
+ +
+
+

レゴのように拡張できる

+

APIはレゴブロックのように組み合わせられます。たとえば「翻訳API + 音声合成API」を組み合わせれば、多言語音声読み上げ機能が作れます。1つのAPIだけでは実現できない価値が、組み合わせで生まれるのです。

+
+
+
+
+ + + + + +
+
+
+ +
+

よくある誤解

+
+ +

+ APIについて学び始めると、多くの人が同じところでつまずきます。ここでは、初学者が陥りがちな3つの誤解を取り上げて、正しい理解に修正します。 +

+ +
+
+
+
+ +
+

誤解: 「APIはプログラマーだけが使うもの」

+
+
+
+
+ +
+
+

実際は:

+

+ あなたも毎日APIを使っています。朝、天気アプリを開く。SNSにログインする。電子マネーで買い物する。これらの操作はすべて、裏側でAPIが動いています。プログラマーが「APIを使う」のは、この仕組みのコードを書いている側にいるだけの話。気づかないうちにAPIの恩恵を毎日受けているのです。 +

+
+
+
+
+ +
+
+
+ +
+

誤解: 「APIって難しい技術でしょ?」

+
+
+
+
+ +
+
+

実際は:

+

+ APIの概念自体は「注文して結果を受け取る」というシンプルな仕組みです。レストランで注文できるなら、APIの概念は理解できます。先ほどのコード例のように、実際のプログラムも数行で書けることがほとんどです。難しいのはAPIそのものではなく、「APIで何を作るか」を考える部分。道具はシンプル、使いこなすセンスが問われるということです。 +

+
+
+
+
+ +
+
+
+ +
+

誤解: 「APIを使うと個人情報が漏れそうで怖い」

+
+
+
+
+ +
+
+

実際は:

+

+ 適切に設計されたAPIは、必要最小限の情報だけをやり取りします。たとえば銀行のAPIが口座残高を返す際、パスワードや暗証番号は一切含まれません。APIはデータの「窓口」であり、「何の情報を公開し、何を隠すか」を厳密に制御できます。むしろ、データベースに直接触るよりもAPIを介した方が安全なのです。レストランのたとえで言えば、お客さんが直接厨房に入るより、ウェイターを通した方が厨房の秩序が保たれるのと同じです。 +

+
+
+
+
+
+
+ + + + + +
+
+
+ +
+

まとめ — 覚えておきたい3つのこと

+
+ +

+ 長い図解を読んでいただきありがとうございます。最後に、この記事で伝えたかったことを3つに絞ってまとめます。 +

+ +
+
+
+
01
+
+

APIは「ソフトウェアの窓口」

+

+ レストランのウェイターのように、あなた(アプリ)とサーバーの間を取り持つ仲介役。相手の内部構造を知らなくても、決まったルール(インターフェース)で話しかければ結果が返ってきます。 +

+
+
+
+ +
+
+
02
+
+

あなたはすでにAPIユーザー

+

+ 天気予報、SNSログイン、地図検索、オンライン決済。気づかないうちに、あなたの日常はAPIに支えられています。APIは特別な人だけのものではなく、全員の生活を支える仕組みです。 +

+
+
+
+ +
+
+
03
+
+

APIで「車輪の再発明」がなくなる

+

+ すでにある優れた機能をAPIで借りることで、自分は「自分にしか作れない部分」に集中できます。開発スピードが上がり、品質も上がり、保守の手間も減る。これがAPIの最大の恩恵です。 +

+
+
+
+
+ +
+ +

APIは「知っている」だけで世界が広がる概念です。

+

+ 次にアプリを使うとき、「この裏側でどんなAPIが動いているんだろう?」と想像してみてください。天気予報の数字も、ログインボタンも、決済完了の画面も、すべてAPIが繋いでいます。テクノロジーの見え方が、少しだけ変わるはずです。 +

+
+
+ + +
+
+

AI-Driven School の図解ツールで作成

+
+ + + + + diff --git a/public/test.html b/public/test.html new file mode 100644 index 0000000..f094bf9 --- /dev/null +++ b/public/test.html @@ -0,0 +1,30 @@ + + + + + FB Tool Test + + + +

FBツール テストページ

+

このページはFBツールの動作確認用です。テキストを選択してコメントを追加できます。

+ +

サンプルテキスト

+

AI-Driven Schoolは、AI時代に最適化した企画スキルと開発スキルを実践的に学ぶ6ヶ月間のオンラインプログラムです。講義設計・運営ドキュメント・CS・マーケティング・ツール開発をこのリポジトリで管理しています。

+

受講生はチームワークを通じて、実践的なプロジェクトに取り組みます。フィードバックは成長の鍵であり、適切なタイミングで的確なコメントを届けることが重要です。

+

カリキュラムは全12回で構成され、毎月のテーマに沿って段階的にスキルを積み上げていきます。各回の講義では座学とワークを組み合わせ、すぐに使える知識と経験を提供します。

+ +

フォーカス連動テスト

+

サイドバーのカードをクリックすると、対応するハイライト箇所にスクロールします。逆に、本文のハイライトをクリックするとサイドバーのカードにスクロールします。カードにホバーするとハイライトがパルスで点滅します。

+ +

リサイズテスト

+

サイドバーの左端にマウスを当てるとカーソルが左右矢印に変わります。ドラッグで幅を変更でき、300pxから800pxの範囲で調整可能です。変更した幅はページをリロードしても維持されます。

+ + + + diff --git a/public/vercel.svg b/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/widget.js b/public/widget.js new file mode 100644 index 0000000..c4f8b25 --- /dev/null +++ b/public/widget.js @@ -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: '', + check: '', + rotateCcw: '', + pencil: '', + trash: '', + user: '', + x: '', + panelRight: '', + }; + + 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 = '' + icon('panelRight', 16); + if (unresolvedCount > 0) h += '' + unresolvedCount + ''; + h += ''; + h += 'コメント'; + 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 = '
'; + + // Header + html += '
'; + html += '
コメント' + counts.all + '
'; + html += '
'; + html += ''; + html += '
'; + + // User + if (state.username) { + if (state.editingName) { + html += '
'; + } else { + html += '
' + icon('user', 14) + esc(state.username) + '
'; + } + } + + // Filters + html += '
'; + ['unresolved', 'resolved', 'all'].forEach(function (f) { + var label = f === 'unresolved' ? '未解決' : f === 'resolved' ? '解決済' : 'すべて'; + html += ''; + }); + html += '
'; + + // List + var items = filtered().sort(function (a, b) { return b.timestamp - a.timestamp; }); + html += '
'; + if (items.length === 0) { + html += '
' + icon('message', 40) + 'コメントはまだありません
テキストを選択してコメントを追加
'; + } else { + items.forEach(function (c) { html += renderCard(c); }); + } + html += '
'; + + 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 = '
'; + + // Header + h += '
'; + h += '
' + esc(c.author.charAt(0)) + '
'; + h += '' + esc(c.author) + ''; + h += '' + fmtTime(c.timestamp) + ''; + if (c.resolved) h += '' + icon('check', 12) + ' 解決済'; + h += '
'; + h += '' + esc(c.priority.charAt(0).toUpperCase() + c.priority.slice(1)) + ''; + h += '
'; + + // Quote + if (c.quote) { + var q = c.quote.length > 100 ? c.quote.substring(0, 100) + '...' : c.quote; + h += '
' + esc(q) + '
'; + } + + // Body / Edit + if (state.editingId === c.id) { + h += '
'; + h += '
'; + ['must', 'better', 'want'].forEach(function (p) { + var sel = state.editPriority === p; + var pc2 = PRIORITY_COLORS[p]; + h += ''; + }); + h += '
'; + h += ''; + h += '
'; + h += '
'; + } else { + h += '
' + esc(c.content) + '
'; + } + + // Actions + if (state.editingId !== c.id) { + h += '
'; + h += ''; + h += ''; + if (isOwn) h += ''; + if (isOwn) h += ''; + h += '
'; + } + + // Replies + var replies = getReplies(c.id); + if (replies.length > 0) { + h += '
'; + replies.forEach(function (r) { + var isOwnReply = r.author === state.username; + h += '
' + esc(r.author.charAt(0)) + '
' + esc(r.author) + ' · ' + fmtTime(r.timestamp) + '
'; + if (state.editingId === r.id) { + h += '
'; + } else { + h += '
' + esc(r.content) + '
'; + h += '
'; + h += ''; + if (isOwnReply) h += ''; + if (isOwnReply) h += ''; + h += '
'; + } + h += '
'; + }); + h += '
'; + } + + // Reply input + if (state.replyingTo === c.id) { + h += '
'; + } + + h += '
'; + 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 = '
コメントを追加
'; + h += '
' + esc(q) + '
'; + h += ''; + h += '
'; + ['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 += ''; + }); + h += '
'; + h += '
'; + h += ''; + h += ''; + h += '
'; + 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 = '

ようこそ

コメントに表示される名前を入力してください。

'; + 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(); + } +})(); diff --git a/public/window.svg b/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scripts/migrate.ts b/scripts/migrate.ts new file mode 100644 index 0000000..f7fe8a7 --- /dev/null +++ b/scripts/migrate.ts @@ -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); +}); diff --git a/src/app/api/comments/route.ts b/src/app/api/comments/route.ts new file mode 100644 index 0000000..deb6c06 --- /dev/null +++ b/src/app/api/comments/route.ts @@ -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) { + 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, + }; +} diff --git a/src/app/api/fetch-page/route.ts b/src/app/api/fetch-page/route.ts new file mode 100644 index 0000000..5bbdb02 --- /dev/null +++ b/src/app/api/fetch-page/route.ts @@ -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, '>'); +} + +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 = ``; + const injected = html.replace('', `${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 }); + } +} diff --git a/src/app/favicon.ico b/src/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/src/app/favicon.ico differ diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..a8da733 --- /dev/null +++ b/src/app/globals.css @@ -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; + } +} \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..b59fc4a --- /dev/null +++ b/src/app/layout.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..1f2e5f0 --- /dev/null +++ b/src/app/page.tsx @@ -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(null); + + const handleScrollToQuote = useCallback((commentId: string) => { + viewerRef.current?.scrollToQuote(commentId); + }, []); + + return ( + <> + {!username && } +
+
+ +
+ setSidebarOpen(!sidebarOpen)} + onScrollToQuote={handleScrollToQuote} + /> +
+ + ); +} + +export default function Page() { + return ( + + + + ); +} diff --git a/src/components/comment-card.tsx b/src/components/comment-card.tsx new file mode 100644 index 0000000..b614b5c --- /dev/null +++ b/src/components/comment-card.tsx @@ -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 = { + 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 ( +
+ {/* Header */} +
+
+
+ {comment.author.charAt(0)} +
+ {comment.author} + {formatTime(comment.timestamp)} + {comment.resolved && ( + ✓ 解決済 + )} +
+ {isStrikethrough ? ( + + 取消 + + ) : ( + isOwn && store.cyclePriority(comment.id)} + title={isOwn ? 'クリックで優先度を変更' : ''} + > + {priorityCfg.label} + + )} +
+ + {/* Quote */} + {comment.quote && ( +
onScrollToQuote?.(comment.id)} + > + “{comment.quote.length > 100 ? comment.quote.substring(0, 100) + '...' : comment.quote}” +
+ )} + + {/* Body / Edit */} + {editing ? ( +
+
+ {(['must', 'better', 'want'] as Priority[]).map((p) => ( + + ))} +
+