feat: 図解コメントツールを最新版に置換
Made-with: Cursor
This commit is contained in:
parent
bc8a281e4c
commit
8343e190a7
@ -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": {}
|
|
||||||
}
|
|
||||||
4927
package-lock.json
generated
4927
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "diagram-comment-tool",
|
"name": "commenting-visual-explainers",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -7,30 +7,29 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"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"
|
"db:migrate": "npx tsx scripts/migrate.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.3.0",
|
|
||||||
"@neondatabase/serverless": "^1.0.2",
|
"@neondatabase/serverless": "^1.0.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"lucide-react": "^0.577.0",
|
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3"
|
||||||
"shadcn": "^4.0.6",
|
|
||||||
"tailwind-merge": "^3.5.0",
|
|
||||||
"tw-animate-css": "^1.4.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@types/jsdom": "^28.0.1",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
|
"esbuild": "^0.27.4",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
"tailwindcss": "^4",
|
"jsdom": "^27.0.1",
|
||||||
"typescript": "5.9.3"
|
"typescript": "5.9.3",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +0,0 @@
|
|||||||
const config = {
|
|
||||||
plugins: {
|
|
||||||
"@tailwindcss/postcss": {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
@ -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 |
@ -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 |
@ -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 |
@ -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">¥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>
|
|
||||||
@ -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>
|
|
||||||
@ -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 |
928
public/widget.js
928
public/widget.js
File diff suppressed because one or more lines are too long
@ -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 |
@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getDb } from '@/lib/db';
|
import { getDb } from '@/lib/db';
|
||||||
|
import type { Comment } from '@/shared/types';
|
||||||
|
|
||||||
function corsHeaders() {
|
function corsHeaders() {
|
||||||
return {
|
return {
|
||||||
@ -56,35 +57,52 @@ export async function POST(request: NextRequest) {
|
|||||||
return json({ ok: true });
|
return json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PutAction = 'edit' | 'resolve' | 'cyclePriority' | 'rename';
|
||||||
|
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
if (!verifyToken(request)) return json({ error: 'Unauthorized' }, 403);
|
if (!verifyToken(request)) return json({ error: 'Unauthorized' }, 403);
|
||||||
const body = await request.json();
|
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 (!id) return json({ error: 'id is required' }, 400);
|
||||||
|
if (!action) return json({ error: 'action is required' }, 400);
|
||||||
|
|
||||||
const sql = getDb();
|
const sql = getDb();
|
||||||
|
|
||||||
if (updates.content !== undefined && updates.priority !== undefined) {
|
switch (action) {
|
||||||
await sql`
|
case 'edit': {
|
||||||
UPDATE comments SET content = ${updates.content}, priority = ${updates.priority}, updated_at = ${Date.now()} WHERE id = ${id}
|
const { content, priority } = body;
|
||||||
`;
|
if (content === undefined || priority === undefined) return json({ error: 'content and priority are required for edit' }, 400);
|
||||||
} 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`
|
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 });
|
return json({ ok: true });
|
||||||
@ -101,24 +119,24 @@ export async function DELETE(request: NextRequest) {
|
|||||||
return json({ ok: true });
|
return json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function toComment(row: Record<string, unknown>) {
|
function toComment(row: Record<string, unknown>): Comment {
|
||||||
return {
|
return {
|
||||||
id: row.id as string,
|
id: row.id as string,
|
||||||
author: row.author as string,
|
author: row.author as string,
|
||||||
type: row.type as string,
|
type: (row.type as Comment['type']) || 'comment',
|
||||||
quote: row.quote as string,
|
quote: (row.quote as string) || '',
|
||||||
quoteContext: {
|
quoteContext: {
|
||||||
beforeText: row.quote_context_before as string,
|
beforeText: (row.quote_context_before as string) || '',
|
||||||
afterText: row.quote_context_after as string,
|
afterText: (row.quote_context_after as string) || '',
|
||||||
},
|
},
|
||||||
content: row.content as string,
|
content: (row.content as string) || '',
|
||||||
priority: row.priority as string,
|
priority: (row.priority as Comment['priority']) || 'want',
|
||||||
parentId: (row.parent_id as string) || null,
|
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,
|
resolvedBy: (row.resolved_by as string) || null,
|
||||||
resolvedAt: row.resolved_at ? Number(row.resolved_at) : null,
|
resolvedAt: row.resolved_at ? Number(row.resolved_at) : null,
|
||||||
timestamp: Number(row.timestamp),
|
timestamp: Number(row.timestamp),
|
||||||
updatedAt: row.updated_at ? Number(row.updated_at) : null,
|
updatedAt: row.updated_at ? Number(row.updated_at) : null,
|
||||||
pageUrl: row.page_url as string,
|
pageUrl: (row.page_url as string) || '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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, '&')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>');
|
|
||||||
}
|
|
||||||
|
|
||||||
function verifyToken(request: NextRequest): boolean {
|
|
||||||
const token = process.env.API_TOKEN;
|
|
||||||
if (!token) return true;
|
|
||||||
|
|
||||||
const auth = request.headers.get('Authorization');
|
|
||||||
if (!auth?.startsWith('Bearer ')) return false;
|
|
||||||
|
|
||||||
return auth.slice(7) === token;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
if (!verifyToken(request)) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = request.nextUrl.searchParams.get('url');
|
|
||||||
if (!raw) {
|
|
||||||
return NextResponse.json({ error: 'url parameter is required' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = validateUrl(raw);
|
|
||||||
if (!result.ok) {
|
|
||||||
return NextResponse.json({ error: result.reason }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(result.url.href, { next: { revalidate: 60 } });
|
|
||||||
const html = await res.text();
|
|
||||||
|
|
||||||
const baseTag = `<base href="${escapeHtmlAttr(result.url.href)}">`;
|
|
||||||
const injected = html.replace('<head>', `<head>${baseTag}`);
|
|
||||||
|
|
||||||
return new NextResponse(injected, {
|
|
||||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : 'Unknown error';
|
|
||||||
return NextResponse.json({ error: msg }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="ja">
|
<html lang="ja">
|
||||||
<body
|
<body>{children}</body>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
||||||
>
|
|
||||||
<TooltipProvider>{children}</TooltipProvider>
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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)}
|
|
||||||
>
|
|
||||||
“{comment.quote.length > 100 ? comment.quote.substring(0, 100) + '...' : comment.quote}”
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Body / Edit */}
|
|
||||||
{editing ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
{(['must', 'better', 'want'] as Priority[]).map((p) => (
|
|
||||||
<button
|
|
||||||
key={p}
|
|
||||||
onClick={() => setEditPriority(p)}
|
|
||||||
className={cn(
|
|
||||||
'rounded px-2 py-0.5 text-xs font-bold border transition-all',
|
|
||||||
editPriority === p ? PRIORITY_CONFIG[p].badgeClass : 'border-border text-muted-foreground'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{PRIORITY_CONFIG[p].label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Textarea
|
|
||||||
value={editContent}
|
|
||||||
onChange={(e) => setEditContent(e.target.value)}
|
|
||||||
className="min-h-[60px] text-sm"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}>
|
|
||||||
キャンセル
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" onClick={handleSaveEdit}>
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="mb-2 whitespace-pre-wrap text-sm text-muted-foreground leading-relaxed">
|
|
||||||
{comment.content}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
{!editing && (
|
|
||||||
<div className="flex items-center gap-0.5">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 cursor-pointer gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={() => setShowReply(!showReply)}
|
|
||||||
>
|
|
||||||
<MessageSquare className="h-3 w-3" />
|
|
||||||
返信
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 cursor-pointer gap-1 px-2 text-xs text-muted-foreground hover:text-green-400"
|
|
||||||
onClick={() => store.resolveComment(comment.id)}
|
|
||||||
>
|
|
||||||
{comment.resolved ? <RotateCcw className="h-3 w-3" /> : <Check className="h-3 w-3" />}
|
|
||||||
{comment.resolved ? '再開' : '解決'}
|
|
||||||
</Button>
|
|
||||||
{isOwn && !isStrikethrough && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 cursor-pointer gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={() => {
|
|
||||||
setEditContent(comment.content);
|
|
||||||
setEditPriority(comment.priority);
|
|
||||||
setEditing(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pencil className="h-3 w-3" />
|
|
||||||
編集
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{isOwn && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 cursor-pointer gap-1 px-2 text-xs text-muted-foreground hover:text-destructive"
|
|
||||||
onClick={() => store.deleteComment(comment.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
削除
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Replies */}
|
|
||||||
{replies.length > 0 && (
|
|
||||||
<div className="mt-2 space-y-2 border-t pt-2">
|
|
||||||
{replies.map((r) => (
|
|
||||||
<div key={r.id} className="flex gap-2">
|
|
||||||
<div className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-[9px] font-bold text-primary-foreground">
|
|
||||||
{r.author.charAt(0)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
<strong className="text-foreground/80">{r.author}</strong> · {formatTime(r.timestamp)}
|
|
||||||
</div>
|
|
||||||
<p className="text-[13px] text-muted-foreground leading-relaxed">{r.content}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Reply Input */}
|
|
||||||
{showReply && (
|
|
||||||
<div className="mt-2 space-y-2 border-t pt-2">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Textarea
|
|
||||||
value={replyText}
|
|
||||||
onChange={(e) => setReplyText(e.target.value)}
|
|
||||||
placeholder="返信を入力..."
|
|
||||||
className="min-h-[36px] flex-1 text-sm"
|
|
||||||
autoFocus
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
|
||||||
handleSubmitReply();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="h-9 shrink-0 self-end"
|
|
||||||
onClick={handleSubmitReply}
|
|
||||||
disabled={!replyText.trim()}
|
|
||||||
>
|
|
||||||
送信
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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">
|
|
||||||
“{selection.text.length > 120 ? selection.text.substring(0, 120) + '...' : selection.text}”
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Textarea
|
|
||||||
value={content}
|
|
||||||
onChange={(e) => setContent(e.target.value)}
|
|
||||||
placeholder="フィードバックを入力..."
|
|
||||||
className="mb-3 min-h-[80px] text-sm"
|
|
||||||
autoFocus
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') submit('must');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 flex-none border-muted-foreground/20 text-xs text-muted-foreground hover:bg-muted"
|
|
||||||
onClick={submitStrikethrough}
|
|
||||||
>
|
|
||||||
<Strikethrough className="mr-1 h-3 w-3" />
|
|
||||||
取り消す
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="h-9 flex-1 border border-red-500/30 bg-red-500/10 text-red-400 hover:bg-red-500/20"
|
|
||||||
onClick={() => submit('must')}
|
|
||||||
>
|
|
||||||
Must
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="h-9 flex-1 border border-amber-500/30 bg-amber-500/10 text-amber-400 hover:bg-amber-500/20"
|
|
||||||
onClick={() => submit('better')}
|
|
||||||
>
|
|
||||||
Better
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="h-9 flex-1 border border-green-500/30 bg-green-500/10 text-green-400 hover:bg-green-500/20"
|
|
||||||
onClick={() => submit('want')}
|
|
||||||
>
|
|
||||||
Want
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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 }
|
|
||||||
@ -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 }
|
|
||||||
@ -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,
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
}
|
|
||||||
@ -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 }
|
|
||||||
@ -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 }
|
|
||||||
@ -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 }
|
|
||||||
@ -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 }
|
|
||||||
@ -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 }
|
|
||||||
@ -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 }
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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
5
src/shared/api-client.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export function authHeaders(token: string): Record<string, string> {
|
||||||
|
const hdrs: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (token) hdrs['Authorization'] = 'Bearer ' + token;
|
||||||
|
return hdrs;
|
||||||
|
}
|
||||||
61
src/shared/constants.ts
Normal file
61
src/shared/constants.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import type { Priority } from './types';
|
||||||
|
|
||||||
|
export const PRIORITY_COLORS: Record<
|
||||||
|
Priority,
|
||||||
|
{ bg: string; text: string; light: string; border: string }
|
||||||
|
> = {
|
||||||
|
must: {
|
||||||
|
bg: '#ef4444',
|
||||||
|
text: '#fff',
|
||||||
|
light: 'rgba(239,68,68,0.1)',
|
||||||
|
border: 'rgba(239,68,68,0.3)',
|
||||||
|
},
|
||||||
|
better: {
|
||||||
|
bg: '#f59e0b',
|
||||||
|
text: '#fff',
|
||||||
|
light: 'rgba(245,158,11,0.1)',
|
||||||
|
border: 'rgba(245,158,11,0.3)',
|
||||||
|
},
|
||||||
|
want: {
|
||||||
|
bg: '#22c55e',
|
||||||
|
text: '#fff',
|
||||||
|
light: 'rgba(34,197,94,0.1)',
|
||||||
|
border: 'rgba(34,197,94,0.3)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PRIORITY_LABELS: Record<Priority, string> = {
|
||||||
|
must: 'Must',
|
||||||
|
better: 'Better',
|
||||||
|
want: 'Want',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PRIORITY_CYCLE: Record<Priority, Priority> = {
|
||||||
|
must: 'better',
|
||||||
|
better: 'want',
|
||||||
|
want: 'must',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HIGHLIGHT_COLORS: Record<
|
||||||
|
Priority,
|
||||||
|
{ bg: string; hoverBg: string; border: string }
|
||||||
|
> = {
|
||||||
|
must: {
|
||||||
|
bg: 'rgba(239,68,68,0.15)',
|
||||||
|
hoverBg: 'rgba(239,68,68,0.25)',
|
||||||
|
border: '#ef4444',
|
||||||
|
},
|
||||||
|
better: {
|
||||||
|
bg: 'rgba(245,158,11,0.15)',
|
||||||
|
hoverBg: 'rgba(245,158,11,0.25)',
|
||||||
|
border: '#f59e0b',
|
||||||
|
},
|
||||||
|
want: {
|
||||||
|
bg: 'rgba(34,197,94,0.15)',
|
||||||
|
hoverBg: 'rgba(34,197,94,0.25)',
|
||||||
|
border: '#22c55e',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const USERNAME_KEY = 'fb-username';
|
||||||
|
export const SIDEBAR_WIDTH_KEY = 'fb-sidebar-width';
|
||||||
10
src/shared/slug.ts
Normal file
10
src/shared/slug.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export function slugify(url: string): string {
|
||||||
|
return url
|
||||||
|
.replace(/^https?:\/\//, '')
|
||||||
|
.replace(/[^a-zA-Z0-9\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF]/g, '_')
|
||||||
|
.substring(0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateId(): string {
|
||||||
|
return Date.now().toString(36) + Math.random().toString(36).substring(2, 8);
|
||||||
|
}
|
||||||
12
src/shared/time.ts
Normal file
12
src/shared/time.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export function fmtTime(ts: number): string {
|
||||||
|
const d = Date.now() - ts;
|
||||||
|
if (d < 60000) return 'たった今';
|
||||||
|
if (d < 3600000) return Math.floor(d / 60000) + '分前';
|
||||||
|
if (d < 86400000) return Math.floor(d / 3600000) + '時間前';
|
||||||
|
return new Date(ts).toLocaleDateString('ja-JP', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
23
src/widget/api.ts
Normal file
23
src/widget/api.ts
Normal 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
24
src/widget/dom.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
type Attrs = Record<string, unknown>;
|
||||||
|
|
||||||
|
export function el(tag: string, attrs?: Attrs | null, children?: string | Node | (Node | null)[] | null): HTMLElement {
|
||||||
|
const e = document.createElement(tag);
|
||||||
|
if (attrs) Object.keys(attrs).forEach((k) => {
|
||||||
|
const v = attrs[k];
|
||||||
|
if (k === 'className') e.className = v as string;
|
||||||
|
else if (k === 'innerHTML') e.innerHTML = v as string;
|
||||||
|
else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.substring(2).toLowerCase(), v as EventListener);
|
||||||
|
else e.setAttribute(k, String(v));
|
||||||
|
});
|
||||||
|
if (children != null) {
|
||||||
|
if (typeof children === 'string') e.textContent = children;
|
||||||
|
else if (Array.isArray(children)) children.forEach((c) => { if (c) e.appendChild(c); });
|
||||||
|
else e.appendChild(children);
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function esc(s: string): string {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = s;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
110
src/widget/highlight.ts
Normal file
110
src/widget/highlight.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { state, type FbComment } from './state';
|
||||||
|
|
||||||
|
function collectTextNodes(): Text[] {
|
||||||
|
const nodes: Text[] = [];
|
||||||
|
const tw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
|
||||||
|
acceptNode(n) {
|
||||||
|
const p = n.parentElement;
|
||||||
|
if (!p) return NodeFilter.FILTER_REJECT;
|
||||||
|
if (p.tagName === 'SCRIPT' || p.tagName === 'STYLE') return NodeFilter.FILTER_REJECT;
|
||||||
|
if (p.closest('#fb-sidebar,#fb-toggle,#fb-popup,.fb-highlight')) return NodeFilter.FILTER_REJECT;
|
||||||
|
return NodeFilter.FILTER_ACCEPT;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let n: Node | null;
|
||||||
|
while ((n = tw.nextNode())) nodes.push(n as Text);
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapNormToOrig(orig: string, normStart: number, normEnd: number): [number, number] | null {
|
||||||
|
const normalized = orig.replace(/[\s\u00A0]+/g, ' ');
|
||||||
|
if (normStart >= normalized.length) return null;
|
||||||
|
let origIdx = 0, normIdx = 0;
|
||||||
|
let origStart = -1, origEnd = -1;
|
||||||
|
while (origIdx < orig.length) {
|
||||||
|
if (normIdx === normStart && origStart === -1) origStart = origIdx;
|
||||||
|
if (normIdx === normEnd) { origEnd = origIdx; break; }
|
||||||
|
if (normIdx < normalized.length && orig[origIdx] === normalized[normIdx]) {
|
||||||
|
origIdx++; normIdx++;
|
||||||
|
} else {
|
||||||
|
origIdx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (origEnd === -1 && normIdx >= normEnd) origEnd = origIdx;
|
||||||
|
if (origStart === -1 || origEnd === -1 || origStart >= origEnd) return null;
|
||||||
|
return [origStart, origEnd];
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapTextRange(node: Text, start: number, end: number, comment: FbComment, onClickHighlight: (id: string) => void): void {
|
||||||
|
const orig = node.textContent!;
|
||||||
|
const before = document.createTextNode(orig.substring(0, start));
|
||||||
|
const mark = document.createElement('mark');
|
||||||
|
mark.className = 'fb-highlight fb-highlight-' + comment.priority;
|
||||||
|
mark.dataset.commentId = comment.id;
|
||||||
|
mark.textContent = orig.substring(start, end);
|
||||||
|
mark.addEventListener('click', () => { onClickHighlight(comment.id); });
|
||||||
|
const after = document.createTextNode(orig.substring(end));
|
||||||
|
node.parentNode!.insertBefore(before, node);
|
||||||
|
node.parentNode!.insertBefore(mark, node);
|
||||||
|
node.parentNode!.insertBefore(after, node);
|
||||||
|
node.parentNode!.removeChild(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyHighlights(onClickHighlight: (id: string) => void): void {
|
||||||
|
document.querySelectorAll('.fb-highlight').forEach((el) => {
|
||||||
|
const t = document.createTextNode(el.textContent || '');
|
||||||
|
el.parentNode!.replaceChild(t, el);
|
||||||
|
});
|
||||||
|
document.body.normalize();
|
||||||
|
|
||||||
|
state.comments.filter((c) => !c.parentId && !c.resolved && c.quote && c.quote.length >= 2).forEach((c) => {
|
||||||
|
const search = c.quote.replace(/[\s\u00A0]+/g, ' ').trim();
|
||||||
|
const textNodes = collectTextNodes();
|
||||||
|
let found = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < textNodes.length; i++) {
|
||||||
|
const node = textNodes[i];
|
||||||
|
const orig = node.textContent!;
|
||||||
|
|
||||||
|
const di = orig.indexOf(search);
|
||||||
|
if (di !== -1) {
|
||||||
|
try { wrapTextRange(node, di, di + search.length, c, onClickHighlight); found = true; } catch (_) { /* ignore */ }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const norm = orig.replace(/[\s\u00A0]+/g, ' ');
|
||||||
|
const ni = norm.indexOf(search);
|
||||||
|
if (ni === -1) continue;
|
||||||
|
const range = mapNormToOrig(orig, ni, ni + search.length);
|
||||||
|
if (range) {
|
||||||
|
try { wrapTextRange(node, range[0], range[1], c, onClickHighlight); found = true; } catch (_) { /* ignore */ }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
let concat = '';
|
||||||
|
const nodeMap: Array<{ node: Text; start: number; end: number }> = [];
|
||||||
|
for (let j = 0; j < textNodes.length; j++) {
|
||||||
|
const s = concat.length;
|
||||||
|
concat += textNodes[j].textContent;
|
||||||
|
nodeMap.push({ node: textNodes[j], start: s, end: concat.length });
|
||||||
|
}
|
||||||
|
const concatNorm = concat.replace(/[\s\u00A0]+/g, ' ');
|
||||||
|
const ci = concatNorm.indexOf(search);
|
||||||
|
if (ci !== -1) {
|
||||||
|
const range = mapNormToOrig(concat, ci, ci + search.length);
|
||||||
|
if (range) {
|
||||||
|
const mStart = range[0], mEnd = range[1];
|
||||||
|
for (let k = nodeMap.length - 1; k >= 0; k--) {
|
||||||
|
const nm = nodeMap[k];
|
||||||
|
if (nm.end <= mStart || nm.start >= mEnd) continue;
|
||||||
|
const ls = Math.max(0, mStart - nm.start);
|
||||||
|
const le = Math.min(nm.node.textContent!.length, mEnd - nm.start);
|
||||||
|
try { wrapTextRange(nm.node, ls, le, c, onClickHighlight); } catch (_) { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
14
src/widget/icons.ts
Normal file
14
src/widget/icons.ts
Normal 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
158
src/widget/index.ts
Normal 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
79
src/widget/render/card.ts
Normal 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> · ' + fmtTime(r.timestamp) + '</div>';
|
||||||
|
if (state.editingId === r.id) {
|
||||||
|
h += '<div class="fb-edit-area"><textarea data-action="edit-textarea">' + esc(state.editContent) + '</textarea><div class="fb-edit-btns"><button data-action="cancel-edit">キャンセル</button><button class="save" data-action="save-edit" data-id="' + r.id + '">保存</button></div></div>';
|
||||||
|
} else {
|
||||||
|
h += '<div class="fb-reply-text">' + esc(r.content) + '</div>';
|
||||||
|
h += '<div class="fb-actions" style="margin-top:4px">';
|
||||||
|
h += '<button class="fb-act" data-action="reply" data-id="' + c.id + '">' + icon('message', 12) + '返信</button>';
|
||||||
|
if (isOwnReply) h += '<button class="fb-act" data-action="edit" data-id="' + r.id + '">' + icon('pencil', 12) + '編集</button>';
|
||||||
|
if (isOwnReply) h += '<button class="fb-act del" data-action="delete-reply" data-id="' + r.id + '">' + icon('trash', 12) + '削除</button>';
|
||||||
|
h += '</div>';
|
||||||
|
}
|
||||||
|
h += '</div></div>';
|
||||||
|
});
|
||||||
|
h += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.replyingTo === c.id) {
|
||||||
|
h += '<div class="fb-reply-input"><textarea placeholder="返信を入力..." data-action="reply-textarea">' + esc(state.replyText) + '</textarea><button data-action="submit-reply" data-id="' + c.id + '"' + (state.replyText.trim() ? '' : ' disabled') + '>送信</button></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
h += '</div>';
|
||||||
|
return h;
|
||||||
|
}
|
||||||
22
src/widget/render/index.ts
Normal file
22
src/widget/render/index.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import type { Priority } from '../../shared/types';
|
||||||
|
import { renderToggle } from './toggle';
|
||||||
|
import { renderSidebar, toggleSidebar } from './sidebar';
|
||||||
|
import { renderPopup } from './popup';
|
||||||
|
import { renderNameDialog } from './name-dialog';
|
||||||
|
|
||||||
|
let _closePopup: (() => void) | null = null;
|
||||||
|
let _submitComment: ((priority: Priority) => void) | null = null;
|
||||||
|
|
||||||
|
export function setRenderDeps(closePopup: () => void, submitComment: (priority: Priority) => void): void {
|
||||||
|
_closePopup = closePopup;
|
||||||
|
_submitComment = submitComment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function render(): void {
|
||||||
|
renderToggle(toggleSidebar);
|
||||||
|
renderSidebar(render);
|
||||||
|
renderPopup(render, _closePopup!, _submitComment!);
|
||||||
|
renderNameDialog(render);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { toggleSidebar };
|
||||||
26
src/widget/render/name-dialog.ts
Normal file
26
src/widget/render/name-dialog.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { USERNAME_KEY } from '../../shared/constants';
|
||||||
|
import { el } from '../dom';
|
||||||
|
import { state } from '../state';
|
||||||
|
|
||||||
|
export function renderNameDialog(onRender: () => void): void {
|
||||||
|
const existing = document.getElementById('fb-name-overlay');
|
||||||
|
if (state.username) { if (existing) existing.remove(); return; }
|
||||||
|
if (existing) return;
|
||||||
|
const overlay = el('div', { id: 'fb-name-overlay', className: 'fb-name-overlay' });
|
||||||
|
overlay.innerHTML = '<div class="fb-name-box"><h2>ようこそ</h2><p>コメントに表示される名前を入力してください。</p><input placeholder="例: 田中太郎" id="fb-name-field"><button id="fb-name-submit" disabled>始める</button></div>';
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
const input = document.getElementById('fb-name-field') as HTMLInputElement;
|
||||||
|
const btn = document.getElementById('fb-name-submit') as HTMLButtonElement;
|
||||||
|
input.addEventListener('input', () => { btn.disabled = !input.value.trim(); });
|
||||||
|
input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && input.value.trim()) { setName(input.value.trim(), onRender); } });
|
||||||
|
btn.addEventListener('click', () => { if (input.value.trim()) setName(input.value.trim(), onRender); });
|
||||||
|
setTimeout(() => { input.focus(); }, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setName(name: string, onRender: () => void): void {
|
||||||
|
state.username = name;
|
||||||
|
localStorage.setItem(USERNAME_KEY, name);
|
||||||
|
const overlay = document.getElementById('fb-name-overlay');
|
||||||
|
if (overlay) overlay.remove();
|
||||||
|
onRender();
|
||||||
|
}
|
||||||
61
src/widget/render/popup.ts
Normal file
61
src/widget/render/popup.ts
Normal 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';
|
||||||
|
}
|
||||||
197
src/widget/render/sidebar.ts
Normal file
197
src/widget/render/sidebar.ts
Normal 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);
|
||||||
|
}
|
||||||
18
src/widget/render/toggle.ts
Normal file
18
src/widget/render/toggle.ts
Normal 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
32
src/widget/scroll.ts
Normal 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
35
src/widget/selection.ts
Normal 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
68
src/widget/state.ts
Normal 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
136
src/widget/styles.ts
Normal 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
129
tests/shared.unit.test.ts
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { slugify, generateId } from '../src/shared/slug';
|
||||||
|
import { fmtTime } from '../src/shared/time';
|
||||||
|
import { PRIORITY_CYCLE, PRIORITY_COLORS, HIGHLIGHT_COLORS } from '../src/shared/constants';
|
||||||
|
import { authHeaders } from '../src/shared/api-client';
|
||||||
|
import { mapNormToOrig } from '../src/widget/highlight';
|
||||||
|
|
||||||
|
describe('slugify', () => {
|
||||||
|
test('HTTP プレフィックスを除去する', () => {
|
||||||
|
expect(slugify('https://example.com/page')).toBe('example_com_page');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('日本語文字を保持する', () => {
|
||||||
|
const result = slugify('https://example.com/テスト');
|
||||||
|
expect(result).toContain('テスト');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('100文字で切り詰める', () => {
|
||||||
|
const long = 'https://example.com/' + 'a'.repeat(200);
|
||||||
|
expect(slugify(long).length).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('記号をアンダースコアに変換する', () => {
|
||||||
|
expect(slugify('https://a.b/c?d=e&f=g')).toBe('a_b_c_d_e_f_g');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateId', () => {
|
||||||
|
test('文字列を返す', () => {
|
||||||
|
expect(typeof generateId()).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('一意性がある(100回生成してユニーク)', () => {
|
||||||
|
const ids = new Set(Array.from({ length: 100 }, () => generateId()));
|
||||||
|
expect(ids.size).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fmtTime', () => {
|
||||||
|
let realNow: () => number;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
realNow = Date.now;
|
||||||
|
vi.spyOn(Date, 'now').mockReturnValue(1700000000000);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('1分未満は「たった今」', () => {
|
||||||
|
expect(fmtTime(1700000000000 - 30000)).toBe('たった今');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('1時間未満は「N分前」', () => {
|
||||||
|
expect(fmtTime(1700000000000 - 300000)).toBe('5分前');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('24時間未満は「N時間前」', () => {
|
||||||
|
expect(fmtTime(1700000000000 - 7200000)).toBe('2時間前');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('24時間以上は日時文字列', () => {
|
||||||
|
const result = fmtTime(1700000000000 - 86400000 * 2);
|
||||||
|
expect(result).toMatch(/\d+月/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PRIORITY_CYCLE', () => {
|
||||||
|
test('must → better → want → must の循環', () => {
|
||||||
|
expect(PRIORITY_CYCLE.must).toBe('better');
|
||||||
|
expect(PRIORITY_CYCLE.better).toBe('want');
|
||||||
|
expect(PRIORITY_CYCLE.want).toBe('must');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('3つの優先度が全て定義されている', () => {
|
||||||
|
expect(Object.keys(PRIORITY_CYCLE)).toEqual(['must', 'better', 'want']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PRIORITY_COLORS', () => {
|
||||||
|
test('3つの優先度が全て定義されている', () => {
|
||||||
|
expect(Object.keys(PRIORITY_COLORS)).toEqual(['must', 'better', 'want']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('各色に bg, text, light, border がある', () => {
|
||||||
|
for (const p of ['must', 'better', 'want'] as const) {
|
||||||
|
expect(PRIORITY_COLORS[p]).toHaveProperty('bg');
|
||||||
|
expect(PRIORITY_COLORS[p]).toHaveProperty('text');
|
||||||
|
expect(PRIORITY_COLORS[p]).toHaveProperty('light');
|
||||||
|
expect(PRIORITY_COLORS[p]).toHaveProperty('border');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HIGHLIGHT_COLORS', () => {
|
||||||
|
test('3つの優先度が全て定義されている', () => {
|
||||||
|
expect(Object.keys(HIGHLIGHT_COLORS)).toEqual(['must', 'better', 'want']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('authHeaders', () => {
|
||||||
|
test('トークンありの場合 Authorization を含む', () => {
|
||||||
|
const h = authHeaders('my-token');
|
||||||
|
expect(h['Authorization']).toBe('Bearer my-token');
|
||||||
|
expect(h['Content-Type']).toBe('application/json');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('トークンなしの場合 Authorization を含まない', () => {
|
||||||
|
const h = authHeaders('');
|
||||||
|
expect(h['Authorization']).toBeUndefined();
|
||||||
|
expect(h['Content-Type']).toBe('application/json');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapNormToOrig', () => {
|
||||||
|
test('空白なしの単純マッチ', () => {
|
||||||
|
expect(mapNormToOrig('hello world', 0, 5)).toEqual([0, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('余分な空白がある場合の正規化マッチ', () => {
|
||||||
|
// 正規化後のインデックス 6-11 は orig で 6-12 に対応(余分な空白を含む)
|
||||||
|
expect(mapNormToOrig('hello world', 6, 11)).toEqual([6, 12]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('範囲外の normStart は null を返す', () => {
|
||||||
|
expect(mapNormToOrig('short', 100, 105)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
168
tests/widget.integration.test.ts
Normal file
168
tests/widget.integration.test.ts
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
import { describe, test, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
const WIDGET_JS = readFileSync(
|
||||||
|
resolve(__dirname, '../public/widget.js'),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
|
||||||
|
function loadWidget() {
|
||||||
|
document.head.innerHTML = '';
|
||||||
|
document.body.innerHTML = '<p>テストテキスト</p>';
|
||||||
|
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = 'https://my-app.vercel.app/widget.js';
|
||||||
|
script.dataset.token = 'test-token-abc';
|
||||||
|
Object.defineProperty(document, 'currentScript', {
|
||||||
|
value: script,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
window.localStorage.clear();
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
json: () => Promise.resolve([]),
|
||||||
|
ok: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-eval
|
||||||
|
eval(WIDGET_JS);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('widget.js 初期化', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
loadWidget();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('トグルボタンが DOM に追加される', () => {
|
||||||
|
const toggle = document.getElementById('fb-toggle');
|
||||||
|
expect(toggle).toBeTruthy();
|
||||||
|
expect(toggle!.tagName).toBe('BUTTON');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('サイドバーが DOM に追加される', () => {
|
||||||
|
const sidebar = document.getElementById('fb-sidebar');
|
||||||
|
expect(sidebar).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('スタイルが注入される', () => {
|
||||||
|
const style = document.getElementById('fb-widget-styles');
|
||||||
|
expect(style).toBeTruthy();
|
||||||
|
expect(style!.tagName).toBe('STYLE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CSS 変数が定義されている', () => {
|
||||||
|
const style = document.getElementById('fb-widget-styles');
|
||||||
|
const css = style!.textContent || '';
|
||||||
|
expect(css).toContain('--fb-bg');
|
||||||
|
expect(css).toContain('--fb-fg');
|
||||||
|
expect(css).toContain('--fb-accent');
|
||||||
|
expect(css).toContain('--fb-border');
|
||||||
|
expect(css).toContain('--fb-muted');
|
||||||
|
expect(css).toContain('--fb-primary');
|
||||||
|
expect(css).toContain('--fb-destructive');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('トグルボタンに「コメント」ラベルがある', () => {
|
||||||
|
const toggle = document.getElementById('fb-toggle');
|
||||||
|
expect(toggle!.innerHTML).toContain('コメント');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('サイドバーにフィルタボタンがある', () => {
|
||||||
|
const sidebar = document.getElementById('fb-sidebar');
|
||||||
|
const html = sidebar!.innerHTML;
|
||||||
|
expect(html).toContain('未解決');
|
||||||
|
expect(html).toContain('解決済');
|
||||||
|
expect(html).toContain('すべて');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('サイドバーにリサイズハンドルがある', () => {
|
||||||
|
const sidebar = document.getElementById('fb-sidebar');
|
||||||
|
const handle = sidebar!.querySelector('.fb-resize-handle');
|
||||||
|
expect(handle).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('サイドバーは初期状態で非表示(右にオフセット)', () => {
|
||||||
|
const sidebar = document.getElementById('fb-sidebar') as HTMLElement;
|
||||||
|
const right = parseInt(sidebar.style.right, 10);
|
||||||
|
expect(right).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('名前未入力時に名前入力ダイアログが表示される', () => {
|
||||||
|
const overlay = document.getElementById('fb-name-overlay');
|
||||||
|
expect(overlay).toBeTruthy();
|
||||||
|
expect(overlay!.innerHTML).toContain('ようこそ');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('widget.js ハイライト CSS', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
loadWidget();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ハイライトスタイルが優先度別に3色定義されている', () => {
|
||||||
|
const style = document.getElementById('fb-widget-styles');
|
||||||
|
const css = style!.textContent || '';
|
||||||
|
expect(css).toContain('.fb-highlight-must');
|
||||||
|
expect(css).toContain('.fb-highlight-better');
|
||||||
|
expect(css).toContain('.fb-highlight-want');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('カードスタイルが定義されている', () => {
|
||||||
|
const style = document.getElementById('fb-widget-styles');
|
||||||
|
const css = style!.textContent || '';
|
||||||
|
expect(css).toContain('.fb-card');
|
||||||
|
expect(css).toContain('.fb-card-head');
|
||||||
|
expect(css).toContain('.fb-badge-p');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ポップアップスタイルが定義されている', () => {
|
||||||
|
const style = document.getElementById('fb-widget-styles');
|
||||||
|
const css = style!.textContent || '';
|
||||||
|
expect(css).toContain('.fb-popup');
|
||||||
|
expect(css).toContain('.fb-popup-pri');
|
||||||
|
expect(css).toContain('.fb-popup-actions');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('widget.js コメントなし状態', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
loadWidget();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('コメントゼロ時にサイドバーに空状態メッセージが表示される', () => {
|
||||||
|
const sidebar = document.getElementById('fb-sidebar');
|
||||||
|
const html = sidebar!.innerHTML;
|
||||||
|
expect(html).toContain('コメントはまだありません');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('コメントゼロ時にバッジが表示されない', () => {
|
||||||
|
const toggle = document.getElementById('fb-toggle');
|
||||||
|
expect(toggle!.querySelector('.fb-badge')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('widget.js SVG アイコン', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
loadWidget();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('トグルボタンに SVG アイコンが含まれる', () => {
|
||||||
|
const toggle = document.getElementById('fb-toggle');
|
||||||
|
expect(toggle!.innerHTML).toContain('<svg');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('サイドバーの閉じるボタンに SVG アイコンが含まれる', () => {
|
||||||
|
const sidebar = document.getElementById('fb-sidebar');
|
||||||
|
const closeBtn = sidebar!.querySelector('[data-action="close"]');
|
||||||
|
expect(closeBtn).toBeTruthy();
|
||||||
|
expect(closeBtn!.innerHTML).toContain('<svg');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -30,5 +30,5 @@
|
|||||||
".next/dev/types/**/*.ts",
|
".next/dev/types/**/*.ts",
|
||||||
"**/*.mts"
|
"**/*.mts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules", "src/widget", "tests"]
|
||||||
}
|
}
|
||||||
|
|||||||
8
vitest.config.ts
Normal file
8
vitest.config.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
include: ['tests/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user