feat: 図解コメントツールを最新版に置換

Made-with: Cursor
This commit is contained in:
hiroki ito 2026-03-20 13:30:40 +09:00
parent bc8a281e4c
commit 8343e190a7
61 changed files with 3544 additions and 7029 deletions

View File

@ -1,25 +0,0 @@
{
"$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": {}
}

4921
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
{
"name": "diagram-comment-tool",
"name": "commenting-visual-explainers",
"version": "0.1.0",
"private": true,
"scripts": {
@ -7,30 +7,29 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest",
"build:widget": "esbuild src/widget/index.ts --bundle --format=iife --outfile=public/widget.js --minify",
"dev:widget": "esbuild src/widget/index.ts --bundle --format=iife --outfile=public/widget.js --watch",
"db:migrate": "npx tsx scripts/migrate.ts"
},
"dependencies": {
"@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"
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/jsdom": "^28.0.1",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.3.1",
"esbuild": "^0.27.4",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "5.9.3"
"jsdom": "^27.0.1",
"typescript": "5.9.3",
"vitest": "^3.2.4"
}
}

View File

@ -1,7 +0,0 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 391 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,969 +0,0 @@
<!-- このファイルの<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">&#165;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>

View File

@ -1,30 +0,0 @@
<!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>

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 128 B

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 385 B

View File

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDb } from '@/lib/db';
import type { Comment } from '@/shared/types';
function corsHeaders() {
return {
@ -56,35 +57,52 @@ export async function POST(request: NextRequest) {
return json({ ok: true });
}
type PutAction = 'edit' | 'resolve' | 'cyclePriority' | 'rename';
export async function PUT(request: NextRequest) {
if (!verifyToken(request)) return json({ error: 'Unauthorized' }, 403);
const body = await request.json();
const { id, ...updates } = body;
const { id, action } = body as { id: string; action: PutAction; [key: string]: unknown };
if (!id) return json({ error: 'id is required' }, 400);
if (!action) return json({ error: 'action is required' }, 400);
const sql = getDb();
if (updates.content !== undefined && updates.priority !== undefined) {
switch (action) {
case 'edit': {
const { content, priority } = body;
if (content === undefined || priority === undefined) return json({ error: 'content and priority are required for edit' }, 400);
await sql`
UPDATE comments SET content = ${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}
UPDATE comments SET content = ${content}, priority = ${priority}, updated_at = ${Date.now()} WHERE id = ${id}
`;
break;
}
case 'resolve': {
const { resolved, resolvedBy, resolvedAt } = body;
await sql`
UPDATE comments SET resolved = ${resolved}, resolved_by = ${resolvedBy || null}, resolved_at = ${resolvedAt || null} WHERE id = ${id}
`;
break;
}
case 'cyclePriority': {
const { priority } = body;
if (!priority) return json({ error: 'priority is required for cyclePriority' }, 400);
await sql`
UPDATE comments SET priority = ${priority} WHERE id = ${id}
`;
break;
}
case 'rename': {
const { author, oldAuthor, projectSlug } = body;
if (!author || !oldAuthor || !projectSlug) return json({ error: 'author, oldAuthor, projectSlug are required for rename' }, 400);
await sql`
UPDATE comments SET author = ${author} WHERE project_slug = ${projectSlug} AND author = ${oldAuthor}
`;
break;
}
default:
return json({ error: `Unknown action: ${action}` }, 400);
}
return json({ ok: true });
@ -101,24 +119,24 @@ export async function DELETE(request: NextRequest) {
return json({ ok: true });
}
function toComment(row: Record<string, unknown>) {
function toComment(row: Record<string, unknown>): Comment {
return {
id: row.id as string,
author: row.author as string,
type: row.type as string,
quote: row.quote as string,
type: (row.type as Comment['type']) || 'comment',
quote: (row.quote as string) || '',
quoteContext: {
beforeText: row.quote_context_before as string,
afterText: row.quote_context_after as string,
beforeText: (row.quote_context_before as string) || '',
afterText: (row.quote_context_after as string) || '',
},
content: row.content as string,
priority: row.priority as string,
content: (row.content as string) || '',
priority: (row.priority as Comment['priority']) || 'want',
parentId: (row.parent_id as string) || null,
resolved: row.resolved as boolean,
resolved: (row.resolved as boolean) || false,
resolvedBy: (row.resolved_by as string) || null,
resolvedAt: row.resolved_at ? Number(row.resolved_at) : null,
timestamp: Number(row.timestamp),
updatedAt: row.updated_at ? Number(row.updated_at) : null,
pageUrl: row.page_url as string,
pageUrl: (row.page_url as string) || '',
};
}

View File

@ -1,92 +0,0 @@
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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
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 });
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@ -1,129 +0,0 @@
@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;
}
}

View File

@ -1,35 +1,11 @@
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>
<body>{children}</body>
</html>
);
}

View File

@ -1,41 +0,0 @@
'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>
);
}

View File

@ -1,253 +0,0 @@
'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)}
>
&ldquo;{comment.quote.length > 100 ? comment.quote.substring(0, 100) + '...' : comment.quote}&rdquo;
</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>
);
}

View File

@ -1,195 +0,0 @@
'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>
);
}

View File

@ -1,185 +0,0 @@
'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>
);
});

View File

@ -1,91 +0,0 @@
'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>
);
}

View File

@ -1,51 +0,0 @@
'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>
);
}

View File

@ -1,204 +0,0 @@
'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">
&ldquo;{selection.text.length > 120 ? selection.text.substring(0, 120) + '...' : selection.text}&rdquo;
</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>
);
}

View File

@ -1,52 +0,0 @@
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 }

View File

@ -1,60 +0,0 @@
"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 }

View File

@ -1,103 +0,0 @@
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,
}

View File

@ -1,157 +0,0 @@
"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,
}

View File

@ -1,20 +0,0 @@
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 }

View File

@ -1,55 +0,0 @@
"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 }

View File

@ -1,25 +0,0 @@
"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 }

View File

@ -1,82 +0,0 @@
"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 }

View File

@ -1,18 +0,0 @@
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 }

View File

@ -1,66 +0,0 @@
"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 }

View File

@ -1,61 +0,0 @@
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);
}

View File

@ -1,213 +0,0 @@
'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;
}

View File

@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

5
src/shared/api-client.ts Normal file
View File

@ -0,0 +1,5 @@
export function authHeaders(token: string): Record<string, string> {
const hdrs: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) hdrs['Authorization'] = 'Bearer ' + token;
return hdrs;
}

61
src/shared/constants.ts Normal file
View File

@ -0,0 +1,61 @@
import type { Priority } from './types';
export const PRIORITY_COLORS: Record<
Priority,
{ bg: string; text: string; light: string; border: string }
> = {
must: {
bg: '#ef4444',
text: '#fff',
light: 'rgba(239,68,68,0.1)',
border: 'rgba(239,68,68,0.3)',
},
better: {
bg: '#f59e0b',
text: '#fff',
light: 'rgba(245,158,11,0.1)',
border: 'rgba(245,158,11,0.3)',
},
want: {
bg: '#22c55e',
text: '#fff',
light: 'rgba(34,197,94,0.1)',
border: 'rgba(34,197,94,0.3)',
},
};
export const PRIORITY_LABELS: Record<Priority, string> = {
must: 'Must',
better: 'Better',
want: 'Want',
};
export const PRIORITY_CYCLE: Record<Priority, Priority> = {
must: 'better',
better: 'want',
want: 'must',
};
export const HIGHLIGHT_COLORS: Record<
Priority,
{ bg: string; hoverBg: string; border: string }
> = {
must: {
bg: 'rgba(239,68,68,0.15)',
hoverBg: 'rgba(239,68,68,0.25)',
border: '#ef4444',
},
better: {
bg: 'rgba(245,158,11,0.15)',
hoverBg: 'rgba(245,158,11,0.25)',
border: '#f59e0b',
},
want: {
bg: 'rgba(34,197,94,0.15)',
hoverBg: 'rgba(34,197,94,0.25)',
border: '#22c55e',
},
};
export const USERNAME_KEY = 'fb-username';
export const SIDEBAR_WIDTH_KEY = 'fb-sidebar-width';

10
src/shared/slug.ts Normal file
View File

@ -0,0 +1,10 @@
export function slugify(url: string): string {
return url
.replace(/^https?:\/\//, '')
.replace(/[^a-zA-Z0-9\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF]/g, '_')
.substring(0, 100);
}
export function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).substring(2, 8);
}

12
src/shared/time.ts Normal file
View File

@ -0,0 +1,12 @@
export function fmtTime(ts: number): string {
const d = Date.now() - ts;
if (d < 60000) return 'たった今';
if (d < 3600000) return Math.floor(d / 60000) + '分前';
if (d < 86400000) return Math.floor(d / 3600000) + '時間前';
return new Date(ts).toLocaleDateString('ja-JP', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}

23
src/widget/api.ts Normal file
View File

@ -0,0 +1,23 @@
import { authHeaders } from '../shared/api-client';
let apiBase = '';
let apiToken = '';
export function initApi(base: string, token: string): void {
apiBase = base;
apiToken = token;
}
export function api(method: string, params: Record<string, unknown>): Promise<unknown> {
let url = apiBase + '/api/comments';
const hdrs = authHeaders(apiToken);
const opts: RequestInit = { method, headers: hdrs };
if (method === 'GET') {
url += '?slug=' + encodeURIComponent(params.slug as string);
} else if (method === 'DELETE') {
url += '?id=' + encodeURIComponent(params.id as string);
} else {
opts.body = JSON.stringify(params);
}
return fetch(url, opts).then((r) => r.json());
}

24
src/widget/dom.ts Normal file
View File

@ -0,0 +1,24 @@
type Attrs = Record<string, unknown>;
export function el(tag: string, attrs?: Attrs | null, children?: string | Node | (Node | null)[] | null): HTMLElement {
const e = document.createElement(tag);
if (attrs) Object.keys(attrs).forEach((k) => {
const v = attrs[k];
if (k === 'className') e.className = v as string;
else if (k === 'innerHTML') e.innerHTML = v as string;
else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.substring(2).toLowerCase(), v as EventListener);
else e.setAttribute(k, String(v));
});
if (children != null) {
if (typeof children === 'string') e.textContent = children;
else if (Array.isArray(children)) children.forEach((c) => { if (c) e.appendChild(c); });
else e.appendChild(children);
}
return e;
}
export function esc(s: string): string {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}

110
src/widget/highlight.ts Normal file
View File

@ -0,0 +1,110 @@
import { state, type FbComment } from './state';
function collectTextNodes(): Text[] {
const nodes: Text[] = [];
const tw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
acceptNode(n) {
const p = n.parentElement;
if (!p) return NodeFilter.FILTER_REJECT;
if (p.tagName === 'SCRIPT' || p.tagName === 'STYLE') return NodeFilter.FILTER_REJECT;
if (p.closest('#fb-sidebar,#fb-toggle,#fb-popup,.fb-highlight')) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
});
let n: Node | null;
while ((n = tw.nextNode())) nodes.push(n as Text);
return nodes;
}
export function mapNormToOrig(orig: string, normStart: number, normEnd: number): [number, number] | null {
const normalized = orig.replace(/[\s\u00A0]+/g, ' ');
if (normStart >= normalized.length) return null;
let origIdx = 0, normIdx = 0;
let origStart = -1, origEnd = -1;
while (origIdx < orig.length) {
if (normIdx === normStart && origStart === -1) origStart = origIdx;
if (normIdx === normEnd) { origEnd = origIdx; break; }
if (normIdx < normalized.length && orig[origIdx] === normalized[normIdx]) {
origIdx++; normIdx++;
} else {
origIdx++;
}
}
if (origEnd === -1 && normIdx >= normEnd) origEnd = origIdx;
if (origStart === -1 || origEnd === -1 || origStart >= origEnd) return null;
return [origStart, origEnd];
}
function wrapTextRange(node: Text, start: number, end: number, comment: FbComment, onClickHighlight: (id: string) => void): void {
const orig = node.textContent!;
const before = document.createTextNode(orig.substring(0, start));
const mark = document.createElement('mark');
mark.className = 'fb-highlight fb-highlight-' + comment.priority;
mark.dataset.commentId = comment.id;
mark.textContent = orig.substring(start, end);
mark.addEventListener('click', () => { onClickHighlight(comment.id); });
const after = document.createTextNode(orig.substring(end));
node.parentNode!.insertBefore(before, node);
node.parentNode!.insertBefore(mark, node);
node.parentNode!.insertBefore(after, node);
node.parentNode!.removeChild(node);
}
export function applyHighlights(onClickHighlight: (id: string) => void): void {
document.querySelectorAll('.fb-highlight').forEach((el) => {
const t = document.createTextNode(el.textContent || '');
el.parentNode!.replaceChild(t, el);
});
document.body.normalize();
state.comments.filter((c) => !c.parentId && !c.resolved && c.quote && c.quote.length >= 2).forEach((c) => {
const search = c.quote.replace(/[\s\u00A0]+/g, ' ').trim();
const textNodes = collectTextNodes();
let found = false;
for (let i = 0; i < textNodes.length; i++) {
const node = textNodes[i];
const orig = node.textContent!;
const di = orig.indexOf(search);
if (di !== -1) {
try { wrapTextRange(node, di, di + search.length, c, onClickHighlight); found = true; } catch (_) { /* ignore */ }
break;
}
const norm = orig.replace(/[\s\u00A0]+/g, ' ');
const ni = norm.indexOf(search);
if (ni === -1) continue;
const range = mapNormToOrig(orig, ni, ni + search.length);
if (range) {
try { wrapTextRange(node, range[0], range[1], c, onClickHighlight); found = true; } catch (_) { /* ignore */ }
break;
}
}
if (!found) {
let concat = '';
const nodeMap: Array<{ node: Text; start: number; end: number }> = [];
for (let j = 0; j < textNodes.length; j++) {
const s = concat.length;
concat += textNodes[j].textContent;
nodeMap.push({ node: textNodes[j], start: s, end: concat.length });
}
const concatNorm = concat.replace(/[\s\u00A0]+/g, ' ');
const ci = concatNorm.indexOf(search);
if (ci !== -1) {
const range = mapNormToOrig(concat, ci, ci + search.length);
if (range) {
const mStart = range[0], mEnd = range[1];
for (let k = nodeMap.length - 1; k >= 0; k--) {
const nm = nodeMap[k];
if (nm.end <= mStart || nm.start >= mEnd) continue;
const ls = Math.max(0, mStart - nm.start);
const le = Math.min(nm.node.textContent!.length, mEnd - nm.start);
try { wrapTextRange(nm.node, ls, le, c, onClickHighlight); } catch (_) { /* ignore */ }
}
}
}
}
});
}

14
src/widget/icons.ts Normal file
View File

@ -0,0 +1,14 @@
export const SVG: Record<string, string> = {
message: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"/></svg>',
check: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
rotateCcw: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>',
pencil: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></svg>',
trash: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>',
user: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>',
x: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>',
panelRight: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/></svg>',
};
export function icon(name: string, size = 14, strokeWidth = 2): string {
return (SVG[name] || '').replace(/SIZE/g, String(size)).replace(/SW/g, String(strokeWidth));
}

158
src/widget/index.ts Normal file
View File

@ -0,0 +1,158 @@
/**
* Icons: Lucide (https://lucide.dev)
* ISC License - Copyright (c) Lucide Contributors 2026
*/
// document.currentScript は IIFE 先頭でキャプチャ必須esbuild の import 巻き上げ前に実行される)
const SCRIPT = document.currentScript as HTMLScriptElement | null;
const API_BASE = SCRIPT ? SCRIPT.src.replace(/\/widget\.js.*$/, '') : '';
const API_TOKEN = SCRIPT ? (SCRIPT.dataset.token || '') : '';
import type { Priority } from '../shared/types';
import { USERNAME_KEY, PRIORITY_CYCLE } from '../shared/constants';
import { generateId } from '../shared/slug';
import { initApi, api } from './api';
import { state, slug, type FbComment } from './state';
import { injectStyles } from './styles';
import { render, toggleSidebar, setRenderDeps } from './render/index';
import { setSidebarActions } from './render/sidebar';
import { applyHighlights } from './highlight';
import { scrollToQuote, scrollToCard } from './scroll';
import { setupTextSelection } from './selection';
initApi(API_BASE, API_TOKEN);
function loadComments(): Promise<void> {
return api('GET', { slug }).then((c) => {
if (Array.isArray(c)) state.comments = c;
render();
applyHighlights(onClickHighlight);
}).catch(() => {
render();
applyHighlights(onClickHighlight);
});
}
function closePopup(): void {
state.selectedText = '';
state.selectedRect = null;
state.popupContent = '';
state.popupPriority = 'must';
render();
}
function submitComment(priority: Priority): void {
if (!state.username || !state.selectedText) return;
const c: FbComment = {
id: generateId(), author: state.username, type: 'comment',
quote: state.selectedText, quoteContext: state.selectedQuoteContext,
content: state.popupContent.trim(),
priority, parentId: null, pageUrl: window.location.href,
projectSlug: slug, timestamp: Date.now(),
resolved: false, resolvedBy: null, resolvedAt: null, updatedAt: null,
};
state.comments.push(c);
closePopup();
applyHighlights(onClickHighlight);
api('POST', c as unknown as Record<string, unknown>).then(loadComments);
}
function resolveComment(id: string): void {
const c = state.comments.find((x) => x.id === id);
if (!c) return;
const now = !c.resolved;
c.resolved = now;
c.resolvedBy = now ? state.username : null;
c.resolvedAt = now ? Date.now() : null;
render(); applyHighlights(onClickHighlight);
api('PUT', { id, action: 'resolve', resolved: now, resolvedBy: c.resolvedBy, resolvedAt: c.resolvedAt });
}
function cyclePriority(id: string): void {
const c = state.comments.find((x) => x.id === id);
if (!c || c.author !== state.username) return;
c.priority = PRIORITY_CYCLE[c.priority] || 'must';
render(); applyHighlights(onClickHighlight);
api('PUT', { id, action: 'cyclePriority', priority: c.priority });
}
function deleteComment(id: string): void {
state.comments = state.comments.filter((c) => c.id !== id && c.parentId !== id);
render(); applyHighlights(onClickHighlight);
api('DELETE', { id });
}
function deleteReply(id: string): void {
state.comments = state.comments.filter((c) => c.id !== id);
render();
api('DELETE', { id });
}
function saveEdit(id: string): void {
const c = state.comments.find((x) => x.id === id);
if (!c) return;
c.content = state.editContent;
c.priority = state.editPriority;
state.editingId = null;
render(); applyHighlights(onClickHighlight);
api('PUT', { id, action: 'edit', content: c.content, priority: c.priority });
}
function submitReply(parentId: string): void {
if (!state.replyText.trim() || !state.username) return;
const r: FbComment = {
id: generateId(), author: state.username, type: 'comment',
quote: '', quoteContext: { beforeText: '', afterText: '' },
content: state.replyText.trim(), priority: 'want',
parentId, pageUrl: window.location.href,
projectSlug: slug, timestamp: Date.now(),
resolved: false, resolvedBy: null, resolvedAt: null, updatedAt: null,
};
state.comments.push(r);
state.replyingTo = null;
state.replyText = '';
render();
api('POST', r as unknown as Record<string, unknown>);
}
function finishNameEdit(): void {
if (state.nameInput.trim() && state.nameInput.trim() !== state.username) {
const oldName = state.username;
state.username = state.nameInput.trim();
localStorage.setItem(USERNAME_KEY, state.username);
api('PUT', { id: '_rename', action: 'rename', author: state.username, oldAuthor: oldName, projectSlug: slug }).then(loadComments);
}
state.editingName = false;
render();
}
function onClickHighlight(id: string): void {
scrollToCard(id, toggleSidebar);
}
// Wire up dependencies before first render
setRenderDeps(closePopup, submitComment);
setSidebarActions({
toggleSidebar,
cyclePriority,
scrollToQuote,
resolveComment,
deleteComment,
deleteReply,
saveEdit,
submitReply,
finishNameEdit,
});
function init(): void {
injectStyles();
render();
setupTextSelection(render, closePopup);
loadComments();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}

79
src/widget/render/card.ts Normal file
View File

@ -0,0 +1,79 @@
import { PRIORITY_COLORS } from '../../shared/constants';
import { fmtTime } from '../../shared/time';
import { esc } from '../dom';
import { icon } from '../icons';
import { state, type FbComment } from '../state';
export function renderCard(c: FbComment): string {
const isOwn = c.author === state.username;
const pc = PRIORITY_COLORS[c.priority] || PRIORITY_COLORS.want;
let h = '<div class="fb-card' + (c.resolved ? ' resolved' : '') + '" style="border-left-color:' + pc.bg + '" data-id="' + c.id + '">';
h += '<div class="fb-card-head"><div class="fb-card-head-left">';
h += '<div class="fb-avatar">' + esc(c.author.charAt(0)) + '</div>';
h += '<span class="fb-author">' + esc(c.author) + '</span>';
h += '<span class="fb-time">' + fmtTime(c.timestamp) + '</span>';
if (c.resolved) h += '<span class="fb-resolved-mark">' + icon('check', 12) + ' 解決済</span>';
h += '</div>';
h += '<span class="fb-badge-p' + (isOwn ? ' own' : '') + '" style="background:' + pc.bg + '" data-action="cycle" data-id="' + c.id + '">' + esc(c.priority.charAt(0).toUpperCase() + c.priority.slice(1)) + '</span>';
h += '</div>';
if (c.quote) {
const q = c.quote.length > 100 ? c.quote.substring(0, 100) + '...' : c.quote;
h += '<div class="fb-quote" style="border-left-color:' + pc.bg + '" data-action="scroll-quote" data-id="' + c.id + '">' + esc(q) + '</div>';
}
if (state.editingId === c.id) {
h += '<div class="fb-edit-area">';
h += '<div class="fb-edit-pri">';
(['must', 'better', 'want'] as const).forEach((p) => {
const sel = state.editPriority === p;
const pc2 = PRIORITY_COLORS[p];
h += '<button data-action="set-edit-pri" data-pri="' + p + '" style="' + (sel ? 'background:' + pc2.bg + ';color:#fff;border-color:' + pc2.bg : '') + '">' + p.charAt(0).toUpperCase() + p.slice(1) + '</button>';
});
h += '</div>';
h += '<textarea data-action="edit-textarea">' + esc(state.editContent) + '</textarea>';
h += '<div class="fb-edit-btns"><button data-action="cancel-edit">キャンセル</button><button class="save" data-action="save-edit" data-id="' + c.id + '">保存</button></div>';
h += '</div>';
} else {
h += '<div class="fb-body">' + esc(c.content) + '</div>';
}
if (state.editingId !== c.id) {
h += '<div class="fb-actions">';
h += '<button class="fb-act" data-action="reply" data-id="' + c.id + '">' + icon('message', 12) + '返信</button>';
h += '<button class="fb-act res" data-action="resolve" data-id="' + c.id + '">' + (c.resolved ? icon('rotateCcw', 12) : icon('check', 12)) + (c.resolved ? '戻す' : '解決') + '</button>';
if (isOwn) h += '<button class="fb-act" data-action="edit" data-id="' + c.id + '">' + icon('pencil', 12) + '編集</button>';
if (isOwn) h += '<button class="fb-act del" data-action="delete" data-id="' + c.id + '">' + icon('trash', 12) + '削除</button>';
h += '</div>';
}
const replies = state.comments.filter((r) => r.parentId === c.id).sort((a, b) => a.timestamp - b.timestamp);
if (replies.length > 0) {
h += '<div class="fb-replies">';
replies.forEach((r) => {
const isOwnReply = r.author === state.username;
h += '<div class="fb-reply-item"><div class="fb-reply-avatar">' + esc(r.author.charAt(0)) + '</div><div style="flex:1;min-width:0"><div class="fb-reply-meta"><strong>' + esc(r.author) + '</strong> &middot; ' + fmtTime(r.timestamp) + '</div>';
if (state.editingId === r.id) {
h += '<div class="fb-edit-area"><textarea data-action="edit-textarea">' + esc(state.editContent) + '</textarea><div class="fb-edit-btns"><button data-action="cancel-edit">キャンセル</button><button class="save" data-action="save-edit" data-id="' + r.id + '">保存</button></div></div>';
} else {
h += '<div class="fb-reply-text">' + esc(r.content) + '</div>';
h += '<div class="fb-actions" style="margin-top:4px">';
h += '<button class="fb-act" data-action="reply" data-id="' + c.id + '">' + icon('message', 12) + '返信</button>';
if (isOwnReply) h += '<button class="fb-act" data-action="edit" data-id="' + r.id + '">' + icon('pencil', 12) + '編集</button>';
if (isOwnReply) h += '<button class="fb-act del" data-action="delete-reply" data-id="' + r.id + '">' + icon('trash', 12) + '削除</button>';
h += '</div>';
}
h += '</div></div>';
});
h += '</div>';
}
if (state.replyingTo === c.id) {
h += '<div class="fb-reply-input"><textarea placeholder="返信を入力..." data-action="reply-textarea">' + esc(state.replyText) + '</textarea><button data-action="submit-reply" data-id="' + c.id + '"' + (state.replyText.trim() ? '' : ' disabled') + '>送信</button></div>';
}
h += '</div>';
return h;
}

View File

@ -0,0 +1,22 @@
import type { Priority } from '../../shared/types';
import { renderToggle } from './toggle';
import { renderSidebar, toggleSidebar } from './sidebar';
import { renderPopup } from './popup';
import { renderNameDialog } from './name-dialog';
let _closePopup: (() => void) | null = null;
let _submitComment: ((priority: Priority) => void) | null = null;
export function setRenderDeps(closePopup: () => void, submitComment: (priority: Priority) => void): void {
_closePopup = closePopup;
_submitComment = submitComment;
}
export function render(): void {
renderToggle(toggleSidebar);
renderSidebar(render);
renderPopup(render, _closePopup!, _submitComment!);
renderNameDialog(render);
}
export { toggleSidebar };

View File

@ -0,0 +1,26 @@
import { USERNAME_KEY } from '../../shared/constants';
import { el } from '../dom';
import { state } from '../state';
export function renderNameDialog(onRender: () => void): void {
const existing = document.getElementById('fb-name-overlay');
if (state.username) { if (existing) existing.remove(); return; }
if (existing) return;
const overlay = el('div', { id: 'fb-name-overlay', className: 'fb-name-overlay' });
overlay.innerHTML = '<div class="fb-name-box"><h2>ようこそ</h2><p>コメントに表示される名前を入力してください。</p><input placeholder="例: 田中太郎" id="fb-name-field"><button id="fb-name-submit" disabled>始める</button></div>';
document.body.appendChild(overlay);
const input = document.getElementById('fb-name-field') as HTMLInputElement;
const btn = document.getElementById('fb-name-submit') as HTMLButtonElement;
input.addEventListener('input', () => { btn.disabled = !input.value.trim(); });
input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && input.value.trim()) { setName(input.value.trim(), onRender); } });
btn.addEventListener('click', () => { if (input.value.trim()) setName(input.value.trim(), onRender); });
setTimeout(() => { input.focus(); }, 100);
}
function setName(name: string, onRender: () => void): void {
state.username = name;
localStorage.setItem(USERNAME_KEY, name);
const overlay = document.getElementById('fb-name-overlay');
if (overlay) overlay.remove();
onRender();
}

View File

@ -0,0 +1,61 @@
import type { Priority } from '../../shared/types';
import { PRIORITY_COLORS } from '../../shared/constants';
import { el, esc } from '../dom';
import { state } from '../state';
export function renderPopup(onRender: () => void, closePopup: () => void, submitComment: (priority: Priority) => void): void {
let popup = document.getElementById('fb-popup');
const isNew = !popup;
if (isNew) {
popup = el('div', { id: 'fb-popup', className: 'fb-popup' });
document.body.appendChild(popup);
popup.addEventListener('click', (e) => {
const t = (e.target as HTMLElement).closest('[data-action]') as HTMLElement | null;
if (!t) return;
if (t.dataset.action === 'cancel-popup') { closePopup(); }
else if (t.dataset.action === 'set-popup-pri') { state.popupPriority = t.dataset.pri as Priority; onRender(); }
else if (t.dataset.action === 'submit-popup') { submitComment(state.popupPriority); }
});
popup.addEventListener('input', (e) => {
if ((e.target as HTMLElement).dataset.action === 'popup-textarea') state.popupContent = (e.target as HTMLTextAreaElement).value;
});
popup.addEventListener('keydown', (e) => {
if ((e.target as HTMLElement).dataset.action === 'popup-textarea' && (e.metaKey || e.ctrlKey) && e.key === 'Enter') {
submitComment(state.popupPriority);
}
});
}
if (!state.selectedRect) { popup!.classList.remove('show'); return; }
popup!.classList.add('show');
const q = state.selectedText.length > 120 ? state.selectedText.substring(0, 120) + '...' : state.selectedText;
const selPc = PRIORITY_COLORS[state.popupPriority];
let h = '<div class="fb-popup-head"><span>コメントを追加</span></div>';
h += '<div class="fb-popup-quote">' + esc(q) + '</div>';
h += '<textarea placeholder="フィードバックを入力..." data-action="popup-textarea">' + esc(state.popupContent) + '</textarea>';
h += '<div class="fb-popup-pri">';
(['must', 'better', 'want'] as const).forEach((p) => {
const pc = PRIORITY_COLORS[p];
const sel = state.popupPriority === p;
const 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;
const rect = state.selectedRect;
const pw = 400, ph = 300, m = 12;
let top = rect.bottom + m;
if (top + ph > window.innerHeight) top = rect.top - ph - m;
if (top < m) top = Math.max(m, (window.innerHeight - ph) / 2);
const availW = state.sidebarOpen ? window.innerWidth - state.sidebarWidth : window.innerWidth;
const left = Math.max(m, Math.min(rect.left, availW - pw - m));
popup!.style.top = top + 'px';
popup!.style.left = left + 'px';
}

View File

@ -0,0 +1,197 @@
import type { Priority, FilterMode } from '../../shared/types';
import { SIDEBAR_WIDTH_KEY } from '../../shared/constants';
import { el, esc } from '../dom';
import { icon } from '../icons';
import { state } from '../state';
import { renderCard } from './card';
function applySidebarWidth(): void {
const sb = document.getElementById('fb-sidebar');
if (!sb) return;
const w = state.sidebarWidth;
sb.style.width = w + 'px';
sb.style.right = state.sidebarOpen ? '0px' : (-(w + 20)) + 'px';
document.body.style.marginRight = state.sidebarOpen ? w + 'px' : '';
}
export function toggleSidebar(): void {
state.sidebarOpen = !state.sidebarOpen;
const btn = document.getElementById('fb-toggle');
if (btn) btn.style.display = state.sidebarOpen ? 'none' : '';
document.body.style.transition = 'margin-right 0.3s ease';
applySidebarWidth();
}
function topLevel() { return state.comments.filter((c) => !c.parentId); }
function filtered() {
const tl = topLevel();
if (state.filter === 'resolved') return tl.filter((c) => c.resolved);
if (state.filter === 'all') return tl;
return tl.filter((c) => !c.resolved);
}
export function renderSidebar(onRender: () => void): void {
let sb = document.getElementById('fb-sidebar');
const isNew = !sb;
if (isNew) {
sb = el('div', { id: 'fb-sidebar' });
document.body.appendChild(sb);
sb.addEventListener('mouseover', (e) => {
const card = (e.target as HTMLElement).closest('.fb-card') as HTMLElement | null;
if (!card) return;
const mark = document.querySelector('.fb-highlight[data-comment-id="' + card.dataset.id + '"]');
if (mark) mark.classList.add('fb-pulse');
});
sb.addEventListener('mouseout', (e) => {
const card = (e.target as HTMLElement).closest('.fb-card') as HTMLElement | null;
if (!card) return;
if (e.relatedTarget && card.contains(e.relatedTarget as Node)) return;
const mark = document.querySelector('.fb-highlight[data-comment-id="' + card.dataset.id + '"]');
if (mark) mark.classList.remove('fb-pulse');
});
}
applySidebarWidth();
const tl = topLevel();
const counts = {
unresolved: tl.filter((c) => !c.resolved).length,
resolved: tl.filter((c) => c.resolved).length,
all: tl.length,
};
let html = '<div class="fb-resize-handle"></div>';
html += '<div class="fb-header">';
html += '<div class="fb-header-left"><span class="fb-header-title">コメント</span><span class="fb-header-count">' + counts.all + '</span></div>';
html += '<div class="fb-header-actions">';
html += '<button class="fb-hdr-btn" data-action="close" title="閉じる">' + icon('x', 18) + '</button>';
html += '</div></div>';
if (state.username) {
if (state.editingName) {
html += '<div class="fb-user-row"><input class="fb-name-input" value="' + esc(state.nameInput) + '" data-action="name-input" autofocus></div>';
} else {
html += '<div class="fb-user-row" data-action="edit-name">' + icon('user', 14) + esc(state.username) + '</div>';
}
}
html += '<div class="fb-filters">';
(['unresolved', 'resolved', 'all'] as const).forEach((f) => {
const label = f === 'unresolved' ? '未解決' : f === 'resolved' ? '解決済' : 'すべて';
html += '<button class="fb-filter' + (state.filter === f ? ' active' : '') + '" data-filter="' + f + '">' + label + '<span class="cnt">' + counts[f] + '</span></button>';
});
html += '</div>';
const items = filtered().sort((a, b) => b.timestamp - a.timestamp);
html += '<div class="fb-list">';
if (items.length === 0) {
html += '<div class="fb-empty">' + icon('message', 40) + 'コメントはまだありません<br><span style="font-size:11px">テキストを選択してコメントを追加</span></div>';
} else {
items.forEach((c) => { html += renderCard(c); });
}
html += '</div>';
sb!.innerHTML = html;
if (isNew) bindSidebarEvents(sb!, onRender);
}
export interface SidebarActions {
toggleSidebar: () => void;
cyclePriority: (id: string) => void;
scrollToQuote: (id: string) => void;
resolveComment: (id: string) => void;
deleteComment: (id: string) => void;
deleteReply: (id: string) => void;
saveEdit: (id: string) => void;
submitReply: (id: string) => void;
finishNameEdit: () => void;
}
let _actions: SidebarActions | null = null;
export function setSidebarActions(actions: SidebarActions): void {
_actions = actions;
}
function bindSidebarEvents(sb: HTMLElement, onRender: () => void): void {
sb.addEventListener('click', (e) => {
const t = (e.target as HTMLElement).closest('[data-action]') as HTMLElement | null;
if (t) {
const action = t.dataset.action;
const id = t.dataset.id;
if (action === 'close') { _actions?.toggleSidebar(); }
else if (action === 'edit-name') { state.editingName = true; state.nameInput = state.username; onRender(); }
else if (action === 'cycle' && id) { _actions?.cyclePriority(id); }
else if (action === 'scroll-quote' && id) { _actions?.scrollToQuote(id); }
else if (action === 'reply' && id) { state.replyingTo = state.replyingTo === id ? null : id; state.replyText = ''; onRender(); }
else if (action === 'resolve' && id) { _actions?.resolveComment(id); }
else if (action === 'edit' && id) { const c = state.comments.find((x) => x.id === id); if (c) { state.editingId = id; state.editContent = c.content; state.editPriority = c.priority; onRender(); } }
else if (action === 'delete' && id) { _actions?.deleteComment(id); }
else if (action === 'delete-reply' && id) { _actions?.deleteReply(id); }
else if (action === 'cancel-edit') { state.editingId = null; onRender(); }
else if (action === 'save-edit' && id) { _actions?.saveEdit(id); }
else if (action === 'submit-reply' && id) { _actions?.submitReply(id); }
else if (action === 'set-edit-pri') { state.editPriority = t.dataset.pri as Priority; onRender(); }
return;
}
const filterBtn = (e.target as HTMLElement).closest('[data-filter]') as HTMLElement | null;
if (filterBtn) { state.filter = filterBtn.dataset.filter as FilterMode; onRender(); return; }
const card = (e.target as HTMLElement).closest('.fb-card') as HTMLElement | null;
if (card && card.dataset.id) {
_actions?.scrollToQuote(card.dataset.id);
}
});
sb.addEventListener('mousedown', (e) => {
if (!(e.target as HTMLElement).closest('.fb-resize-handle')) return;
e.preventDefault();
const startX = e.clientX;
const startWidth = state.sidebarWidth;
const handle = (e.target as HTMLElement).closest('.fb-resize-handle')!;
document.body.classList.add('fb-resizing');
handle.classList.add('active');
function onMove(ev: MouseEvent) {
state.sidebarWidth = Math.min(800, Math.max(300, startWidth + (startX - ev.clientX)));
const s = document.getElementById('fb-sidebar');
if (s) s.style.width = state.sidebarWidth + 'px';
document.body.style.transition = 'none';
document.body.style.marginRight = state.sidebarWidth + 'px';
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.classList.remove('fb-resizing');
handle.classList.remove('active');
document.body.style.transition = '';
localStorage.setItem(SIDEBAR_WIDTH_KEY, String(state.sidebarWidth));
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
sb.addEventListener('input', (e) => {
const t = e.target as HTMLInputElement;
if (t.dataset.action === 'name-input') { state.nameInput = t.value; }
else if (t.dataset.action === 'edit-textarea') { state.editContent = t.value; }
else if (t.dataset.action === 'reply-textarea') { state.replyText = t.value; const btn = sb.querySelector('[data-action="submit-reply"]') as HTMLButtonElement | null; if (btn) btn.disabled = !state.replyText.trim(); }
});
sb.addEventListener('keydown', (e) => {
const t = e.target as HTMLElement;
if (t.dataset.action === 'name-input') {
if (e.key === 'Enter') { _actions?.finishNameEdit(); }
else if (e.key === 'Escape') { state.editingName = false; onRender(); }
}
if (t.dataset.action === 'reply-textarea' && (e.metaKey || e.ctrlKey) && e.key === 'Enter') {
const id = (sb.querySelector('[data-action="submit-reply"]') as HTMLElement | null)?.dataset.id;
if (id) _actions?.submitReply(id);
}
});
sb.addEventListener('blur', (e) => {
if ((e.target as HTMLElement).dataset?.action === 'name-input') { _actions?.finishNameEdit(); }
}, true);
}

View File

@ -0,0 +1,18 @@
import { el } from '../dom';
import { icon } from '../icons';
import { state } from '../state';
export function renderToggle(toggleSidebar: () => void): void {
let btn = document.getElementById('fb-toggle');
if (!btn) {
btn = el('button', { id: 'fb-toggle', onClick: toggleSidebar });
document.body.appendChild(btn);
}
const unresolvedCount = state.comments.filter((c) => !c.parentId && !c.resolved).length;
let 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' : '';
}

32
src/widget/scroll.ts Normal file
View File

@ -0,0 +1,32 @@
import type { Priority } from '../shared/types';
import { state } from './state';
const PRIORITY_FLASH: Record<Priority, string> = {
must: 'rgba(239,68,68,0.4)',
better: 'rgba(245,158,11,0.4)',
want: 'rgba(34,197,94,0.4)',
};
export function scrollToQuote(id: string): void {
const mark = document.querySelector('.fb-highlight[data-comment-id="' + id + '"]') as HTMLElement | null;
if (!mark) return;
mark.scrollIntoView({ behavior: 'smooth', block: 'center' });
const c = state.comments.find((x) => x.id === id);
const flashColor = c ? (PRIORITY_FLASH[c.priority] || PRIORITY_FLASH.want) : PRIORITY_FLASH.want;
const orig = mark.style.backgroundColor;
mark.style.backgroundColor = flashColor;
mark.style.transition = 'background-color 0.3s';
setTimeout(() => { mark.style.backgroundColor = orig; }, 1500);
}
export function scrollToCard(id: string, toggleSidebar: () => void): void {
const wasClosed = !state.sidebarOpen;
if (wasClosed) { toggleSidebar(); }
setTimeout(() => {
const card = document.querySelector('.fb-card[data-id="' + id + '"]') as HTMLElement | null;
if (!card) return;
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
card.classList.add('fb-focused');
setTimeout(() => { card.classList.remove('fb-focused'); }, 1500);
}, wasClosed ? 350 : 0);
}

35
src/widget/selection.ts Normal file
View File

@ -0,0 +1,35 @@
import { state, type QuoteContext } from './state';
export function getQuoteContext(range: Range): QuoteContext {
let before = '', after = '';
try {
const br = document.createRange();
br.setStart(document.body, 0);
br.setEnd(range.startContainer, range.startOffset);
before = br.toString().slice(-50).replace(/[\s\u00A0]+/g, ' ').trim();
const ar = document.createRange();
ar.setStart(range.endContainer, range.endOffset);
ar.setEnd(document.body, document.body.childNodes.length);
after = ar.toString().slice(0, 50).replace(/[\s\u00A0]+/g, ' ').trim();
} catch (_) { /* ignore */ }
return { beforeText: before, afterText: after };
}
export function setupTextSelection(onRender: () => void, closePopup: () => void): void {
document.addEventListener('mouseup', (e) => {
if ((e.target as HTMLElement).closest('#fb-sidebar,#fb-toggle,#fb-popup')) return;
const sel = window.getSelection();
const text = sel?.toString().trim();
if (!text || !sel || sel.rangeCount === 0 || !state.username) return;
const range = sel.getRangeAt(0);
state.selectedText = text.replace(/[\s\u00A0]+/g, ' ').substring(0, 200);
state.selectedRect = range.getBoundingClientRect();
state.selectedQuoteContext = getQuoteContext(range);
state.popupContent = '';
onRender();
});
document.addEventListener('mousedown', (e) => {
if ((e.target as HTMLElement).closest('#fb-popup')) return;
if (state.selectedRect) closePopup();
});
}

68
src/widget/state.ts Normal file
View File

@ -0,0 +1,68 @@
import type { Priority, FilterMode } from '../shared/types';
import { USERNAME_KEY, SIDEBAR_WIDTH_KEY } from '../shared/constants';
import { slugify } from '../shared/slug';
export interface QuoteContext {
beforeText: string;
afterText: string;
}
export interface FbComment {
id: string;
author: string;
type: string;
quote: string;
quoteContext: QuoteContext;
content: string;
priority: Priority;
parentId: string | null;
resolved: boolean;
resolvedBy: string | null;
resolvedAt: number | null;
timestamp: number;
updatedAt: number | null;
pageUrl: string;
projectSlug?: string;
}
export interface WidgetState {
username: string;
comments: FbComment[];
filter: FilterMode;
sidebarOpen: boolean;
selectedText: string;
selectedQuoteContext: QuoteContext;
selectedRect: DOMRect | null;
popupContent: string;
editingId: string | null;
editContent: string;
editPriority: Priority;
replyingTo: string | null;
replyText: string;
editingName: boolean;
nameInput: string;
popupPriority: Priority;
sidebarWidth: number;
}
export const state: WidgetState = {
username: localStorage.getItem(USERNAME_KEY) || '',
comments: [],
filter: 'unresolved',
sidebarOpen: false,
selectedText: '',
selectedQuoteContext: { beforeText: '', afterText: '' },
selectedRect: null,
popupContent: '',
editingId: null,
editContent: '',
editPriority: 'want',
replyingTo: null,
replyText: '',
editingName: false,
nameInput: '',
popupPriority: 'must',
sidebarWidth: parseInt(localStorage.getItem(SIDEBAR_WIDTH_KEY) || '', 10) || 400,
};
export const slug = slugify(window.location.href);

136
src/widget/styles.ts Normal file
View File

@ -0,0 +1,136 @@
export function injectStyles(): void {
const 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}',
'#fb-toggle{position:fixed;right:0;top:50%;transform:translateY(-50%);width:36px;background:var(--fb-bg);border:1px solid var(--fb-border);border-right:none;border-radius:8px 0 0 8px;cursor:pointer;z-index:99999;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;font-family:var(--fb-font);transition:all .15s;box-shadow:-2px 0 8px rgba(0,0,0,0.06);padding:8px 0}',
'#fb-toggle:hover{background:var(--fb-muted);box-shadow:-2px 0 12px rgba(0,0,0,0.1)}',
'#fb-toggle .fb-toggle-icon{color:var(--fb-muted-fg);display:flex;align-items:center;justify-content:center;position:relative}',
'#fb-toggle .fb-toggle-label{font-size:10px;color:var(--fb-accent);font-weight:700;writing-mode:vertical-rl;letter-spacing:1px;line-height:1;white-space:nowrap}',
'#fb-toggle .fb-badge{position:absolute;top:-6px;left:-6px;background:var(--fb-destructive);color:#fff;font-size:9px;font-weight:700;min-width:16px;height:16px;border-radius:8px;display:flex;align-items:center;justify-content:center;padding:0 3px;line-height:1}',
'#fb-sidebar{position:fixed;top:0;height:100vh;background:rgba(245,245,245,0.5);border-left:1px solid var(--fb-border);z-index:99998;transition:right .3s ease;display:flex;flex-direction:column;font-family:var(--fb-font);font-size:14px;color:var(--fb-fg)}',
'#fb-sidebar *{box-sizing:border-box}',
'.fb-resize-handle{position:absolute;left:-3px;top:0;width:6px;height:100%;cursor:col-resize;z-index:1}',
'.fb-resize-handle:hover{background:rgba(59,130,246,0.15)}',
'.fb-resize-handle.active{background:rgba(59,130,246,0.3)}',
'body.fb-resizing{cursor:col-resize !important;-webkit-user-select:none !important;user-select:none !important}',
'body.fb-resizing *{cursor:col-resize !important}',
'.fb-header{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--fb-border)}',
'.fb-header-left{display:flex;align-items:center;gap:8px}',
'.fb-header-title{font-size:14px;font-weight:700;color:var(--fb-accent)}',
'.fb-header-count{background:var(--fb-muted);color:var(--fb-muted-fg);font-size:11px;padding:1px 7px;border-radius:10px}',
'.fb-header-actions{display:flex;align-items:center;gap:2px}',
'.fb-hdr-btn{background:none;border:none;cursor:pointer;padding:6px;border-radius:6px;color:var(--fb-muted-fg);transition:all .15s;display:inline-flex;align-items:center;gap:4px;font-family:var(--fb-font);font-size:12px}',
'.fb-hdr-btn:hover{background:var(--fb-muted);color:var(--fb-fg)}',
'.fb-user-row{display:flex;align-items:center;padding:8px 16px;border-bottom:1px solid var(--fb-border);font-size:13px;color:var(--fb-muted-fg);cursor:pointer;transition:color .15s;gap:6px}',
'.fb-user-row:hover{color:var(--fb-fg)}',
'.fb-filters{display:flex;border-bottom:1px solid var(--fb-border)}',
'.fb-filter{flex:1;padding:10px 0;text-align:center;font-size:13px;color:var(--fb-muted-fg);border:none;background:none;cursor:pointer;border-bottom:2px solid transparent;transition:all .15s;font-family:var(--fb-font)}',
'.fb-filter:hover{color:var(--fb-fg)}',
'.fb-filter.active{color:var(--fb-fg);border-bottom-color:var(--fb-accent)}',
'.fb-filter .cnt{font-size:11px;margin-left:4px;padding:1px 5px;border-radius:8px;background:var(--fb-muted);color:var(--fb-muted-fg)}',
'.fb-filter.active .cnt{background:rgba(59,130,246,0.1);color:var(--fb-accent)}',
'.fb-list{flex:1;overflow-y:auto;padding:8px}',
'.fb-empty{text-align:center;padding:60px 20px;color:var(--fb-muted-fg);font-size:13px}',
'.fb-empty svg{margin:0 auto 12px;display:block;color:var(--fb-border)}',
'.fb-card{background:var(--fb-bg);border:1px solid var(--fb-border);border-left:3px solid var(--fb-border);border-radius:12px;padding:14px;margin-bottom:6px;transition:box-shadow .3s,background .3s;cursor:pointer}',
'.fb-card:hover{box-shadow:0 1px 3px rgba(0,0,0,0.05)}',
'.fb-card.resolved{opacity:.5}',
'.fb-card-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}',
'.fb-card-head-left{display:flex;align-items:center;gap:6px}',
'.fb-avatar{width:22px;height:22px;border-radius:50%;background:var(--fb-primary);color:var(--fb-primary-fg);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;flex-shrink:0}',
'.fb-author{font-size:13px;font-weight:700;color:var(--fb-fg)}',
'.fb-time{font-size:11px;color:var(--fb-muted-fg)}',
'.fb-resolved-mark{font-size:11px;color:#22c55e;margin-left:4px;display:inline-flex;align-items:center;gap:2px}',
'.fb-badge-p{font-size:11px;font-weight:700;padding:2px 8px;border-radius:4px;color:#fff;cursor:default;transition:all .15s}',
'.fb-badge-p.own{cursor:pointer}',
'.fb-badge-p.own:hover{transform:scale(1.1);box-shadow:0 0 0 2px rgba(0,0,0,0.08)}',
'.fb-quote{font-size:12px;color:var(--fb-muted-fg);padding:6px 10px;background:var(--fb-muted);border-left:2px solid var(--fb-primary);border-radius:0 4px 4px 0;margin-bottom:8px;font-style:italic;line-height:1.5;cursor:pointer;transition:background .15s}',
'.fb-quote:hover{background:var(--fb-border)}',
'.fb-body{font-size:13px;color:var(--fb-muted-fg);line-height:1.6;margin-bottom:6px;white-space:pre-wrap}',
'.fb-actions{display:flex;gap:2px;flex-wrap:wrap}',
'.fb-act{font-size:12px;color:#a3a3a3;background:none;border:none;padding:4px 8px;border-radius:4px;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:3px;font-family:var(--fb-font)}',
'.fb-act:hover{color:var(--fb-fg)}',
'.fb-act.del:hover{color:var(--fb-destructive)}',
'.fb-act.res:hover{color:#22c55e}',
'.fb-replies{margin-top:8px;padding-top:8px;border-top:1px solid var(--fb-muted)}',
'.fb-reply-item{display:flex;gap:8px;padding:6px 0}',
'.fb-reply-item+.fb-reply-item{border-top:1px solid var(--fb-muted)}',
'.fb-reply-avatar{width:20px;height:20px;border-radius:50%;background:var(--fb-primary);color:var(--fb-primary-fg);display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:700;flex-shrink:0;margin-top:2px}',
'.fb-reply-meta{font-size:12px;color:var(--fb-muted-fg);margin-bottom:2px}',
'.fb-reply-meta strong{color:#525252;font-weight:700}',
'.fb-reply-text{font-size:13px;color:var(--fb-muted-fg);line-height:1.5}',
'.fb-reply-input{display:flex;gap:8px;margin-top:8px}',
'.fb-reply-input textarea{flex:1;padding:8px 10px;border:1px solid var(--fb-border);border-radius:6px;font-size:13px;font-family:var(--fb-font);resize:none;outline:none;min-height:36px;color:var(--fb-fg)}',
'.fb-reply-input textarea:focus{border-color:var(--fb-accent)}',
'.fb-reply-input button{padding:6px 14px;background:var(--fb-primary);color:var(--fb-primary-fg);border:none;border-radius:6px;font-size:12px;cursor:pointer;font-family:var(--fb-font);align-self:flex-end}',
'.fb-reply-input button:disabled{opacity:.4;cursor:default}',
'.fb-edit-area textarea{width:100%;padding:8px 10px;border:1px solid var(--fb-border);border-radius:6px;font-size:13px;font-family:var(--fb-font);resize:vertical;outline:none;min-height:50px;margin-bottom:6px;color:var(--fb-fg)}',
'.fb-edit-area textarea:focus{border-color:var(--fb-accent)}',
'.fb-edit-btns{display:flex;gap:6px}',
'.fb-edit-btns button{padding:4px 12px;border-radius:6px;font-size:12px;cursor:pointer;border:1px solid var(--fb-border);background:var(--fb-bg);color:var(--fb-muted-fg);font-family:var(--fb-font)}',
'.fb-edit-btns button.save{background:var(--fb-primary);color:var(--fb-primary-fg);border-color:var(--fb-primary)}',
'.fb-edit-pri{display:flex;gap:4px;margin-bottom:6px}',
'.fb-edit-pri button{padding:2px 10px;border-radius:4px;font-size:11px;font-weight:700;cursor:pointer;border:1px solid var(--fb-border);background:var(--fb-bg);color:var(--fb-muted-fg);font-family:var(--fb-font);transition:all .15s}',
'.fb-popup{position:fixed;z-index:100000;width:400px;background:var(--fb-bg);border:1px solid var(--fb-border);border-radius:12px;padding:16px;box-shadow:0 10px 25px rgba(0,0,0,0.1);font-family:var(--fb-font);display:none}',
'.fb-popup.show{display:block}',
'.fb-popup-head{margin-bottom:10px}',
'.fb-popup-head span{font-size:14px;font-weight:700;color:var(--fb-fg)}',
'.fb-popup-quote{font-size:13px;color:var(--fb-muted-fg);padding:8px 12px;background:var(--fb-muted);border-left:2px solid var(--fb-accent);border-radius:0 6px 6px 0;margin-bottom:10px;font-style:italic;line-height:1.5}',
'.fb-popup textarea{width:100%;min-height:70px;padding:10px 12px;border:1px solid var(--fb-border);border-radius:8px;font-size:14px;font-family:var(--fb-font);resize:vertical;outline:none;margin-bottom:10px;color:var(--fb-fg)}',
'.fb-popup textarea:focus{border-color:var(--fb-accent)}',
'.fb-popup textarea::placeholder{color:var(--fb-muted-fg)}',
'.fb-popup-pri{display:flex;gap:6px;margin-bottom:10px}',
'.fb-popup-pri button{flex:1;padding:7px 8px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;border:2px solid transparent;transition:all .15s;font-family:var(--fb-font)}',
'.fb-popup-actions{display:flex;gap:8px;justify-content:flex-end}',
'.fb-popup-actions button{padding:8px 18px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;transition:all .15s;font-family:var(--fb-font)}',
'.fb-popup-actions .cancel{background:none;border:1px solid var(--fb-border);color:var(--fb-muted-fg)}',
'.fb-popup-actions .cancel:hover{background:var(--fb-muted);color:var(--fb-fg)}',
'.fb-popup-actions .submit{border:none;color:#fff}',
'.fb-name-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:100001}',
'.fb-name-box{background:var(--fb-bg);border-radius:12px;padding:28px;width:360px;box-shadow:0 20px 40px rgba(0,0,0,0.15)}',
'.fb-name-box h2{font-size:18px;font-weight:700;margin:0 0 6px;color:var(--fb-fg)}',
'.fb-name-box p{font-size:14px;color:var(--fb-muted-fg);margin:0 0 16px}',
'.fb-name-box input{width:100%;padding:10px 14px;border:1px solid var(--fb-border);border-radius:8px;font-size:15px;outline:none;margin-bottom:12px;font-family:var(--fb-font);color:var(--fb-fg)}',
'.fb-name-box input:focus{border-color:var(--fb-accent)}',
'.fb-name-box input::placeholder{color:var(--fb-muted-fg)}',
'.fb-name-box button{width:100%;padding:10px;background:var(--fb-primary);color:var(--fb-primary-fg);border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:var(--fb-font)}',
'.fb-name-box button:disabled{opacity:.4;cursor:default}',
'.fb-name-input{width:100%;padding:4px 8px;border:1px solid var(--fb-border);border-radius:4px;font-size:12px;outline:none;font-family:var(--fb-font);color:var(--fb-fg)}',
'.fb-name-input:focus{border-color:var(--fb-accent)}',
'.fb-highlight{padding:1px 0;cursor:pointer;transition:background .15s}',
'.fb-highlight-must{background:rgba(239,68,68,0.15);border-bottom:2px solid #ef4444}',
'.fb-highlight-must:hover{background:rgba(239,68,68,0.25)}',
'.fb-highlight-better{background:rgba(245,158,11,0.15);border-bottom:2px solid #f59e0b}',
'.fb-highlight-better:hover{background:rgba(245,158,11,0.25)}',
'.fb-highlight-want{background:rgba(34,197,94,0.15);border-bottom:2px solid #22c55e}',
'.fb-highlight-want:hover{background:rgba(34,197,94,0.25)}',
'@keyframes fb-pulse{0%,100%{opacity:1}50%{opacity:0.4}}',
'.fb-highlight.fb-pulse{animation:fb-pulse 1s ease-in-out infinite}',
'.fb-card.fb-focused{box-shadow:0 0 0 2px var(--fb-accent);background:rgba(59,130,246,0.04)}',
'#fb-sidebar svg,#fb-toggle svg,.fb-popup svg{pointer-events:none}',
].join('\n');
document.head.appendChild(style);
}

129
tests/shared.unit.test.ts Normal file
View File

@ -0,0 +1,129 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { slugify, generateId } from '../src/shared/slug';
import { fmtTime } from '../src/shared/time';
import { PRIORITY_CYCLE, PRIORITY_COLORS, HIGHLIGHT_COLORS } from '../src/shared/constants';
import { authHeaders } from '../src/shared/api-client';
import { mapNormToOrig } from '../src/widget/highlight';
describe('slugify', () => {
test('HTTP プレフィックスを除去する', () => {
expect(slugify('https://example.com/page')).toBe('example_com_page');
});
test('日本語文字を保持する', () => {
const result = slugify('https://example.com/テスト');
expect(result).toContain('テスト');
});
test('100文字で切り詰める', () => {
const long = 'https://example.com/' + 'a'.repeat(200);
expect(slugify(long).length).toBe(100);
});
test('記号をアンダースコアに変換する', () => {
expect(slugify('https://a.b/c?d=e&f=g')).toBe('a_b_c_d_e_f_g');
});
});
describe('generateId', () => {
test('文字列を返す', () => {
expect(typeof generateId()).toBe('string');
});
test('一意性がある100回生成してユニーク', () => {
const ids = new Set(Array.from({ length: 100 }, () => generateId()));
expect(ids.size).toBe(100);
});
});
describe('fmtTime', () => {
let realNow: () => number;
beforeEach(() => {
realNow = Date.now;
vi.spyOn(Date, 'now').mockReturnValue(1700000000000);
});
afterEach(() => {
vi.restoreAllMocks();
});
test('1分未満は「たった今」', () => {
expect(fmtTime(1700000000000 - 30000)).toBe('たった今');
});
test('1時間未満は「N分前」', () => {
expect(fmtTime(1700000000000 - 300000)).toBe('5分前');
});
test('24時間未満は「N時間前」', () => {
expect(fmtTime(1700000000000 - 7200000)).toBe('2時間前');
});
test('24時間以上は日時文字列', () => {
const result = fmtTime(1700000000000 - 86400000 * 2);
expect(result).toMatch(/\d+月/);
});
});
describe('PRIORITY_CYCLE', () => {
test('must → better → want → must の循環', () => {
expect(PRIORITY_CYCLE.must).toBe('better');
expect(PRIORITY_CYCLE.better).toBe('want');
expect(PRIORITY_CYCLE.want).toBe('must');
});
test('3つの優先度が全て定義されている', () => {
expect(Object.keys(PRIORITY_CYCLE)).toEqual(['must', 'better', 'want']);
});
});
describe('PRIORITY_COLORS', () => {
test('3つの優先度が全て定義されている', () => {
expect(Object.keys(PRIORITY_COLORS)).toEqual(['must', 'better', 'want']);
});
test('各色に bg, text, light, border がある', () => {
for (const p of ['must', 'better', 'want'] as const) {
expect(PRIORITY_COLORS[p]).toHaveProperty('bg');
expect(PRIORITY_COLORS[p]).toHaveProperty('text');
expect(PRIORITY_COLORS[p]).toHaveProperty('light');
expect(PRIORITY_COLORS[p]).toHaveProperty('border');
}
});
});
describe('HIGHLIGHT_COLORS', () => {
test('3つの優先度が全て定義されている', () => {
expect(Object.keys(HIGHLIGHT_COLORS)).toEqual(['must', 'better', 'want']);
});
});
describe('authHeaders', () => {
test('トークンありの場合 Authorization を含む', () => {
const h = authHeaders('my-token');
expect(h['Authorization']).toBe('Bearer my-token');
expect(h['Content-Type']).toBe('application/json');
});
test('トークンなしの場合 Authorization を含まない', () => {
const h = authHeaders('');
expect(h['Authorization']).toBeUndefined();
expect(h['Content-Type']).toBe('application/json');
});
});
describe('mapNormToOrig', () => {
test('空白なしの単純マッチ', () => {
expect(mapNormToOrig('hello world', 0, 5)).toEqual([0, 5]);
});
test('余分な空白がある場合の正規化マッチ', () => {
// 正規化後のインデックス 6-11 は orig で 6-12 に対応(余分な空白を含む)
expect(mapNormToOrig('hello world', 6, 11)).toEqual([6, 12]);
});
test('範囲外の normStart は null を返す', () => {
expect(mapNormToOrig('short', 100, 105)).toBeNull();
});
});

View File

@ -0,0 +1,168 @@
import { describe, test, expect, beforeEach, vi } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
const WIDGET_JS = readFileSync(
resolve(__dirname, '../public/widget.js'),
'utf-8'
);
function loadWidget() {
document.head.innerHTML = '';
document.body.innerHTML = '<p>テストテキスト</p>';
const script = document.createElement('script');
script.src = 'https://my-app.vercel.app/widget.js';
script.dataset.token = 'test-token-abc';
Object.defineProperty(document, 'currentScript', {
value: script,
writable: true,
configurable: true,
});
window.localStorage.clear();
vi.stubGlobal(
'fetch',
vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve([]),
ok: true,
})
)
);
// eslint-disable-next-line no-eval
eval(WIDGET_JS);
}
describe('widget.js 初期化', () => {
beforeEach(() => {
loadWidget();
});
test('トグルボタンが DOM に追加される', () => {
const toggle = document.getElementById('fb-toggle');
expect(toggle).toBeTruthy();
expect(toggle!.tagName).toBe('BUTTON');
});
test('サイドバーが DOM に追加される', () => {
const sidebar = document.getElementById('fb-sidebar');
expect(sidebar).toBeTruthy();
});
test('スタイルが注入される', () => {
const style = document.getElementById('fb-widget-styles');
expect(style).toBeTruthy();
expect(style!.tagName).toBe('STYLE');
});
test('CSS 変数が定義されている', () => {
const style = document.getElementById('fb-widget-styles');
const css = style!.textContent || '';
expect(css).toContain('--fb-bg');
expect(css).toContain('--fb-fg');
expect(css).toContain('--fb-accent');
expect(css).toContain('--fb-border');
expect(css).toContain('--fb-muted');
expect(css).toContain('--fb-primary');
expect(css).toContain('--fb-destructive');
});
test('トグルボタンに「コメント」ラベルがある', () => {
const toggle = document.getElementById('fb-toggle');
expect(toggle!.innerHTML).toContain('コメント');
});
test('サイドバーにフィルタボタンがある', () => {
const sidebar = document.getElementById('fb-sidebar');
const html = sidebar!.innerHTML;
expect(html).toContain('未解決');
expect(html).toContain('解決済');
expect(html).toContain('すべて');
});
test('サイドバーにリサイズハンドルがある', () => {
const sidebar = document.getElementById('fb-sidebar');
const handle = sidebar!.querySelector('.fb-resize-handle');
expect(handle).toBeTruthy();
});
test('サイドバーは初期状態で非表示(右にオフセット)', () => {
const sidebar = document.getElementById('fb-sidebar') as HTMLElement;
const right = parseInt(sidebar.style.right, 10);
expect(right).toBeLessThan(0);
});
test('名前未入力時に名前入力ダイアログが表示される', () => {
const overlay = document.getElementById('fb-name-overlay');
expect(overlay).toBeTruthy();
expect(overlay!.innerHTML).toContain('ようこそ');
});
});
describe('widget.js ハイライト CSS', () => {
beforeEach(() => {
loadWidget();
});
test('ハイライトスタイルが優先度別に3色定義されている', () => {
const style = document.getElementById('fb-widget-styles');
const css = style!.textContent || '';
expect(css).toContain('.fb-highlight-must');
expect(css).toContain('.fb-highlight-better');
expect(css).toContain('.fb-highlight-want');
});
test('カードスタイルが定義されている', () => {
const style = document.getElementById('fb-widget-styles');
const css = style!.textContent || '';
expect(css).toContain('.fb-card');
expect(css).toContain('.fb-card-head');
expect(css).toContain('.fb-badge-p');
});
test('ポップアップスタイルが定義されている', () => {
const style = document.getElementById('fb-widget-styles');
const css = style!.textContent || '';
expect(css).toContain('.fb-popup');
expect(css).toContain('.fb-popup-pri');
expect(css).toContain('.fb-popup-actions');
});
});
describe('widget.js コメントなし状態', () => {
beforeEach(() => {
loadWidget();
});
test('コメントゼロ時にサイドバーに空状態メッセージが表示される', () => {
const sidebar = document.getElementById('fb-sidebar');
const html = sidebar!.innerHTML;
expect(html).toContain('コメントはまだありません');
});
test('コメントゼロ時にバッジが表示されない', () => {
const toggle = document.getElementById('fb-toggle');
expect(toggle!.querySelector('.fb-badge')).toBeNull();
});
});
describe('widget.js SVG アイコン', () => {
beforeEach(() => {
loadWidget();
});
test('トグルボタンに SVG アイコンが含まれる', () => {
const toggle = document.getElementById('fb-toggle');
expect(toggle!.innerHTML).toContain('<svg');
});
test('サイドバーの閉じるボタンに SVG アイコンが含まれる', () => {
const sidebar = document.getElementById('fb-sidebar');
const closeBtn = sidebar!.querySelector('[data-action="close"]');
expect(closeBtn).toBeTruthy();
expect(closeBtn!.innerHTML).toContain('<svg');
});
});

View File

@ -30,5 +30,5 @@
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
"exclude": ["node_modules", "src/widget", "tests"]
}

8
vitest.config.ts Normal file
View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
include: ['tests/**/*.test.ts'],
},
});