first commit
Made-with: Cursor
This commit is contained in:
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,969 @@
|
||||
<!-- このファイルの<head>はreferences/base.htmlと同一に保つ。base.html変更時は必ずこちらも更新する -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
|
||||
<meta name="googlebot" content="noindex, nofollow">
|
||||
<meta property="og:title" content="APIの仕組み">
|
||||
<meta property="og:description" content="APIの仕組みを、身近な例とビジュアルでわかりやすく図解します。">
|
||||
<meta property="og:type" content="article">
|
||||
<title>APIの仕組み</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='20' fill='%23FFFFFF'/><rect x='8' y='8' width='84' height='84' rx='14' fill='none' stroke='%233B82F6' stroke-width='4'/><circle cx='32' cy='40' r='10' fill='%233B82F6' opacity='0.8'/><circle cx='68' cy='40' r='10' fill='%2360A5FA' opacity='0.8'/><rect x='25' y='62' width='50' height='6' rx='3' fill='%2394A3B8' opacity='0.6'/><rect x='30' y='74' width='40' height='6' rx='3' fill='%2394A3B8' opacity='0.3'/></svg>">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;500;700;900&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
ads: {
|
||||
bg: '#FFFFFF',
|
||||
surface: '#F8FAFC',
|
||||
hover: '#F1F5F9',
|
||||
border: '#E2E8F0',
|
||||
accent: '#3B82F6',
|
||||
'accent-light': '#2563EB',
|
||||
text: '#1E293B',
|
||||
muted: '#64748B',
|
||||
dim: '#94A3B8',
|
||||
positive: '#10B981',
|
||||
negative: '#EF4444',
|
||||
warning: '#F59E0B',
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['"Noto Sans JP"', '"Hiragino Sans"', '"Hiragino Kaku Gothic ProN"', '"Yu Gothic UI"', '"Meiryo"', 'sans-serif'],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
body { border-top: none !important; }
|
||||
.rounded-xl { break-inside: avoid; }
|
||||
.md\:flex-row { flex-direction: row !important; }
|
||||
.md\:hidden { display: none !important; }
|
||||
.hidden.md\:block { display: block !important; }
|
||||
.md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }
|
||||
.sm\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)) !important; }
|
||||
.md\:mb-20 { margin-bottom: 5rem !important; }
|
||||
.md\:py-16 { padding-top: 4rem !important; padding-bottom: 4rem !important; }
|
||||
.bg-clip-text.text-transparent {
|
||||
-webkit-background-clip: initial !important;
|
||||
background-clip: initial !important;
|
||||
color: #2563EB !important;
|
||||
-webkit-text-fill-color: #2563EB !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-ads-bg text-slate-600 antialiased leading-relaxed border-t-4 border-ads-accent">
|
||||
<div class="no-print max-w-3xl mx-auto px-5 pt-2 flex justify-end">
|
||||
<button onclick="window.print()" class="flex items-center gap-1.5 text-xs text-ads-dim hover:text-ads-accent transition-colors cursor-pointer">
|
||||
<i data-lucide="download" class="w-3.5 h-3.5"></i>
|
||||
PDF
|
||||
</button>
|
||||
</div>
|
||||
<main class="max-w-3xl mx-auto px-5 py-10 md:py-16">
|
||||
<!-- CONTENT_START -->
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- HERO -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="text-center mb-8 md:mb-10">
|
||||
<div class="inline-flex items-center gap-2 bg-ads-accent/10 text-ads-accent-light px-4 py-1.5 rounded-full text-sm font-medium mb-6">
|
||||
<i data-lucide="cpu" class="w-4 h-4"></i>
|
||||
テクノロジー
|
||||
</div>
|
||||
<h1 class="text-3xl md:text-5xl font-black text-slate-900 tracking-tight mb-6">
|
||||
<span class="bg-gradient-to-r from-blue-600 to-cyan-600 bg-clip-text text-transparent">API</span>の仕組み
|
||||
</h1>
|
||||
<p class="text-lg text-ads-muted max-w-xl mx-auto leading-relaxed">
|
||||
「APIって何?」と聞かれて、うまく答えられない。<br>
|
||||
ひとことで言うと ──
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- ONE-PAGE SUMMARY -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="bg-ads-surface border border-ads-border rounded-2xl p-6 md:p-10 mb-6">
|
||||
<div class="text-center mb-8 md:mb-10">
|
||||
<p class="text-xl md:text-2xl font-black text-slate-900 mb-2">
|
||||
API = ソフトウェアの<span class="text-ads-accent-light">「注文窓口」</span>
|
||||
</p>
|
||||
<p class="text-sm text-ads-muted">
|
||||
中身を知らなくても、決まった形で頼めば結果が届く
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Core flow: App → API → Server -->
|
||||
<div class="flex flex-col md:flex-row items-center justify-center gap-2 md:gap-0 mb-8">
|
||||
<!-- App -->
|
||||
<div class="flex flex-col items-center w-36 p-3">
|
||||
<div class="w-14 h-14 rounded-xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-2.5">
|
||||
<i data-lucide="smartphone" class="w-7 h-7 text-blue-600"></i>
|
||||
</div>
|
||||
<div class="font-bold text-slate-900 text-sm">あなたのアプリ</div>
|
||||
<div class="text-[11px] text-blue-600/70 mt-0.5">「天気を教えて」</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow 1: Request -->
|
||||
<div class="flex items-center justify-center md:w-16 py-1 md:py-0">
|
||||
<div class="flex flex-col items-center gap-0.5">
|
||||
<span class="text-[9px] font-medium text-ads-accent tracking-wider">リクエスト</span>
|
||||
<i data-lucide="arrow-right" class="w-5 h-5 text-ads-accent hidden md:block"></i>
|
||||
<i data-lucide="arrow-down" class="w-5 h-5 text-ads-accent md:hidden"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API (centerpiece — visually prominent) -->
|
||||
<div class="flex flex-col items-center w-40 p-4 bg-ads-accent/5 border-2 border-ads-accent/20 rounded-2xl">
|
||||
<div class="w-16 h-16 rounded-2xl bg-ads-accent/15 border-2 border-ads-accent/30 flex items-center justify-center mb-2.5">
|
||||
<i data-lucide="arrow-left-right" class="w-8 h-8 text-ads-accent-light"></i>
|
||||
</div>
|
||||
<div class="font-bold text-ads-accent-light text-base">API</div>
|
||||
<div class="text-[11px] text-ads-muted mt-0.5 mb-2">注文を届け、結果を返す</div>
|
||||
<div class="text-[10px] text-ads-muted bg-white rounded-lg px-2.5 py-1 border border-ads-border/50">レストランのウェイター役</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow 2: Forward to server -->
|
||||
<div class="flex items-center justify-center md:w-16 py-1 md:py-0">
|
||||
<div class="flex flex-col items-center gap-0.5">
|
||||
<span class="text-[9px] font-medium text-ads-accent tracking-wider">依頼</span>
|
||||
<i data-lucide="arrow-right" class="w-5 h-5 text-ads-accent hidden md:block"></i>
|
||||
<i data-lucide="arrow-down" class="w-5 h-5 text-ads-accent md:hidden"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server -->
|
||||
<div class="flex flex-col items-center w-36 p-3">
|
||||
<div class="w-14 h-14 rounded-xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center mb-2.5">
|
||||
<i data-lucide="server" class="w-7 h-7 text-emerald-600"></i>
|
||||
</div>
|
||||
<div class="font-bold text-slate-900 text-sm">サービス</div>
|
||||
<div class="text-[11px] text-emerald-600/70 mt-0.5">処理して結果を返す</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Return flow -->
|
||||
<div class="flex justify-center mb-8">
|
||||
<div class="flex items-center gap-2 text-xs text-emerald-600 bg-emerald-500/5 border border-emerald-500/15 rounded-full px-4 py-1.5">
|
||||
<i data-lucide="corner-down-left" class="w-3.5 h-3.5"></i>
|
||||
結果(レスポンス)があなたのアプリに届く
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider with label -->
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<div class="flex-1 border-t border-ads-border/50"></div>
|
||||
<div class="text-[11px] text-ads-dim font-medium">あなたも毎日使っている</div>
|
||||
<div class="flex-1 border-t border-ads-border/50"></div>
|
||||
</div>
|
||||
|
||||
<!-- Everyday examples as pills -->
|
||||
<div class="flex flex-wrap justify-center gap-2 mb-8">
|
||||
<div class="flex items-center gap-1.5 bg-white border border-ads-border rounded-full px-3.5 py-1.5 text-xs text-slate-700">
|
||||
<i data-lucide="cloud" class="w-3.5 h-3.5 text-cyan-500"></i>
|
||||
天気予報
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 bg-white border border-ads-border rounded-full px-3.5 py-1.5 text-xs text-slate-700">
|
||||
<i data-lucide="log-in" class="w-3.5 h-3.5 text-blue-500"></i>
|
||||
Googleログイン
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 bg-white border border-ads-border rounded-full px-3.5 py-1.5 text-xs text-slate-700">
|
||||
<i data-lucide="credit-card" class="w-3.5 h-3.5 text-emerald-500"></i>
|
||||
オンライン決済
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 bg-white border border-ads-border rounded-full px-3.5 py-1.5 text-xs text-slate-700">
|
||||
<i data-lucide="map-pin" class="w-3.5 h-3.5 text-red-500"></i>
|
||||
地図・ナビ
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<p class="text-center text-ads-muted mb-16 md:mb-20">
|
||||
ここから先で、この仕組みをひとつずつ丁寧に解説していきます。
|
||||
</p>
|
||||
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SECTION 1: そもそもAPIって何? -->
|
||||
<!-- ============================================================ -->
|
||||
<section class="mb-16 md:mb-20">
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-blue-500/10 flex-shrink-0">
|
||||
<i data-lucide="help-circle" class="w-5 h-5 text-blue-600"></i>
|
||||
</div>
|
||||
<h2 class="text-xl md:text-2xl font-bold text-slate-900">そもそもAPIって何?</h2>
|
||||
</div>
|
||||
|
||||
<p class="mb-6 leading-relaxed">
|
||||
API(エーピーアイ)は <strong class="text-slate-900">Application Programming Interface</strong>(アプリケーション・プログラミング・インターフェース)の略称です。正式名称を聞いても「何のこと?」と思いますよね。
|
||||
</p>
|
||||
|
||||
<p class="mb-6 leading-relaxed">
|
||||
まずは日常のたとえで考えてみましょう。レストランに行った場面を想像してください。
|
||||
</p>
|
||||
|
||||
<p class="mb-8 leading-relaxed">
|
||||
あなた(お客さん)は、厨房に直接入って料理を作ることはできません。厨房のルールも、調理器具の使い方も知りません。でも、<strong class="text-slate-900">ウェイターに「パスタをください」と注文すれば、厨房で作られた料理があなたのテーブルに届きます。</strong>厨房の中で何が起きているかを知る必要はありません。
|
||||
</p>
|
||||
|
||||
<!-- レストラン比喩フロー図 -->
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl p-6 md:p-8 mb-8">
|
||||
<h3 class="text-lg font-bold text-slate-900 text-center mb-8">レストランで考えるAPIの役割</h3>
|
||||
|
||||
<div class="flex flex-col md:flex-row items-center justify-center gap-4 md:gap-0">
|
||||
<!-- あなた(お客さん) -->
|
||||
<div class="w-44 bg-blue-500/10 border border-blue-500/20 rounded-xl p-5 text-center">
|
||||
<div class="w-12 h-12 rounded-full bg-blue-500/20 flex items-center justify-center mx-auto mb-3">
|
||||
<i data-lucide="user" class="w-6 h-6 text-blue-600"></i>
|
||||
</div>
|
||||
<div class="font-bold text-blue-700 mb-1">あなた</div>
|
||||
<div class="text-xs text-blue-600/70">お客さん</div>
|
||||
<div class="text-xs text-ads-muted mt-3 bg-slate-100/80 rounded-lg px-3 py-1.5">「パスタください」</div>
|
||||
</div>
|
||||
|
||||
<!-- 矢印 1 -->
|
||||
<div class="flex items-center justify-center md:w-16 py-2 md:py-0">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="text-[10px] text-ads-muted">注文</span>
|
||||
<i data-lucide="arrow-right" class="w-5 h-5 text-ads-accent hidden md:block"></i>
|
||||
<i data-lucide="arrow-down" class="w-5 h-5 text-ads-accent md:hidden"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API(ウェイター) -->
|
||||
<div class="w-44 bg-ads-accent/10 border-2 border-ads-accent/30 rounded-xl p-5 text-center">
|
||||
<div class="w-12 h-12 rounded-full bg-ads-accent/20 flex items-center justify-center mx-auto mb-3">
|
||||
<i data-lucide="message-square" class="w-6 h-6 text-ads-accent-light"></i>
|
||||
</div>
|
||||
<div class="font-bold text-ads-accent-light mb-1">API</div>
|
||||
<div class="text-xs text-ads-accent/70">ウェイター</div>
|
||||
<div class="text-xs text-ads-muted mt-3 bg-slate-100/80 rounded-lg px-3 py-1.5">注文を伝え、料理を届ける</div>
|
||||
</div>
|
||||
|
||||
<!-- 矢印 2 -->
|
||||
<div class="flex items-center justify-center md:w-16 py-2 md:py-0">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="text-[10px] text-ads-muted">依頼</span>
|
||||
<i data-lucide="arrow-right" class="w-5 h-5 text-ads-accent hidden md:block"></i>
|
||||
<i data-lucide="arrow-down" class="w-5 h-5 text-ads-accent md:hidden"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- サーバー(厨房) -->
|
||||
<div class="w-44 bg-emerald-500/10 border border-emerald-500/20 rounded-xl p-5 text-center">
|
||||
<div class="w-12 h-12 rounded-full bg-emerald-500/20 flex items-center justify-center mx-auto mb-3">
|
||||
<i data-lucide="server" class="w-6 h-6 text-emerald-600"></i>
|
||||
</div>
|
||||
<div class="font-bold text-emerald-700 mb-1">サーバー</div>
|
||||
<div class="text-xs text-emerald-600/70">厨房</div>
|
||||
<div class="text-xs text-ads-muted mt-3 bg-slate-100/80 rounded-lg px-3 py-1.5">パスタを作って渡す</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mt-6">
|
||||
<div class="flex items-center gap-2 text-ads-muted text-sm">
|
||||
<i data-lucide="corner-down-left" class="w-4 h-4"></i>
|
||||
料理(レスポンス)があなたのテーブルに届く
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mb-6 leading-relaxed">
|
||||
この比喩がAPIの本質をほぼ言い当てています。あなた(アプリ)は、厨房(サーバー)の中で何が起きているかを知る必要がありません。ウェイター(API)に決まった形式で注文を伝えれば、結果が返ってくる。これがAPIです。
|
||||
</p>
|
||||
|
||||
<!-- ポイントボックス -->
|
||||
<div class="bg-ads-accent/5 border border-ads-accent/20 rounded-xl p-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-lg bg-ads-accent/10 flex-shrink-0 mt-0.5">
|
||||
<i data-lucide="lightbulb" class="w-4 h-4 text-ads-accent-light"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-ads-accent-light mb-1">ここがポイント</p>
|
||||
<p class="text-ads-muted leading-relaxed">
|
||||
APIは<strong class="text-slate-800">「仲介役」</strong>です。相手の内部構造を知らなくても、<strong class="text-slate-800">決まったルールで話しかければ結果が返ってくる</strong>。これがAPIの本質です。この「決まったルール」のことを、エンジニアは「インターフェース(Interface)」と呼びます。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SECTION 2: もう少し正確に言うと -->
|
||||
<!-- ============================================================ -->
|
||||
<section class="mb-16 md:mb-20">
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-purple-500/10 flex-shrink-0">
|
||||
<i data-lucide="search" class="w-5 h-5 text-purple-600"></i>
|
||||
</div>
|
||||
<h2 class="text-xl md:text-2xl font-bold text-slate-900">もう少し正確に言うと</h2>
|
||||
</div>
|
||||
|
||||
<p class="mb-6 leading-relaxed">
|
||||
レストランのたとえで、ざっくりとしたイメージはつかめましたか? ここからもう少しだけ正確に説明します。
|
||||
</p>
|
||||
|
||||
<p class="mb-8 leading-relaxed">
|
||||
APIとは、ひとことで言えば<strong class="text-slate-900">「ソフトウェア同士が会話するための窓口」</strong>です。あなたが使っているアプリの裏側で、別のサービスのデータや機能を借りてくるための「取り決め」と考えてください。
|
||||
</p>
|
||||
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl p-6 mb-8 text-center">
|
||||
<div class="text-xs text-ads-dim font-medium tracking-widest uppercase mb-3">技術的な定義</div>
|
||||
<p class="text-lg md:text-xl font-bold text-slate-900 leading-relaxed">
|
||||
API = あるソフトウェアの機能を、<br class="hidden md:block">
|
||||
別のソフトウェアから使えるようにする仕組み
|
||||
</p>
|
||||
<p class="text-xs text-ads-dim mt-3">出典: <a href="https://developer.mozilla.org/ja/docs/Learn/JavaScript/Client-side_web_APIs/Introduction" class="underline decoration-ads-dim/30 hover:text-ads-accent transition-colors">MDN Web Docs — Web API の紹介</a></p>
|
||||
</div>
|
||||
|
||||
<p class="mb-8 leading-relaxed">
|
||||
これだけだとまだ抽象的に感じるかもしれません。では、<strong class="text-slate-900">APIがある世界とない世界</strong>を比べてみましょう。
|
||||
</p>
|
||||
|
||||
<!-- Before / After 比較 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Before -->
|
||||
<div class="bg-red-500/5 border border-red-500/20 rounded-xl p-6">
|
||||
<div class="inline-flex items-center gap-1.5 bg-red-500/10 text-red-600 px-3 py-1 rounded-full text-xs font-bold tracking-wide mb-5">
|
||||
<i data-lucide="x-circle" class="w-3.5 h-3.5"></i>
|
||||
BEFORE — APIがない世界
|
||||
</div>
|
||||
<ul class="space-y-3">
|
||||
<li class="flex items-start gap-2.5">
|
||||
<i data-lucide="x" class="w-4 h-4 text-red-600 mt-1 flex-shrink-0"></i>
|
||||
<span>天気情報が欲しければ、<strong class="text-red-700">自分で気象観測の仕組み</strong>を構築する</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2.5">
|
||||
<i data-lucide="x" class="w-4 h-4 text-red-600 mt-1 flex-shrink-0"></i>
|
||||
<span>決済機能が欲しければ、<strong class="text-red-700">クレジットカード処理を自前で開発</strong>する</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2.5">
|
||||
<i data-lucide="x" class="w-4 h-4 text-red-600 mt-1 flex-shrink-0"></i>
|
||||
<span>地図を表示したければ、<strong class="text-red-700">地図データを自分で作成・更新</strong>する</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2.5">
|
||||
<i data-lucide="x" class="w-4 h-4 text-red-600 mt-1 flex-shrink-0"></i>
|
||||
<span>ユーザー認証は<strong class="text-red-700">パスワード管理からセキュリティ対策まで全部自前</strong></span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="mt-5 pt-4 border-t border-red-500/10 text-sm text-red-700/70 flex items-center gap-2">
|
||||
<i data-lucide="alert-circle" class="w-4 h-4"></i>
|
||||
膨大な開発コストと時間。バグのリスクも高い。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- After -->
|
||||
<div class="bg-emerald-500/5 border border-emerald-500/20 rounded-xl p-6">
|
||||
<div class="inline-flex items-center gap-1.5 bg-emerald-500/10 text-emerald-600 px-3 py-1 rounded-full text-xs font-bold tracking-wide mb-5">
|
||||
<i data-lucide="check-circle" class="w-3.5 h-3.5"></i>
|
||||
AFTER — APIがある世界
|
||||
</div>
|
||||
<ul class="space-y-3">
|
||||
<li class="flex items-start gap-2.5">
|
||||
<i data-lucide="check" class="w-4 h-4 text-emerald-600 mt-1 flex-shrink-0"></i>
|
||||
<span>天気情報は<strong class="text-emerald-700">天気予報APIに問い合わせるだけ</strong>で取得できる</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2.5">
|
||||
<i data-lucide="check" class="w-4 h-4 text-emerald-600 mt-1 flex-shrink-0"></i>
|
||||
<span>決済は<strong class="text-emerald-700">Stripe APIに任せれば数行のコード</strong>で完成</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2.5">
|
||||
<i data-lucide="check" class="w-4 h-4 text-emerald-600 mt-1 flex-shrink-0"></i>
|
||||
<span>地図は<strong class="text-emerald-700">Google Maps APIで高品質な地図を即表示</strong>できる</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2.5">
|
||||
<i data-lucide="check" class="w-4 h-4 text-emerald-600 mt-1 flex-shrink-0"></i>
|
||||
<span>ログインは<strong class="text-emerald-700">GoogleやAppleのAPIで安全に認証</strong>できる</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="mt-5 pt-4 border-t border-emerald-500/10 text-sm text-emerald-700/70 flex items-center gap-2">
|
||||
<i data-lucide="zap" class="w-4 h-4"></i>
|
||||
「自分が本当に作るべきもの」に集中できる。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SECTION 3: APIの仕組み -->
|
||||
<!-- ============================================================ -->
|
||||
<section class="mb-16 md:mb-20">
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-cyan-500/10 flex-shrink-0">
|
||||
<i data-lucide="settings" class="w-5 h-5 text-cyan-600"></i>
|
||||
</div>
|
||||
<h2 class="text-xl md:text-2xl font-bold text-slate-900">APIの仕組み — リクエストとレスポンス</h2>
|
||||
</div>
|
||||
|
||||
<p class="mb-6 leading-relaxed">
|
||||
APIでのやり取りは、実はとてもシンプルです。基本は<strong class="text-slate-900">「聞く(リクエスト)」</strong>と<strong class="text-slate-900">「答える(レスポンス)」</strong>の2つだけ。
|
||||
</p>
|
||||
|
||||
<p class="mb-8 leading-relaxed">
|
||||
リクエスト(Request)とは、「こういう情報をください」「この処理をしてください」とAPIに送るメッセージのことです。レスポンス(Response)は、APIがその要求に対して返す結果です。この2つのやり取りを分解すると、4つのステップになります。
|
||||
</p>
|
||||
|
||||
<!-- 4ステップフロー図 -->
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl p-6 md:p-8 mb-8">
|
||||
<h3 class="text-lg font-bold text-slate-900 text-center mb-8">APIリクエスト〜レスポンスの流れ</h3>
|
||||
|
||||
<div class="flex flex-col md:flex-row items-stretch justify-center gap-3 md:gap-0">
|
||||
<div class="flex-1 bg-blue-500/10 border border-blue-500/20 rounded-xl p-4 text-center">
|
||||
<div class="w-8 h-8 rounded-full bg-blue-500 text-white text-sm font-bold flex items-center justify-center mx-auto mb-3">1</div>
|
||||
<i data-lucide="send" class="w-6 h-6 text-blue-600 mx-auto mb-2"></i>
|
||||
<div class="font-bold text-blue-700 text-sm mb-1">リクエスト送信</div>
|
||||
<div class="text-xs text-ads-muted leading-relaxed">あなたのアプリが<br>「こういう情報ください」<br>とAPIに送る</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center md:w-10 py-1 md:py-0">
|
||||
<i data-lucide="chevron-right" class="w-5 h-5 text-ads-dim hidden md:block"></i>
|
||||
<i data-lucide="chevron-down" class="w-5 h-5 text-ads-dim md:hidden"></i>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 bg-purple-500/10 border border-purple-500/20 rounded-xl p-4 text-center">
|
||||
<div class="w-8 h-8 rounded-full bg-purple-500 text-white text-sm font-bold flex items-center justify-center mx-auto mb-3">2</div>
|
||||
<i data-lucide="shield" class="w-6 h-6 text-purple-600 mx-auto mb-2"></i>
|
||||
<div class="font-bold text-purple-700 text-sm mb-1">APIが受け取る</div>
|
||||
<div class="text-xs text-ads-muted leading-relaxed">リクエストの内容を<br>チェック・認証する<br>(門番の役割)</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center md:w-10 py-1 md:py-0">
|
||||
<i data-lucide="chevron-right" class="w-5 h-5 text-ads-dim hidden md:block"></i>
|
||||
<i data-lucide="chevron-down" class="w-5 h-5 text-ads-dim md:hidden"></i>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 bg-emerald-500/10 border border-emerald-500/20 rounded-xl p-4 text-center">
|
||||
<div class="w-8 h-8 rounded-full bg-emerald-500 text-white text-sm font-bold flex items-center justify-center mx-auto mb-3">3</div>
|
||||
<i data-lucide="database" class="w-6 h-6 text-emerald-600 mx-auto mb-2"></i>
|
||||
<div class="font-bold text-emerald-700 text-sm mb-1">サーバーが処理</div>
|
||||
<div class="text-xs text-ads-muted leading-relaxed">データベース検索や<br>計算など、実際の<br>処理を実行する</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center md:w-10 py-1 md:py-0">
|
||||
<i data-lucide="chevron-right" class="w-5 h-5 text-ads-dim hidden md:block"></i>
|
||||
<i data-lucide="chevron-down" class="w-5 h-5 text-ads-dim md:hidden"></i>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 bg-amber-500/10 border border-amber-500/20 rounded-xl p-4 text-center">
|
||||
<div class="w-8 h-8 rounded-full bg-amber-500 text-white text-sm font-bold flex items-center justify-center mx-auto mb-3">4</div>
|
||||
<i data-lucide="reply" class="w-6 h-6 text-amber-600 mx-auto mb-2"></i>
|
||||
<div class="font-bold text-amber-700 text-sm mb-1">レスポンス返却</div>
|
||||
<div class="text-xs text-ads-muted leading-relaxed">処理結果をあなたの<br>アプリに返す<br>(料理が届く瞬間)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mb-6 leading-relaxed">
|
||||
言葉だけだとまだピンとこないかもしれません。では、実際のコードで見てみましょう。たとえば、天気予報APIから東京の天気を取得するコードは、たったこれだけです。
|
||||
</p>
|
||||
|
||||
<!-- コード例: JavaScript -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-2 bg-slate-800 border border-slate-700/50 rounded-t-xl px-4 py-2.5 text-xs text-ads-muted">
|
||||
<i data-lucide="code" class="w-3.5 h-3.5"></i>
|
||||
JavaScript — 天気予報APIの呼び出し例
|
||||
</div>
|
||||
<pre class="bg-slate-950 border border-slate-700/50 border-t-0 rounded-b-xl p-5 overflow-x-auto text-sm leading-loose"><code><span class="text-slate-500">// 1. APIにリクエストを送る(「東京の天気を教えて」と聞く)</span>
|
||||
<span class="text-purple-400">const</span> response <span class="text-slate-400">=</span> <span class="text-purple-400">await</span> <span class="text-blue-400">fetch</span>(<span class="text-emerald-400">"https://api.weather.example.com/current?city=tokyo"</span>);
|
||||
|
||||
<span class="text-slate-500">// 2. レスポンスをJSON形式(データの構造)に変換する</span>
|
||||
<span class="text-purple-400">const</span> data <span class="text-slate-400">=</span> <span class="text-purple-400">await</span> response.<span class="text-blue-400">json</span>();
|
||||
|
||||
<span class="text-slate-500">// 3. 必要なデータを取り出して使う</span>
|
||||
console.<span class="text-blue-400">log</span>(data.temperature); <span class="text-slate-500">// → "22°C"</span>
|
||||
console.<span class="text-blue-400">log</span>(data.condition); <span class="text-slate-500">// → "晴れ"</span>
|
||||
console.<span class="text-blue-400">log</span>(data.humidity); <span class="text-slate-500">// → "65%"</span></code></pre>
|
||||
</div>
|
||||
|
||||
<!-- コード解説 -->
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl p-5 mb-8">
|
||||
<h4 class="text-sm font-bold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<i data-lucide="file-text" class="w-4 h-4 text-ads-accent"></i>
|
||||
コードの解説(1行ずつ読み解く)
|
||||
</h4>
|
||||
<div class="space-y-4 text-sm">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-xs font-mono bg-blue-500/10 text-blue-600 px-2 py-0.5 rounded flex-shrink-0 mt-0.5">1</span>
|
||||
<span class="leading-relaxed"><code class="text-emerald-700 text-xs bg-slate-100 px-1.5 py-0.5 rounded">fetch()</code> は「指定したURLに問い合わせる」命令。URLの末尾にある <code class="text-emerald-700 text-xs bg-slate-100 px-1.5 py-0.5 rounded">?city=tokyo</code> が「東京の情報が欲しい」というリクエストの中身です。レストランのたとえで言えば「パスタください」にあたる部分。</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-xs font-mono bg-purple-500/10 text-purple-600 px-2 py-0.5 rounded flex-shrink-0 mt-0.5">2</span>
|
||||
<span class="leading-relaxed">返ってきたデータは機械向けの生データなので、<code class="text-emerald-700 text-xs bg-slate-100 px-1.5 py-0.5 rounded">.json()</code> で人間が読みやすい形(JSON = JavaScript Object Notation)に変換します。JSONは「名前: 値」の組み合わせでデータを表現する書式で、Web業界で最も広く使われています。</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-xs font-mono bg-emerald-500/10 text-emerald-600 px-2 py-0.5 rounded flex-shrink-0 mt-0.5">3</span>
|
||||
<span class="leading-relaxed">変換したデータから <code class="text-emerald-700 text-xs bg-slate-100 px-1.5 py-0.5 rounded">data.temperature</code> のように、ドット(.)で区切って欲しい情報を名前で取り出します。辞書で単語を引くのに似ています。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mb-4 leading-relaxed">
|
||||
ターミナル(コマンドを入力する黒い画面)からもAPIを試せます。<code class="text-emerald-700 text-xs bg-slate-100 px-1.5 py-0.5 rounded">curl</code>(カール)というコマンドを使うと、たった1行でAPIにリクエストを送れます。
|
||||
</p>
|
||||
|
||||
<!-- ターミナルUIモックアップ: curl -->
|
||||
<div class="mb-8 rounded-xl overflow-hidden border border-slate-700/50">
|
||||
<div class="flex items-center gap-3 bg-slate-800 px-4 py-2.5">
|
||||
<div class="flex gap-1.5">
|
||||
<div class="w-3 h-3 rounded-full bg-red-500/80"></div>
|
||||
<div class="w-3 h-3 rounded-full bg-yellow-500/80"></div>
|
||||
<div class="w-3 h-3 rounded-full bg-green-500/80"></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-xs text-slate-400">
|
||||
<i data-lucide="terminal" class="w-3.5 h-3.5"></i>
|
||||
ターミナル — curlコマンドでAPIを叩く
|
||||
</div>
|
||||
</div>
|
||||
<pre class="bg-slate-950 p-5 overflow-x-auto text-sm leading-loose"><code><span class="text-emerald-400">$</span> curl https://api.weather.example.com/current?city=tokyo
|
||||
|
||||
<span class="text-slate-500"># 返ってくるレスポンス(JSON形式)</span>
|
||||
{
|
||||
<span class="text-blue-400">"city"</span>: <span class="text-emerald-400">"東京"</span>,
|
||||
<span class="text-blue-400">"temperature"</span>: <span class="text-amber-400">"22°C"</span>,
|
||||
<span class="text-blue-400">"condition"</span>: <span class="text-emerald-400">"晴れ"</span>,
|
||||
<span class="text-blue-400">"humidity"</span>: <span class="text-amber-400">"65%"</span>
|
||||
}</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="bg-amber-500/5 border border-amber-500/20 rounded-xl p-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-500/10 flex-shrink-0 mt-0.5">
|
||||
<i data-lucide="info" class="w-4 h-4 text-amber-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-amber-700 mb-1">ちょっと補足: URLの構造</p>
|
||||
<p class="text-ads-muted leading-relaxed text-sm">
|
||||
<code class="text-emerald-700 text-xs bg-slate-100 px-1.5 py-0.5 rounded">https://api.weather.example.com/current?city=tokyo</code> のURLは、大きく3つの部分に分かれます。<strong class="text-slate-800">api.weather.example.com</strong> がAPIの住所(ベースURL)、<strong class="text-slate-800">/current</strong> が「何を」(現在の天気)、<strong class="text-slate-800">?city=tokyo</strong> が「どこの」(東京)というパラメータです。レストランで例えると「〇〇レストランの(住所)、メインメニューから(何を)、パスタを(詳細)」に対応します。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-8 mb-6 leading-relaxed">
|
||||
では、このAPIのレスポンスが実際のアプリではどう表示されるのでしょうか? あなたが見ている天気アプリの画面を覗いてみましょう。
|
||||
</p>
|
||||
|
||||
<!-- ブラウザUIモックアップ -->
|
||||
<div class="rounded-xl overflow-hidden border border-slate-700/50 mb-8">
|
||||
<div class="flex items-center gap-3 bg-slate-800 px-4 py-2.5">
|
||||
<div class="flex gap-1.5">
|
||||
<div class="w-3 h-3 rounded-full bg-red-500/80"></div>
|
||||
<div class="w-3 h-3 rounded-full bg-yellow-500/80"></div>
|
||||
<div class="w-3 h-3 rounded-full bg-green-500/80"></div>
|
||||
</div>
|
||||
<div class="flex-1 bg-slate-700/50 rounded-lg px-3 py-1 text-xs text-slate-400 flex items-center gap-1.5">
|
||||
<i data-lucide="lock" class="w-3 h-3 text-emerald-400"></i>
|
||||
weather-app.example.com
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gradient-to-b from-sky-100 to-sky-50 p-6 md:p-8">
|
||||
<div class="text-center">
|
||||
<div class="text-xs text-sky-600/70 font-medium mb-1">現在地: 東京</div>
|
||||
<div class="flex items-center justify-center gap-2 mb-2">
|
||||
<i data-lucide="sun" class="w-10 h-10 text-amber-500"></i>
|
||||
<span class="text-4xl font-black text-sky-900">22°C</span>
|
||||
</div>
|
||||
<div class="text-sm text-sky-700 font-medium mb-4">晴れ</div>
|
||||
<div class="flex justify-center gap-6 text-xs text-sky-600/80">
|
||||
<div class="flex items-center gap-1"><i data-lucide="droplets" class="w-3.5 h-3.5"></i> 65%</div>
|
||||
<div class="flex items-center gap-1"><i data-lucide="wind" class="w-3.5 h-3.5"></i> 3m/s</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-ads-accent/5 border border-ads-accent/20 rounded-xl p-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-lg bg-ads-accent/10 flex-shrink-0 mt-0.5">
|
||||
<i data-lucide="lightbulb" class="w-4 h-4 text-ads-accent-light"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-ads-accent-light mb-1">APIの結果 → アプリの画面</p>
|
||||
<p class="text-ads-muted leading-relaxed">
|
||||
上の天気アプリは、裏側で <code class="text-emerald-700 text-xs bg-slate-100 px-1.5 py-0.5 rounded">temperature: "22°C"</code> や <code class="text-emerald-700 text-xs bg-slate-100 px-1.5 py-0.5 rounded">condition: "晴れ"</code> というAPIレスポンスを受け取り、見やすいデザインに変換して表示しています。<strong class="text-slate-800">あなたが普段見ているきれいな画面の裏側では、こうしたAPIのやり取りが行われている</strong>のです。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SECTION 4: 身近なAPIの例 -->
|
||||
<!-- ============================================================ -->
|
||||
<section class="mb-16 md:mb-20">
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-amber-500/10 flex-shrink-0">
|
||||
<i data-lucide="smartphone" class="w-5 h-5 text-amber-600"></i>
|
||||
</div>
|
||||
<h2 class="text-xl md:text-2xl font-bold text-slate-900">身近なAPIの例 — 実はあなたも毎日使っている</h2>
|
||||
</div>
|
||||
|
||||
<p class="mb-8 leading-relaxed">
|
||||
「API」と聞くとプログラマーの専門用語に聞こえるかもしれません。しかし、あなたがスマホで何気なくやっている日常の操作の裏側では、たくさんのAPIが動いています。<strong class="text-slate-900">「あなたが見ている画面」の裏側で、APIが何をしているのか</strong>を図解します。
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- 天気予報アプリ -->
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl overflow-hidden">
|
||||
<div class="bg-gradient-to-b from-sky-100 to-sky-50 p-5">
|
||||
<div class="text-center">
|
||||
<div class="text-[10px] text-sky-600/60 font-medium mb-0.5">東京</div>
|
||||
<div class="flex items-center justify-center gap-1.5 mb-1">
|
||||
<i data-lucide="sun" class="w-7 h-7 text-amber-500"></i>
|
||||
<span class="text-2xl font-black text-sky-900">22°C</span>
|
||||
</div>
|
||||
<div class="text-xs text-sky-700 font-medium mb-2">晴れ</div>
|
||||
<div class="flex justify-center gap-4 text-[10px] text-sky-600/70">
|
||||
<div class="flex items-center gap-0.5"><i data-lucide="droplets" class="w-3 h-3"></i> 65%</div>
|
||||
<div class="flex items-center gap-0.5"><i data-lucide="wind" class="w-3 h-3"></i> 3m/s</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5 pt-4">
|
||||
<h3 class="font-bold text-slate-900 text-sm mb-2 flex items-center gap-2">
|
||||
<i data-lucide="cloud" class="w-4 h-4 text-cyan-600"></i> 天気予報アプリ
|
||||
</h3>
|
||||
<div class="text-xs text-cyan-600 font-medium mb-1">裏側でAPIがやっていること</div>
|
||||
<p class="text-sm text-ads-muted leading-relaxed">気象庁のサーバーに「東京の最新天気データをください」とリクエストを送り、気温・天候・湿度・風速などのデータをJSON形式で受け取っている</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Googleログイン -->
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl overflow-hidden">
|
||||
<div class="bg-white p-5 flex flex-col items-center justify-center">
|
||||
<div class="text-xs text-ads-muted mb-3">アカウントにログイン</div>
|
||||
<div class="flex items-center gap-2.5 border border-ads-border rounded-lg px-5 py-2.5 bg-white hover:bg-ads-hover transition-colors">
|
||||
<div class="w-5 h-5 rounded-full bg-gradient-to-br from-blue-500 via-red-500 to-yellow-500 flex items-center justify-center">
|
||||
<span class="text-[8px] font-black text-white">G</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-slate-700">Google でログイン</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-3">
|
||||
<div class="flex-1 border-t border-ads-border/50"></div>
|
||||
<span class="text-[10px] text-ads-dim">または</span>
|
||||
<div class="flex-1 border-t border-ads-border/50"></div>
|
||||
</div>
|
||||
<div class="w-full mt-2 bg-ads-surface border border-ads-border/50 rounded-lg px-3 py-1.5 text-xs text-ads-dim">メールアドレスで登録</div>
|
||||
</div>
|
||||
<div class="p-5 pt-4">
|
||||
<h3 class="font-bold text-slate-900 text-sm mb-2 flex items-center gap-2">
|
||||
<i data-lucide="log-in" class="w-4 h-4 text-blue-600"></i> 「Googleでログイン」ボタン
|
||||
</h3>
|
||||
<div class="text-xs text-blue-600 font-medium mb-1">裏側でAPIがやっていること</div>
|
||||
<p class="text-sm text-ads-muted leading-relaxed">GoogleのOAuth API(オーオース = 認可の仕組み)に「このユーザーの身元を確認してください」と問い合わせ、認証トークン(本人確認済みの証)を受け取っている</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- オンライン決済 -->
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl overflow-hidden">
|
||||
<div class="bg-gradient-to-b from-emerald-50 to-white p-5 text-center">
|
||||
<div class="w-10 h-10 rounded-full bg-emerald-500/15 flex items-center justify-center mx-auto mb-2">
|
||||
<i data-lucide="check" class="w-6 h-6 text-emerald-600"></i>
|
||||
</div>
|
||||
<div class="text-sm font-bold text-emerald-700 mb-1">お支払い完了</div>
|
||||
<div class="text-xl font-black text-slate-900 mb-1">¥1,980</div>
|
||||
<div class="text-[10px] text-ads-dim">VISA **** 4242</div>
|
||||
</div>
|
||||
<div class="p-5 pt-4">
|
||||
<h3 class="font-bold text-slate-900 text-sm mb-2 flex items-center gap-2">
|
||||
<i data-lucide="credit-card" class="w-4 h-4 text-emerald-600"></i> オンライン決済
|
||||
</h3>
|
||||
<div class="text-xs text-emerald-600 font-medium mb-1">裏側でAPIがやっていること</div>
|
||||
<p class="text-sm text-ads-muted leading-relaxed">Stripe等の決済APIが、クレジットカード会社のサーバーと暗号化通信を行い、与信確認(この人は支払える?)→ 決済処理 → 結果通知を実行している</p>
|
||||
<p class="text-[10px] text-ads-dim mt-2">出典: <a href="https://docs.stripe.com/api" class="underline decoration-ads-dim/30 hover:text-ads-accent transition-colors">Stripe API 公式ドキュメント</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 地図・ナビ -->
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl overflow-hidden">
|
||||
<div class="bg-emerald-50/50 p-5">
|
||||
<div class="relative bg-emerald-100/80 rounded-lg p-4 h-28 flex flex-col justify-between">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-4 h-4 rounded-full bg-blue-500 border-2 border-white flex items-center justify-center">
|
||||
<div class="w-1.5 h-1.5 bg-white rounded-full"></div>
|
||||
</div>
|
||||
<span class="text-[10px] text-blue-700 font-medium">現在地</span>
|
||||
</div>
|
||||
<div class="border-l-2 border-dashed border-blue-400/60 ml-2 h-6"></div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i data-lucide="map-pin" class="w-4 h-4 text-red-500"></i>
|
||||
<span class="text-[10px] text-red-700 font-medium">東京駅</span>
|
||||
</div>
|
||||
<div class="bg-white rounded-full px-2 py-0.5 text-[10px] font-bold text-blue-700 border border-blue-200">12分</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5 pt-4">
|
||||
<h3 class="font-bold text-slate-900 text-sm mb-2 flex items-center gap-2">
|
||||
<i data-lucide="map-pin" class="w-4 h-4 text-red-600"></i> 地図・ナビアプリ
|
||||
</h3>
|
||||
<div class="text-xs text-red-600 font-medium mb-1">裏側でAPIがやっていること</div>
|
||||
<p class="text-sm text-ads-muted leading-relaxed">Google Maps APIが地図画像の取得、現在の交通情報の取得、経路計算をそれぞれ別のAPIに問い合わせ、統合して表示している</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 bg-ads-accent/5 border border-ads-accent/20 rounded-xl p-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-lg bg-ads-accent/10 flex-shrink-0 mt-0.5">
|
||||
<i data-lucide="lightbulb" class="w-4 h-4 text-ads-accent-light"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-ads-accent-light mb-1">気づきましたか?</p>
|
||||
<p class="text-ads-muted leading-relaxed text-sm">
|
||||
上の4つの例に共通しているのは、<strong class="text-slate-800">あなたがAPIの存在を意識していない</strong>ということです。天気を確認するとき「今からAPIを呼ぶぞ」とは思いませんよね。優れたAPIは、ユーザーにその存在を感じさせません。まるで空気のように、裏側で静かに仕事をしているのです。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SECTION 5: APIを使うとどう嬉しいか -->
|
||||
<!-- ============================================================ -->
|
||||
<section class="mb-16 md:mb-20">
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-emerald-500/10 flex-shrink-0">
|
||||
<i data-lucide="trending-up" class="w-5 h-5 text-emerald-600"></i>
|
||||
</div>
|
||||
<h2 class="text-xl md:text-2xl font-bold text-slate-900">APIを使うとどう嬉しいか</h2>
|
||||
</div>
|
||||
|
||||
<p class="mb-8 leading-relaxed">
|
||||
ここまで読んで「APIは便利そうだ」と感じてもらえたと思います。では、開発者の視点から見たとき、APIを使うことで<strong class="text-slate-900">具体的にどのくらいの効果</strong>があるのか。数字と一緒に見てみましょう。
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-10">
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl p-6 text-center">
|
||||
<div class="text-3xl md:text-4xl font-black text-ads-accent leading-tight mb-2">50回+</div>
|
||||
<div class="text-sm text-ads-muted">あなたが1日に<br>APIを使っている回数</div>
|
||||
</div>
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl p-6 text-center">
|
||||
<div class="text-3xl md:text-4xl font-black text-emerald-600 leading-tight mb-2">24,000+</div>
|
||||
<div class="text-sm text-ads-muted">世界で公開されている<br>APIの数</div>
|
||||
<p class="text-[10px] text-ads-dim mt-2">出典: <a href="https://www.programmableweb.com/" class="underline decoration-ads-dim/30 hover:text-ads-accent transition-colors">ProgrammableWeb</a></p>
|
||||
</div>
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl p-6 text-center">
|
||||
<div class="text-3xl md:text-4xl font-black text-amber-600 leading-tight mb-2">0.2秒</div>
|
||||
<div class="text-sm text-ads-muted">多くのAPIの<br>平均応答時間</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="flex gap-4 bg-ads-surface border border-ads-border rounded-xl p-5">
|
||||
<div class="w-10 h-10 rounded-lg bg-amber-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<i data-lucide="zap" class="w-5 h-5 text-amber-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-900 mb-2">開発スピードが上がる</h3>
|
||||
<p class="text-sm text-ads-muted leading-relaxed">決済、認証、地図、翻訳...。これらをゼロから作ると何ヶ月もかかりますが、APIを使えば数日〜数時間で実装できます。車を作りたいとき、エンジンから設計する必要はないのです。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 bg-ads-surface border border-ads-border rounded-xl p-5">
|
||||
<div class="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<i data-lucide="shield" class="w-5 h-5 text-blue-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-900 mb-2">品質が担保される</h3>
|
||||
<p class="text-sm text-ads-muted leading-relaxed">Google Maps、Stripe、AWSなど、各分野の専門企業が何千人体制で開発・運用しているAPIの品質は、個人や小さなチームで再現できるレベルではありません。その品質を「借りる」ことができます。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 bg-ads-surface border border-ads-border rounded-xl p-5">
|
||||
<div class="w-10 h-10 rounded-lg bg-purple-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<i data-lucide="refresh-cw" class="w-5 h-5 text-purple-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-900 mb-2">保守の手間が減る</h3>
|
||||
<p class="text-sm text-ads-muted leading-relaxed">API提供元がバグ修正・機能改善・セキュリティ更新を継続的に行ってくれます。あなたはAPIを「使うだけ」。自分でゼロから作った機能は、自分でずっと面倒を見続ける必要があります。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 bg-ads-surface border border-ads-border rounded-xl p-5">
|
||||
<div class="w-10 h-10 rounded-lg bg-emerald-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<i data-lucide="layers" class="w-5 h-5 text-emerald-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-900 mb-2">レゴのように拡張できる</h3>
|
||||
<p class="text-sm text-ads-muted leading-relaxed">APIはレゴブロックのように組み合わせられます。たとえば「翻訳API + 音声合成API」を組み合わせれば、多言語音声読み上げ機能が作れます。1つのAPIだけでは実現できない価値が、組み合わせで生まれるのです。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SECTION 6: よくある誤解 -->
|
||||
<!-- ============================================================ -->
|
||||
<section class="mb-16 md:mb-20">
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-red-500/10 flex-shrink-0">
|
||||
<i data-lucide="alert-circle" class="w-5 h-5 text-red-600"></i>
|
||||
</div>
|
||||
<h2 class="text-xl md:text-2xl font-bold text-slate-900">よくある誤解</h2>
|
||||
</div>
|
||||
|
||||
<p class="mb-8 leading-relaxed">
|
||||
APIについて学び始めると、多くの人が同じところでつまずきます。ここでは、初学者が陥りがちな3つの誤解を取り上げて、正しい理解に修正します。
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl overflow-hidden">
|
||||
<div class="flex items-center gap-3 px-6 py-4 bg-red-500/5 border-b border-ads-border/50">
|
||||
<div class="w-6 h-6 rounded-full bg-red-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<i data-lucide="x" class="w-3.5 h-3.5 text-red-600"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-red-700 text-sm">誤解: 「APIはプログラマーだけが使うもの」</h3>
|
||||
</div>
|
||||
<div class="px-6 py-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-6 h-6 rounded-full bg-emerald-500/10 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<i data-lucide="check" class="w-3.5 h-3.5 text-emerald-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-bold text-emerald-700 mb-2">実際は:</p>
|
||||
<p class="text-sm text-ads-muted leading-relaxed">
|
||||
あなたも毎日APIを使っています。朝、天気アプリを開く。SNSにログインする。電子マネーで買い物する。これらの操作はすべて、裏側でAPIが動いています。プログラマーが「APIを使う」のは、この仕組みのコードを書いている側にいるだけの話。<strong class="text-slate-800">気づかないうちにAPIの恩恵を毎日受けている</strong>のです。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl overflow-hidden">
|
||||
<div class="flex items-center gap-3 px-6 py-4 bg-red-500/5 border-b border-ads-border/50">
|
||||
<div class="w-6 h-6 rounded-full bg-red-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<i data-lucide="x" class="w-3.5 h-3.5 text-red-600"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-red-700 text-sm">誤解: 「APIって難しい技術でしょ?」</h3>
|
||||
</div>
|
||||
<div class="px-6 py-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-6 h-6 rounded-full bg-emerald-500/10 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<i data-lucide="check" class="w-3.5 h-3.5 text-emerald-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-bold text-emerald-700 mb-2">実際は:</p>
|
||||
<p class="text-sm text-ads-muted leading-relaxed">
|
||||
APIの概念自体は「注文して結果を受け取る」というシンプルな仕組みです。レストランで注文できるなら、APIの概念は理解できます。先ほどのコード例のように、実際のプログラムも数行で書けることがほとんどです。難しいのはAPIそのものではなく、<strong class="text-slate-800">「APIで何を作るか」を考える部分</strong>。道具はシンプル、使いこなすセンスが問われるということです。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl overflow-hidden">
|
||||
<div class="flex items-center gap-3 px-6 py-4 bg-red-500/5 border-b border-ads-border/50">
|
||||
<div class="w-6 h-6 rounded-full bg-red-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<i data-lucide="x" class="w-3.5 h-3.5 text-red-600"></i>
|
||||
</div>
|
||||
<h3 class="font-bold text-red-700 text-sm">誤解: 「APIを使うと個人情報が漏れそうで怖い」</h3>
|
||||
</div>
|
||||
<div class="px-6 py-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-6 h-6 rounded-full bg-emerald-500/10 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<i data-lucide="check" class="w-3.5 h-3.5 text-emerald-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-bold text-emerald-700 mb-2">実際は:</p>
|
||||
<p class="text-sm text-ads-muted leading-relaxed">
|
||||
適切に設計されたAPIは、<strong class="text-slate-800">必要最小限の情報だけ</strong>をやり取りします。たとえば銀行のAPIが口座残高を返す際、パスワードや暗証番号は一切含まれません。APIはデータの「窓口」であり、<strong class="text-slate-800">「何の情報を公開し、何を隠すか」を厳密に制御</strong>できます。むしろ、データベースに直接触るよりもAPIを介した方が安全なのです。レストランのたとえで言えば、お客さんが直接厨房に入るより、ウェイターを通した方が厨房の秩序が保たれるのと同じです。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SECTION 7: まとめ -->
|
||||
<!-- ============================================================ -->
|
||||
<section>
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-ads-accent/10 flex-shrink-0">
|
||||
<i data-lucide="check-circle" class="w-5 h-5 text-ads-accent-light"></i>
|
||||
</div>
|
||||
<h2 class="text-xl md:text-2xl font-bold text-slate-900">まとめ — 覚えておきたい3つのこと</h2>
|
||||
</div>
|
||||
|
||||
<p class="mb-8 leading-relaxed">
|
||||
長い図解を読んでいただきありがとうございます。最後に、この記事で伝えたかったことを3つに絞ってまとめます。
|
||||
</p>
|
||||
|
||||
<div class="space-y-4 mb-10">
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl p-6 border-l-4 border-l-blue-500">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="text-2xl font-black text-blue-500 leading-none flex-shrink-0 mt-1">01</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-900 mb-2">APIは「ソフトウェアの窓口」</h3>
|
||||
<p class="text-sm text-ads-muted leading-relaxed">
|
||||
レストランのウェイターのように、あなた(アプリ)とサーバーの間を取り持つ仲介役。相手の内部構造を知らなくても、決まったルール(インターフェース)で話しかければ結果が返ってきます。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl p-6 border-l-4 border-l-emerald-500">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="text-2xl font-black text-emerald-500 leading-none flex-shrink-0 mt-1">02</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-900 mb-2">あなたはすでにAPIユーザー</h3>
|
||||
<p class="text-sm text-ads-muted leading-relaxed">
|
||||
天気予報、SNSログイン、地図検索、オンライン決済。気づかないうちに、あなたの日常はAPIに支えられています。APIは特別な人だけのものではなく、全員の生活を支える仕組みです。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-ads-surface border border-ads-border rounded-xl p-6 border-l-4 border-l-amber-500">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="text-2xl font-black text-amber-500 leading-none flex-shrink-0 mt-1">03</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-900 mb-2">APIで「車輪の再発明」がなくなる</h3>
|
||||
<p class="text-sm text-ads-muted leading-relaxed">
|
||||
すでにある優れた機能をAPIで借りることで、自分は「自分にしか作れない部分」に集中できます。開発スピードが上がり、品質も上がり、保守の手間も減る。これがAPIの最大の恩恵です。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center bg-gradient-to-b from-ads-accent/5 to-transparent border border-ads-accent/10 rounded-xl p-8 md:p-10">
|
||||
<i data-lucide="globe" class="w-8 h-8 text-ads-accent mx-auto mb-4"></i>
|
||||
<p class="text-lg font-bold text-slate-900 mb-3">APIは「知っている」だけで世界が広がる概念です。</p>
|
||||
<p class="text-ads-muted max-w-lg mx-auto leading-relaxed">
|
||||
次にアプリを使うとき、「この裏側でどんなAPIが動いているんだろう?」と想像してみてください。天気予報の数字も、ログインボタンも、決済完了の画面も、すべてAPIが繋いでいます。テクノロジーの見え方が、少しだけ変わるはずです。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CONTENT_END -->
|
||||
</main>
|
||||
<footer class="max-w-3xl mx-auto px-5 pb-10 pt-6 border-t border-ads-border/30">
|
||||
<p class="text-xs text-ads-dim text-center">AI-Driven School の図解ツールで作成</p>
|
||||
</footer>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script>lucide.createIcons();</script>
|
||||
<script src="/widget.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>FB Tool Test</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Hiragino Sans", sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; line-height: 1.8; color: #333; }
|
||||
h1 { border-bottom: 2px solid #e5e5e5; padding-bottom: 8px; }
|
||||
h2 { color: #555; margin-top: 32px; }
|
||||
p { color: #555; margin: 12px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>FBツール テストページ</h1>
|
||||
<p>このページはFBツールの動作確認用です。テキストを選択してコメントを追加できます。</p>
|
||||
|
||||
<h2>サンプルテキスト</h2>
|
||||
<p>AI-Driven Schoolは、AI時代に最適化した企画スキルと開発スキルを実践的に学ぶ6ヶ月間のオンラインプログラムです。講義設計・運営ドキュメント・CS・マーケティング・ツール開発をこのリポジトリで管理しています。</p>
|
||||
<p>受講生はチームワークを通じて、実践的なプロジェクトに取り組みます。フィードバックは成長の鍵であり、適切なタイミングで的確なコメントを届けることが重要です。</p>
|
||||
<p>カリキュラムは全12回で構成され、毎月のテーマに沿って段階的にスキルを積み上げていきます。各回の講義では座学とワークを組み合わせ、すぐに使える知識と経験を提供します。</p>
|
||||
|
||||
<h2>フォーカス連動テスト</h2>
|
||||
<p>サイドバーのカードをクリックすると、対応するハイライト箇所にスクロールします。逆に、本文のハイライトをクリックするとサイドバーのカードにスクロールします。カードにホバーするとハイライトがパルスで点滅します。</p>
|
||||
|
||||
<h2>リサイズテスト</h2>
|
||||
<p>サイドバーの左端にマウスを当てるとカーソルが左右矢印に変わります。ドラッグで幅を変更でき、300pxから800pxの範囲で調整可能です。変更した幅はページをリロードしても維持されます。</p>
|
||||
|
||||
<script src="/widget.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1,926 @@
|
||||
/**
|
||||
* Icons: Lucide (https://lucide.dev)
|
||||
* ISC License - Copyright (c) Lucide Contributors 2026
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var SCRIPT = document.currentScript;
|
||||
var API_BASE = SCRIPT ? SCRIPT.src.replace(/\/widget\.js.*$/, '') : '';
|
||||
var API_TOKEN = SCRIPT ? (SCRIPT.dataset.token || '') : '';
|
||||
var USERNAME_KEY = 'fb-username';
|
||||
var SIDEBAR_WIDTH_KEY = 'fb-sidebar-width';
|
||||
var PRIORITY_CYCLE = { must: 'better', better: 'want', want: 'must' };
|
||||
var PRIORITY_COLORS = {
|
||||
must: { bg: '#ef4444', text: '#fff', light: 'rgba(239,68,68,0.1)', border: 'rgba(239,68,68,0.3)' },
|
||||
better: { bg: '#f59e0b', text: '#fff', light: 'rgba(245,158,11,0.1)', border: 'rgba(245,158,11,0.3)' },
|
||||
want: { bg: '#22c55e', text: '#fff', light: 'rgba(34,197,94,0.1)', border: 'rgba(34,197,94,0.3)' },
|
||||
};
|
||||
|
||||
var SVG = {
|
||||
message: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"/></svg>',
|
||||
check: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
|
||||
rotateCcw: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>',
|
||||
pencil: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></svg>',
|
||||
trash: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>',
|
||||
user: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>',
|
||||
x: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>',
|
||||
panelRight: '<svg width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="SW" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/></svg>',
|
||||
};
|
||||
|
||||
function icon(name, size, strokeWidth) {
|
||||
size = size || 14;
|
||||
strokeWidth = strokeWidth || 2;
|
||||
return (SVG[name] || '').replace(/SIZE/g, size).replace(/SW/g, strokeWidth);
|
||||
}
|
||||
|
||||
var state = {
|
||||
username: localStorage.getItem(USERNAME_KEY) || '',
|
||||
comments: [],
|
||||
filter: 'unresolved',
|
||||
sidebarOpen: false,
|
||||
selectedText: '',
|
||||
selectedQuoteContext: { beforeText: '', afterText: '' },
|
||||
selectedRect: null,
|
||||
popupContent: '',
|
||||
editingId: null,
|
||||
editContent: '',
|
||||
editPriority: 'want',
|
||||
replyingTo: null,
|
||||
replyText: '',
|
||||
editingName: false,
|
||||
nameInput: '',
|
||||
popupPriority: 'must',
|
||||
sidebarWidth: parseInt(localStorage.getItem(SIDEBAR_WIDTH_KEY), 10) || 400,
|
||||
};
|
||||
|
||||
var slug = (function () {
|
||||
return window.location.href
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/[^a-zA-Z0-9\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF]/g, '_')
|
||||
.substring(0, 100);
|
||||
})();
|
||||
|
||||
function genId() {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substring(2, 8);
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtTime(ts) {
|
||||
var d = Date.now() - ts;
|
||||
if (d < 60000) return 'たった今';
|
||||
if (d < 3600000) return Math.floor(d / 60000) + '分前';
|
||||
if (d < 86400000) return Math.floor(d / 3600000) + '時間前';
|
||||
return new Date(ts).toLocaleDateString('ja-JP', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// ===== API =====
|
||||
function api(method, params) {
|
||||
var url = API_BASE + '/api/comments';
|
||||
var hdrs = { 'Content-Type': 'application/json' };
|
||||
if (API_TOKEN) hdrs['Authorization'] = 'Bearer ' + API_TOKEN;
|
||||
var opts = { method: method, headers: hdrs };
|
||||
if (method === 'GET') {
|
||||
url += '?slug=' + encodeURIComponent(params.slug);
|
||||
} else if (method === 'DELETE') {
|
||||
url += '?id=' + encodeURIComponent(params.id);
|
||||
} else {
|
||||
opts.body = JSON.stringify(params);
|
||||
}
|
||||
return fetch(url, opts).then(function (r) { return r.json(); });
|
||||
}
|
||||
|
||||
function loadComments() {
|
||||
return api('GET', { slug: slug }).then(function (c) {
|
||||
if (Array.isArray(c)) state.comments = c;
|
||||
render();
|
||||
applyHighlights();
|
||||
}).catch(function () {
|
||||
render();
|
||||
applyHighlights();
|
||||
});
|
||||
}
|
||||
|
||||
// ===== CSS =====
|
||||
function injectStyles() {
|
||||
var style = document.createElement('style');
|
||||
style.id = 'fb-widget-styles';
|
||||
style.textContent = [
|
||||
':root{--fb-bg:#ffffff;--fb-fg:#0a0a0a;--fb-muted:#f5f5f5;--fb-muted-fg:#737373;--fb-border:#e5e5e5;--fb-primary:#171717;--fb-primary-fg:#fafafa;--fb-accent:#3b82f6;--fb-destructive:#ef4444;--fb-font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Hiragino Sans",sans-serif}',
|
||||
|
||||
/* Toggle button */
|
||||
'#fb-toggle{position:fixed;right:0;top:50%;transform:translateY(-50%);width:36px;background:var(--fb-bg);border:1px solid var(--fb-border);border-right:none;border-radius:8px 0 0 8px;cursor:pointer;z-index:99999;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;font-family:var(--fb-font);transition:all .15s;box-shadow:-2px 0 8px rgba(0,0,0,0.06);padding:8px 0}',
|
||||
'#fb-toggle:hover{background:var(--fb-muted);box-shadow:-2px 0 12px rgba(0,0,0,0.1)}',
|
||||
'#fb-toggle .fb-toggle-icon{color:var(--fb-muted-fg);display:flex;align-items:center;justify-content:center;position:relative}',
|
||||
'#fb-toggle .fb-toggle-label{font-size:10px;color:var(--fb-accent);font-weight:700;writing-mode:vertical-rl;letter-spacing:1px;line-height:1;white-space:nowrap}',
|
||||
'#fb-toggle .fb-badge{position:absolute;top:-6px;left:-6px;background:var(--fb-destructive);color:#fff;font-size:9px;font-weight:700;min-width:16px;height:16px;border-radius:8px;display:flex;align-items:center;justify-content:center;padding:0 3px;line-height:1}',
|
||||
|
||||
/* Sidebar */
|
||||
'#fb-sidebar{position:fixed;top:0;height:100vh;background:rgba(245,245,245,0.5);border-left:1px solid var(--fb-border);z-index:99998;transition:right .3s ease;display:flex;flex-direction:column;font-family:var(--fb-font);font-size:14px;color:var(--fb-fg)}',
|
||||
'#fb-sidebar *{box-sizing:border-box}',
|
||||
|
||||
/* Resize handle */
|
||||
'.fb-resize-handle{position:absolute;left:-3px;top:0;width:6px;height:100%;cursor:col-resize;z-index:1}',
|
||||
'.fb-resize-handle:hover{background:rgba(59,130,246,0.15)}',
|
||||
'.fb-resize-handle.active{background:rgba(59,130,246,0.3)}',
|
||||
'body.fb-resizing{cursor:col-resize !important;-webkit-user-select:none !important;user-select:none !important}',
|
||||
'body.fb-resizing *{cursor:col-resize !important}',
|
||||
|
||||
/* Header */
|
||||
'.fb-header{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--fb-border)}',
|
||||
'.fb-header-left{display:flex;align-items:center;gap:8px}',
|
||||
'.fb-header-title{font-size:14px;font-weight:700;color:var(--fb-accent)}',
|
||||
'.fb-header-count{background:var(--fb-muted);color:var(--fb-muted-fg);font-size:11px;padding:1px 7px;border-radius:10px}',
|
||||
'.fb-header-actions{display:flex;align-items:center;gap:2px}',
|
||||
'.fb-hdr-btn{background:none;border:none;cursor:pointer;padding:6px;border-radius:6px;color:var(--fb-muted-fg);transition:all .15s;display:inline-flex;align-items:center;gap:4px;font-family:var(--fb-font);font-size:12px}',
|
||||
'.fb-hdr-btn:hover{background:var(--fb-muted);color:var(--fb-fg)}',
|
||||
|
||||
/* User row */
|
||||
'.fb-user-row{display:flex;align-items:center;padding:8px 16px;border-bottom:1px solid var(--fb-border);font-size:13px;color:var(--fb-muted-fg);cursor:pointer;transition:color .15s;gap:6px}',
|
||||
'.fb-user-row:hover{color:var(--fb-fg)}',
|
||||
|
||||
/* Filters */
|
||||
'.fb-filters{display:flex;border-bottom:1px solid var(--fb-border)}',
|
||||
'.fb-filter{flex:1;padding:10px 0;text-align:center;font-size:13px;color:var(--fb-muted-fg);border:none;background:none;cursor:pointer;border-bottom:2px solid transparent;transition:all .15s;font-family:var(--fb-font)}',
|
||||
'.fb-filter:hover{color:var(--fb-fg)}',
|
||||
'.fb-filter.active{color:var(--fb-fg);border-bottom-color:var(--fb-accent)}',
|
||||
'.fb-filter .cnt{font-size:11px;margin-left:4px;padding:1px 5px;border-radius:8px;background:var(--fb-muted);color:var(--fb-muted-fg)}',
|
||||
'.fb-filter.active .cnt{background:rgba(59,130,246,0.1);color:var(--fb-accent)}',
|
||||
|
||||
/* List */
|
||||
'.fb-list{flex:1;overflow-y:auto;padding:8px}',
|
||||
'.fb-empty{text-align:center;padding:60px 20px;color:var(--fb-muted-fg);font-size:13px}',
|
||||
'.fb-empty svg{margin:0 auto 12px;display:block;color:var(--fb-border)}',
|
||||
|
||||
/* Card */
|
||||
'.fb-card{background:var(--fb-bg);border:1px solid var(--fb-border);border-left:3px solid var(--fb-border);border-radius:12px;padding:14px;margin-bottom:6px;transition:box-shadow .3s,background .3s;cursor:pointer}',
|
||||
'.fb-card:hover{box-shadow:0 1px 3px rgba(0,0,0,0.05)}',
|
||||
'.fb-card.resolved{opacity:.5}',
|
||||
|
||||
/* Card header */
|
||||
'.fb-card-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}',
|
||||
'.fb-card-head-left{display:flex;align-items:center;gap:6px}',
|
||||
'.fb-avatar{width:22px;height:22px;border-radius:50%;background:var(--fb-primary);color:var(--fb-primary-fg);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;flex-shrink:0}',
|
||||
'.fb-author{font-size:13px;font-weight:700;color:var(--fb-fg)}',
|
||||
'.fb-time{font-size:11px;color:var(--fb-muted-fg)}',
|
||||
'.fb-resolved-mark{font-size:11px;color:#22c55e;margin-left:4px;display:inline-flex;align-items:center;gap:2px}',
|
||||
|
||||
/* Priority badge */
|
||||
'.fb-badge-p{font-size:11px;font-weight:700;padding:2px 8px;border-radius:4px;color:#fff;cursor:default;transition:all .15s}',
|
||||
'.fb-badge-p.own{cursor:pointer}',
|
||||
'.fb-badge-p.own:hover{transform:scale(1.1);box-shadow:0 0 0 2px rgba(0,0,0,0.08)}',
|
||||
|
||||
/* Quote */
|
||||
'.fb-quote{font-size:12px;color:var(--fb-muted-fg);padding:6px 10px;background:var(--fb-muted);border-left:2px solid var(--fb-primary);border-radius:0 4px 4px 0;margin-bottom:8px;font-style:italic;line-height:1.5;cursor:pointer;transition:background .15s}',
|
||||
'.fb-quote:hover{background:var(--fb-border)}',
|
||||
|
||||
/* Body */
|
||||
'.fb-body{font-size:13px;color:var(--fb-muted-fg);line-height:1.6;margin-bottom:6px;white-space:pre-wrap}',
|
||||
|
||||
/* Actions */
|
||||
'.fb-actions{display:flex;gap:2px;flex-wrap:wrap}',
|
||||
'.fb-act{font-size:12px;color:#a3a3a3;background:none;border:none;padding:4px 8px;border-radius:4px;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:3px;font-family:var(--fb-font)}',
|
||||
'.fb-act:hover{color:var(--fb-fg)}',
|
||||
'.fb-act.del:hover{color:var(--fb-destructive)}',
|
||||
'.fb-act.res:hover{color:#22c55e}',
|
||||
|
||||
/* Replies */
|
||||
'.fb-replies{margin-top:8px;padding-top:8px;border-top:1px solid var(--fb-muted)}',
|
||||
'.fb-reply-item{display:flex;gap:8px;padding:6px 0}',
|
||||
'.fb-reply-item+.fb-reply-item{border-top:1px solid var(--fb-muted)}',
|
||||
'.fb-reply-avatar{width:20px;height:20px;border-radius:50%;background:var(--fb-primary);color:var(--fb-primary-fg);display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:700;flex-shrink:0;margin-top:2px}',
|
||||
'.fb-reply-meta{font-size:12px;color:var(--fb-muted-fg);margin-bottom:2px}',
|
||||
'.fb-reply-meta strong{color:#525252;font-weight:700}',
|
||||
'.fb-reply-text{font-size:13px;color:var(--fb-muted-fg);line-height:1.5}',
|
||||
|
||||
/* Reply input */
|
||||
'.fb-reply-input{display:flex;gap:8px;margin-top:8px}',
|
||||
'.fb-reply-input textarea{flex:1;padding:8px 10px;border:1px solid var(--fb-border);border-radius:6px;font-size:13px;font-family:var(--fb-font);resize:none;outline:none;min-height:36px;color:var(--fb-fg)}',
|
||||
'.fb-reply-input textarea:focus{border-color:var(--fb-accent)}',
|
||||
'.fb-reply-input button{padding:6px 14px;background:var(--fb-primary);color:var(--fb-primary-fg);border:none;border-radius:6px;font-size:12px;cursor:pointer;font-family:var(--fb-font);align-self:flex-end}',
|
||||
'.fb-reply-input button:disabled{opacity:.4;cursor:default}',
|
||||
|
||||
/* Edit area */
|
||||
'.fb-edit-area textarea{width:100%;padding:8px 10px;border:1px solid var(--fb-border);border-radius:6px;font-size:13px;font-family:var(--fb-font);resize:vertical;outline:none;min-height:50px;margin-bottom:6px;color:var(--fb-fg)}',
|
||||
'.fb-edit-area textarea:focus{border-color:var(--fb-accent)}',
|
||||
'.fb-edit-btns{display:flex;gap:6px}',
|
||||
'.fb-edit-btns button{padding:4px 12px;border-radius:6px;font-size:12px;cursor:pointer;border:1px solid var(--fb-border);background:var(--fb-bg);color:var(--fb-muted-fg);font-family:var(--fb-font)}',
|
||||
'.fb-edit-btns button.save{background:var(--fb-primary);color:var(--fb-primary-fg);border-color:var(--fb-primary)}',
|
||||
'.fb-edit-pri{display:flex;gap:4px;margin-bottom:6px}',
|
||||
'.fb-edit-pri button{padding:2px 10px;border-radius:4px;font-size:11px;font-weight:700;cursor:pointer;border:1px solid var(--fb-border);background:var(--fb-bg);color:var(--fb-muted-fg);font-family:var(--fb-font);transition:all .15s}',
|
||||
|
||||
/* Popup */
|
||||
'.fb-popup{position:fixed;z-index:100000;width:400px;background:var(--fb-bg);border:1px solid var(--fb-border);border-radius:12px;padding:16px;box-shadow:0 10px 25px rgba(0,0,0,0.1);font-family:var(--fb-font);display:none}',
|
||||
'.fb-popup.show{display:block}',
|
||||
'.fb-popup-head{margin-bottom:10px}',
|
||||
'.fb-popup-head span{font-size:14px;font-weight:700;color:var(--fb-fg)}',
|
||||
'.fb-popup-quote{font-size:13px;color:var(--fb-muted-fg);padding:8px 12px;background:var(--fb-muted);border-left:2px solid var(--fb-accent);border-radius:0 6px 6px 0;margin-bottom:10px;font-style:italic;line-height:1.5}',
|
||||
'.fb-popup textarea{width:100%;min-height:70px;padding:10px 12px;border:1px solid var(--fb-border);border-radius:8px;font-size:14px;font-family:var(--fb-font);resize:vertical;outline:none;margin-bottom:10px;color:var(--fb-fg)}',
|
||||
'.fb-popup textarea:focus{border-color:var(--fb-accent)}',
|
||||
'.fb-popup textarea::placeholder{color:var(--fb-muted-fg)}',
|
||||
'.fb-popup-pri{display:flex;gap:6px;margin-bottom:10px}',
|
||||
'.fb-popup-pri button{flex:1;padding:7px 8px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;border:2px solid transparent;transition:all .15s;font-family:var(--fb-font)}',
|
||||
'.fb-popup-actions{display:flex;gap:8px;justify-content:flex-end}',
|
||||
'.fb-popup-actions button{padding:8px 18px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;transition:all .15s;font-family:var(--fb-font)}',
|
||||
'.fb-popup-actions .cancel{background:none;border:1px solid var(--fb-border);color:var(--fb-muted-fg)}',
|
||||
'.fb-popup-actions .cancel:hover{background:var(--fb-muted);color:var(--fb-fg)}',
|
||||
'.fb-popup-actions .submit{border:none;color:#fff}',
|
||||
|
||||
/* Name dialog */
|
||||
'.fb-name-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:100001}',
|
||||
'.fb-name-box{background:var(--fb-bg);border-radius:12px;padding:28px;width:360px;box-shadow:0 20px 40px rgba(0,0,0,0.15)}',
|
||||
'.fb-name-box h2{font-size:18px;font-weight:700;margin:0 0 6px;color:var(--fb-fg)}',
|
||||
'.fb-name-box p{font-size:14px;color:var(--fb-muted-fg);margin:0 0 16px}',
|
||||
'.fb-name-box input{width:100%;padding:10px 14px;border:1px solid var(--fb-border);border-radius:8px;font-size:15px;outline:none;margin-bottom:12px;font-family:var(--fb-font);color:var(--fb-fg)}',
|
||||
'.fb-name-box input:focus{border-color:var(--fb-accent)}',
|
||||
'.fb-name-box input::placeholder{color:var(--fb-muted-fg)}',
|
||||
'.fb-name-box button{width:100%;padding:10px;background:var(--fb-primary);color:var(--fb-primary-fg);border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:var(--fb-font)}',
|
||||
'.fb-name-box button:disabled{opacity:.4;cursor:default}',
|
||||
|
||||
/* Inline name edit */
|
||||
'.fb-name-input{width:100%;padding:4px 8px;border:1px solid var(--fb-border);border-radius:4px;font-size:12px;outline:none;font-family:var(--fb-font);color:var(--fb-fg)}',
|
||||
'.fb-name-input:focus{border-color:var(--fb-accent)}',
|
||||
|
||||
/* Highlights */
|
||||
'.fb-highlight{padding:1px 0;cursor:pointer;transition:background .15s}',
|
||||
'.fb-highlight-must{background:rgba(239,68,68,0.15);border-bottom:2px solid #ef4444}',
|
||||
'.fb-highlight-must:hover{background:rgba(239,68,68,0.25)}',
|
||||
'.fb-highlight-better{background:rgba(245,158,11,0.15);border-bottom:2px solid #f59e0b}',
|
||||
'.fb-highlight-better:hover{background:rgba(245,158,11,0.25)}',
|
||||
'.fb-highlight-want{background:rgba(34,197,94,0.15);border-bottom:2px solid #22c55e}',
|
||||
'.fb-highlight-want:hover{background:rgba(34,197,94,0.25)}',
|
||||
|
||||
'@keyframes fb-pulse{0%,100%{opacity:1}50%{opacity:0.4}}',
|
||||
'.fb-highlight.fb-pulse{animation:fb-pulse 1s ease-in-out infinite}',
|
||||
'.fb-card.fb-focused{box-shadow:0 0 0 2px var(--fb-accent);background:rgba(59,130,246,0.04)}',
|
||||
|
||||
'#fb-sidebar svg,#fb-toggle svg,.fb-popup svg{pointer-events:none}',
|
||||
].join('\n');
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// ===== DOM =====
|
||||
function el(tag, attrs, children) {
|
||||
var e = document.createElement(tag);
|
||||
if (attrs) Object.keys(attrs).forEach(function (k) {
|
||||
if (k === 'className') e.className = attrs[k];
|
||||
else if (k === 'innerHTML') e.innerHTML = attrs[k];
|
||||
else if (k.startsWith('on')) e.addEventListener(k.substring(2).toLowerCase(), attrs[k]);
|
||||
else e.setAttribute(k, attrs[k]);
|
||||
});
|
||||
if (children) {
|
||||
if (typeof children === 'string') e.textContent = children;
|
||||
else if (Array.isArray(children)) children.forEach(function (c) { if (c) e.appendChild(c); });
|
||||
else e.appendChild(children);
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
// ===== Render =====
|
||||
function render() {
|
||||
renderToggle();
|
||||
renderSidebar();
|
||||
renderPopup();
|
||||
renderNameDialog();
|
||||
}
|
||||
|
||||
function renderToggle() {
|
||||
var btn = document.getElementById('fb-toggle');
|
||||
if (!btn) {
|
||||
btn = el('button', { id: 'fb-toggle', onClick: toggleSidebar });
|
||||
document.body.appendChild(btn);
|
||||
}
|
||||
var unresolvedCount = state.comments.filter(function (c) { return !c.parentId && !c.resolved; }).length;
|
||||
var h = '<span class="fb-toggle-icon">' + icon('panelRight', 16);
|
||||
if (unresolvedCount > 0) h += '<span class="fb-badge">' + unresolvedCount + '</span>';
|
||||
h += '</span>';
|
||||
h += '<span class="fb-toggle-label">コメント</span>';
|
||||
btn.innerHTML = h;
|
||||
btn.style.display = state.sidebarOpen ? 'none' : '';
|
||||
}
|
||||
|
||||
function applySidebarWidth() {
|
||||
var sb = document.getElementById('fb-sidebar');
|
||||
if (!sb) return;
|
||||
var w = state.sidebarWidth;
|
||||
sb.style.width = w + 'px';
|
||||
sb.style.right = state.sidebarOpen ? '0px' : (-(w + 20)) + 'px';
|
||||
document.body.style.marginRight = state.sidebarOpen ? w + 'px' : '';
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
state.sidebarOpen = !state.sidebarOpen;
|
||||
var btn = document.getElementById('fb-toggle');
|
||||
if (btn) btn.style.display = state.sidebarOpen ? 'none' : '';
|
||||
document.body.style.transition = 'margin-right 0.3s ease';
|
||||
applySidebarWidth();
|
||||
}
|
||||
|
||||
function topLevel() { return state.comments.filter(function (c) { return !c.parentId; }); }
|
||||
function getReplies(id) { return state.comments.filter(function (c) { return c.parentId === id; }).sort(function (a, b) { return a.timestamp - b.timestamp; }); }
|
||||
|
||||
function filtered() {
|
||||
var tl = topLevel();
|
||||
if (state.filter === 'resolved') return tl.filter(function (c) { return c.resolved; });
|
||||
if (state.filter === 'all') return tl;
|
||||
return tl.filter(function (c) { return !c.resolved; });
|
||||
}
|
||||
|
||||
function renderSidebar() {
|
||||
var sb = document.getElementById('fb-sidebar');
|
||||
var isNew = !sb;
|
||||
if (isNew) {
|
||||
sb = el('div', { id: 'fb-sidebar' });
|
||||
document.body.appendChild(sb);
|
||||
sb.addEventListener('mouseover', function (e) {
|
||||
var card = e.target.closest('.fb-card');
|
||||
if (!card) return;
|
||||
var mark = document.querySelector('.fb-highlight[data-comment-id="' + card.dataset.id + '"]');
|
||||
if (mark) mark.classList.add('fb-pulse');
|
||||
});
|
||||
sb.addEventListener('mouseout', function (e) {
|
||||
var card = e.target.closest('.fb-card');
|
||||
if (!card) return;
|
||||
if (e.relatedTarget && card.contains(e.relatedTarget)) return;
|
||||
var mark = document.querySelector('.fb-highlight[data-comment-id="' + card.dataset.id + '"]');
|
||||
if (mark) mark.classList.remove('fb-pulse');
|
||||
});
|
||||
}
|
||||
applySidebarWidth();
|
||||
|
||||
var tl = topLevel();
|
||||
var counts = {
|
||||
unresolved: tl.filter(function (c) { return !c.resolved; }).length,
|
||||
resolved: tl.filter(function (c) { return c.resolved; }).length,
|
||||
all: tl.length,
|
||||
};
|
||||
|
||||
var html = '<div class="fb-resize-handle"></div>';
|
||||
|
||||
// Header
|
||||
html += '<div class="fb-header">';
|
||||
html += '<div class="fb-header-left"><span class="fb-header-title">コメント</span><span class="fb-header-count">' + counts.all + '</span></div>';
|
||||
html += '<div class="fb-header-actions">';
|
||||
html += '<button class="fb-hdr-btn" data-action="close" title="閉じる">' + icon('x', 18) + '</button>';
|
||||
html += '</div></div>';
|
||||
|
||||
// User
|
||||
if (state.username) {
|
||||
if (state.editingName) {
|
||||
html += '<div class="fb-user-row"><input class="fb-name-input" value="' + esc(state.nameInput) + '" data-action="name-input" autofocus></div>';
|
||||
} else {
|
||||
html += '<div class="fb-user-row" data-action="edit-name">' + icon('user', 14) + esc(state.username) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Filters
|
||||
html += '<div class="fb-filters">';
|
||||
['unresolved', 'resolved', 'all'].forEach(function (f) {
|
||||
var label = f === 'unresolved' ? '未解決' : f === 'resolved' ? '解決済' : 'すべて';
|
||||
html += '<button class="fb-filter' + (state.filter === f ? ' active' : '') + '" data-filter="' + f + '">' + label + '<span class="cnt">' + counts[f] + '</span></button>';
|
||||
});
|
||||
html += '</div>';
|
||||
|
||||
// List
|
||||
var items = filtered().sort(function (a, b) { return b.timestamp - a.timestamp; });
|
||||
html += '<div class="fb-list">';
|
||||
if (items.length === 0) {
|
||||
html += '<div class="fb-empty">' + icon('message', 40) + 'コメントはまだありません<br><span style="font-size:11px">テキストを選択してコメントを追加</span></div>';
|
||||
} else {
|
||||
items.forEach(function (c) { html += renderCard(c); });
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
sb.innerHTML = html;
|
||||
if (isNew) bindSidebarEvents(sb);
|
||||
}
|
||||
|
||||
function renderCard(c) {
|
||||
var isOwn = c.author === state.username;
|
||||
var pc = PRIORITY_COLORS[c.priority] || PRIORITY_COLORS.want;
|
||||
|
||||
var h = '<div class="fb-card' + (c.resolved ? ' resolved' : '') + '" style="border-left-color:' + pc.bg + '" data-id="' + c.id + '">';
|
||||
|
||||
// Header
|
||||
h += '<div class="fb-card-head"><div class="fb-card-head-left">';
|
||||
h += '<div class="fb-avatar">' + esc(c.author.charAt(0)) + '</div>';
|
||||
h += '<span class="fb-author">' + esc(c.author) + '</span>';
|
||||
h += '<span class="fb-time">' + fmtTime(c.timestamp) + '</span>';
|
||||
if (c.resolved) h += '<span class="fb-resolved-mark">' + icon('check', 12) + ' 解決済</span>';
|
||||
h += '</div>';
|
||||
h += '<span class="fb-badge-p' + (isOwn ? ' own' : '') + '" style="background:' + pc.bg + '" data-action="cycle" data-id="' + c.id + '">' + esc(c.priority.charAt(0).toUpperCase() + c.priority.slice(1)) + '</span>';
|
||||
h += '</div>';
|
||||
|
||||
// Quote
|
||||
if (c.quote) {
|
||||
var q = c.quote.length > 100 ? c.quote.substring(0, 100) + '...' : c.quote;
|
||||
h += '<div class="fb-quote" style="border-left-color:' + pc.bg + '" data-action="scroll-quote" data-id="' + c.id + '">' + esc(q) + '</div>';
|
||||
}
|
||||
|
||||
// Body / Edit
|
||||
if (state.editingId === c.id) {
|
||||
h += '<div class="fb-edit-area">';
|
||||
h += '<div class="fb-edit-pri">';
|
||||
['must', 'better', 'want'].forEach(function (p) {
|
||||
var sel = state.editPriority === p;
|
||||
var pc2 = PRIORITY_COLORS[p];
|
||||
h += '<button data-action="set-edit-pri" data-pri="' + p + '" style="' + (sel ? 'background:' + pc2.bg + ';color:#fff;border-color:' + pc2.bg : '') + '">' + p.charAt(0).toUpperCase() + p.slice(1) + '</button>';
|
||||
});
|
||||
h += '</div>';
|
||||
h += '<textarea data-action="edit-textarea">' + esc(state.editContent) + '</textarea>';
|
||||
h += '<div class="fb-edit-btns"><button data-action="cancel-edit">キャンセル</button><button class="save" data-action="save-edit" data-id="' + c.id + '">保存</button></div>';
|
||||
h += '</div>';
|
||||
} else {
|
||||
h += '<div class="fb-body">' + esc(c.content) + '</div>';
|
||||
}
|
||||
|
||||
// Actions
|
||||
if (state.editingId !== c.id) {
|
||||
h += '<div class="fb-actions">';
|
||||
h += '<button class="fb-act" data-action="reply" data-id="' + c.id + '">' + icon('message', 12) + '返信</button>';
|
||||
h += '<button class="fb-act res" data-action="resolve" data-id="' + c.id + '">' + (c.resolved ? icon('rotateCcw', 12) : icon('check', 12)) + (c.resolved ? '戻す' : '解決') + '</button>';
|
||||
if (isOwn) h += '<button class="fb-act" data-action="edit" data-id="' + c.id + '">' + icon('pencil', 12) + '編集</button>';
|
||||
if (isOwn) h += '<button class="fb-act del" data-action="delete" data-id="' + c.id + '">' + icon('trash', 12) + '削除</button>';
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
// Replies
|
||||
var replies = getReplies(c.id);
|
||||
if (replies.length > 0) {
|
||||
h += '<div class="fb-replies">';
|
||||
replies.forEach(function (r) {
|
||||
var isOwnReply = r.author === state.username;
|
||||
h += '<div class="fb-reply-item"><div class="fb-reply-avatar">' + esc(r.author.charAt(0)) + '</div><div style="flex:1;min-width:0"><div class="fb-reply-meta"><strong>' + esc(r.author) + '</strong> · ' + fmtTime(r.timestamp) + '</div>';
|
||||
if (state.editingId === r.id) {
|
||||
h += '<div class="fb-edit-area"><textarea data-action="edit-textarea">' + esc(state.editContent) + '</textarea><div class="fb-edit-btns"><button data-action="cancel-edit">キャンセル</button><button class="save" data-action="save-edit" data-id="' + r.id + '">保存</button></div></div>';
|
||||
} else {
|
||||
h += '<div class="fb-reply-text">' + esc(r.content) + '</div>';
|
||||
h += '<div class="fb-actions" style="margin-top:4px">';
|
||||
h += '<button class="fb-act" data-action="reply" data-id="' + c.id + '">' + icon('message', 12) + '返信</button>';
|
||||
if (isOwnReply) h += '<button class="fb-act" data-action="edit" data-id="' + r.id + '">' + icon('pencil', 12) + '編集</button>';
|
||||
if (isOwnReply) h += '<button class="fb-act del" data-action="delete-reply" data-id="' + r.id + '">' + icon('trash', 12) + '削除</button>';
|
||||
h += '</div>';
|
||||
}
|
||||
h += '</div></div>';
|
||||
});
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
// Reply input
|
||||
if (state.replyingTo === c.id) {
|
||||
h += '<div class="fb-reply-input"><textarea placeholder="返信を入力..." data-action="reply-textarea">' + esc(state.replyText) + '</textarea><button data-action="submit-reply" data-id="' + c.id + '"' + (state.replyText.trim() ? '' : ' disabled') + '>送信</button></div>';
|
||||
}
|
||||
|
||||
h += '</div>';
|
||||
return h;
|
||||
}
|
||||
|
||||
function bindSidebarEvents(sb) {
|
||||
sb.addEventListener('click', function (e) {
|
||||
var t = e.target.closest('[data-action]');
|
||||
if (t) {
|
||||
var action = t.dataset.action;
|
||||
var id = t.dataset.id;
|
||||
|
||||
if (action === 'close') { toggleSidebar(); }
|
||||
else if (action === 'edit-name') { state.editingName = true; state.nameInput = state.username; render(); }
|
||||
else if (action === 'cycle' && id) { cyclePriority(id); }
|
||||
else if (action === 'scroll-quote' && id) { scrollToQuote(id); }
|
||||
else if (action === 'reply' && id) { state.replyingTo = state.replyingTo === id ? null : id; state.replyText = ''; render(); }
|
||||
else if (action === 'resolve' && id) { resolveComment(id); }
|
||||
else if (action === 'edit' && id) { var c = state.comments.find(function (x) { return x.id === id; }); if (c) { state.editingId = id; state.editContent = c.content; state.editPriority = c.priority; render(); } }
|
||||
else if (action === 'delete' && id) { deleteComment(id); }
|
||||
else if (action === 'delete-reply' && id) { deleteReply(id); }
|
||||
else if (action === 'cancel-edit') { state.editingId = null; render(); }
|
||||
else if (action === 'save-edit' && id) { saveEdit(id); }
|
||||
else if (action === 'submit-reply' && id) { submitReply(id); }
|
||||
else if (action === 'set-edit-pri') { state.editPriority = t.dataset.pri; render(); }
|
||||
return;
|
||||
}
|
||||
var filterBtn = e.target.closest('[data-filter]');
|
||||
if (filterBtn) { state.filter = filterBtn.dataset.filter; render(); return; }
|
||||
|
||||
var card = e.target.closest('.fb-card');
|
||||
if (card && card.dataset.id) {
|
||||
scrollToQuote(card.dataset.id);
|
||||
}
|
||||
});
|
||||
|
||||
sb.addEventListener('mousedown', function (e) {
|
||||
if (!e.target.closest('.fb-resize-handle')) return;
|
||||
e.preventDefault();
|
||||
var startX = e.clientX;
|
||||
var startWidth = state.sidebarWidth;
|
||||
var handle = e.target.closest('.fb-resize-handle');
|
||||
document.body.classList.add('fb-resizing');
|
||||
handle.classList.add('active');
|
||||
function onMove(ev) {
|
||||
state.sidebarWidth = Math.min(800, Math.max(300, startWidth + (startX - ev.clientX)));
|
||||
var s = document.getElementById('fb-sidebar');
|
||||
if (s) s.style.width = state.sidebarWidth + 'px';
|
||||
document.body.style.transition = 'none';
|
||||
document.body.style.marginRight = state.sidebarWidth + 'px';
|
||||
}
|
||||
function onUp() {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
document.body.classList.remove('fb-resizing');
|
||||
handle.classList.remove('active');
|
||||
document.body.style.transition = '';
|
||||
localStorage.setItem(SIDEBAR_WIDTH_KEY, String(state.sidebarWidth));
|
||||
}
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
});
|
||||
|
||||
sb.addEventListener('input', function (e) {
|
||||
var t = e.target;
|
||||
if (t.dataset.action === 'name-input') { state.nameInput = t.value; }
|
||||
else if (t.dataset.action === 'edit-textarea') { state.editContent = t.value; }
|
||||
else if (t.dataset.action === 'reply-textarea') { state.replyText = t.value; var btn = sb.querySelector('[data-action="submit-reply"]'); if (btn) btn.disabled = !state.replyText.trim(); }
|
||||
});
|
||||
|
||||
sb.addEventListener('keydown', function (e) {
|
||||
var t = e.target;
|
||||
if (t.dataset.action === 'name-input') {
|
||||
if (e.key === 'Enter') { finishNameEdit(); }
|
||||
else if (e.key === 'Escape') { state.editingName = false; render(); }
|
||||
}
|
||||
if (t.dataset.action === 'reply-textarea' && (e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
var id = sb.querySelector('[data-action="submit-reply"]')?.dataset.id;
|
||||
if (id) submitReply(id);
|
||||
}
|
||||
});
|
||||
|
||||
sb.addEventListener('blur', function (e) {
|
||||
if (e.target.dataset?.action === 'name-input') { finishNameEdit(); }
|
||||
}, true);
|
||||
|
||||
}
|
||||
|
||||
// ===== Popup =====
|
||||
function renderPopup() {
|
||||
var popup = document.getElementById('fb-popup');
|
||||
var isNew = !popup;
|
||||
if (isNew) {
|
||||
popup = el('div', { id: 'fb-popup', className: 'fb-popup' });
|
||||
document.body.appendChild(popup);
|
||||
popup.addEventListener('click', function (e) {
|
||||
var t = e.target.closest('[data-action]');
|
||||
if (!t) return;
|
||||
if (t.dataset.action === 'cancel-popup') { closePopup(); }
|
||||
else if (t.dataset.action === 'set-popup-pri') { state.popupPriority = t.dataset.pri; render(); }
|
||||
else if (t.dataset.action === 'submit-popup') { submitComment(state.popupPriority); }
|
||||
});
|
||||
popup.addEventListener('input', function (e) {
|
||||
if (e.target.dataset.action === 'popup-textarea') state.popupContent = e.target.value;
|
||||
});
|
||||
popup.addEventListener('keydown', function (e) {
|
||||
if (e.target.dataset.action === 'popup-textarea' && (e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
submitComment(state.popupPriority);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!state.selectedRect) { popup.classList.remove('show'); return; }
|
||||
popup.classList.add('show');
|
||||
|
||||
var q = state.selectedText.length > 120 ? state.selectedText.substring(0, 120) + '...' : state.selectedText;
|
||||
var selPc = PRIORITY_COLORS[state.popupPriority];
|
||||
var h = '<div class="fb-popup-head"><span>コメントを追加</span></div>';
|
||||
h += '<div class="fb-popup-quote">' + esc(q) + '</div>';
|
||||
h += '<textarea placeholder="フィードバックを入力..." data-action="popup-textarea">' + esc(state.popupContent) + '</textarea>';
|
||||
h += '<div class="fb-popup-pri">';
|
||||
['must', 'better', 'want'].forEach(function (p) {
|
||||
var pc = PRIORITY_COLORS[p];
|
||||
var sel = state.popupPriority === p;
|
||||
var style = sel
|
||||
? 'background:' + pc.light + ';color:' + pc.bg + ';border-color:' + pc.bg
|
||||
: 'background:var(--fb-bg);color:var(--fb-muted-fg);border-color:var(--fb-border)';
|
||||
h += '<button style="' + style + '" data-action="set-popup-pri" data-pri="' + p + '">' + p.charAt(0).toUpperCase() + p.slice(1) + '</button>';
|
||||
});
|
||||
h += '</div>';
|
||||
h += '<div class="fb-popup-actions">';
|
||||
h += '<button class="cancel" data-action="cancel-popup">キャンセル</button>';
|
||||
h += '<button class="submit" style="background:' + selPc.bg + '" data-action="submit-popup">送信</button>';
|
||||
h += '</div>';
|
||||
popup.innerHTML = h;
|
||||
|
||||
var rect = state.selectedRect;
|
||||
var pw = 400, ph = 300, m = 12;
|
||||
var top = rect.bottom + m;
|
||||
if (top + ph > window.innerHeight) top = rect.top - ph - m;
|
||||
if (top < m) top = Math.max(m, (window.innerHeight - ph) / 2);
|
||||
var availW = state.sidebarOpen ? window.innerWidth - state.sidebarWidth : window.innerWidth;
|
||||
var left = Math.max(m, Math.min(rect.left, availW - pw - m));
|
||||
popup.style.top = top + 'px';
|
||||
popup.style.left = left + 'px';
|
||||
}
|
||||
|
||||
function closePopup() {
|
||||
state.selectedText = '';
|
||||
state.selectedRect = null;
|
||||
state.popupContent = '';
|
||||
state.popupPriority = 'must';
|
||||
render();
|
||||
}
|
||||
|
||||
function renderNameDialog() {
|
||||
var existing = document.getElementById('fb-name-overlay');
|
||||
if (state.username) { if (existing) existing.remove(); return; }
|
||||
if (existing) return;
|
||||
var overlay = el('div', { id: 'fb-name-overlay', className: 'fb-name-overlay' });
|
||||
overlay.innerHTML = '<div class="fb-name-box"><h2>ようこそ</h2><p>コメントに表示される名前を入力してください。</p><input placeholder="例: 田中太郎" id="fb-name-field"><button id="fb-name-submit" disabled>始める</button></div>';
|
||||
document.body.appendChild(overlay);
|
||||
var input = document.getElementById('fb-name-field');
|
||||
var btn = document.getElementById('fb-name-submit');
|
||||
input.addEventListener('input', function () { btn.disabled = !input.value.trim(); });
|
||||
input.addEventListener('keydown', function (e) { if (e.key === 'Enter' && input.value.trim()) { setName(input.value.trim()); } });
|
||||
btn.addEventListener('click', function () { if (input.value.trim()) setName(input.value.trim()); });
|
||||
setTimeout(function () { input.focus(); }, 100);
|
||||
}
|
||||
|
||||
function setName(name) {
|
||||
state.username = name;
|
||||
localStorage.setItem(USERNAME_KEY, name);
|
||||
var overlay = document.getElementById('fb-name-overlay');
|
||||
if (overlay) overlay.remove();
|
||||
render();
|
||||
}
|
||||
|
||||
function finishNameEdit() {
|
||||
if (state.nameInput.trim() && state.nameInput.trim() !== state.username) {
|
||||
var oldName = state.username;
|
||||
state.username = state.nameInput.trim();
|
||||
localStorage.setItem(USERNAME_KEY, state.username);
|
||||
api('PUT', { id: '_rename', author: state.username, oldAuthor: oldName, projectSlug: slug }).then(loadComments);
|
||||
}
|
||||
state.editingName = false;
|
||||
render();
|
||||
}
|
||||
|
||||
// ===== Actions =====
|
||||
function submitComment(priority) {
|
||||
if (!state.username || !state.selectedText) return;
|
||||
var c = {
|
||||
id: genId(), author: state.username, type: 'comment',
|
||||
quote: state.selectedText, quoteContext: state.selectedQuoteContext,
|
||||
content: state.popupContent.trim(),
|
||||
priority: priority, parentId: null, pageUrl: window.location.href,
|
||||
projectSlug: slug, timestamp: Date.now(),
|
||||
};
|
||||
state.comments.push(c);
|
||||
closePopup();
|
||||
applyHighlights();
|
||||
api('POST', c).then(loadComments);
|
||||
}
|
||||
|
||||
function resolveComment(id) {
|
||||
var c = state.comments.find(function (x) { return x.id === id; });
|
||||
if (!c) return;
|
||||
var now = !c.resolved;
|
||||
c.resolved = now;
|
||||
c.resolvedBy = now ? state.username : null;
|
||||
c.resolvedAt = now ? Date.now() : null;
|
||||
render(); applyHighlights();
|
||||
api('PUT', { id: id, resolved: now, resolvedBy: c.resolvedBy, resolvedAt: c.resolvedAt });
|
||||
}
|
||||
|
||||
function cyclePriority(id) {
|
||||
var c = state.comments.find(function (x) { return x.id === id; });
|
||||
if (!c || c.author !== state.username) return;
|
||||
c.priority = PRIORITY_CYCLE[c.priority] || 'must';
|
||||
render(); applyHighlights();
|
||||
api('PUT', { id: id, priority: c.priority });
|
||||
}
|
||||
|
||||
function deleteComment(id) {
|
||||
state.comments = state.comments.filter(function (c) { return c.id !== id && c.parentId !== id; });
|
||||
render(); applyHighlights();
|
||||
api('DELETE', { id: id });
|
||||
}
|
||||
|
||||
function deleteReply(id) {
|
||||
state.comments = state.comments.filter(function (c) { return c.id !== id; });
|
||||
render();
|
||||
api('DELETE', { id: id });
|
||||
}
|
||||
|
||||
function saveEdit(id) {
|
||||
var c = state.comments.find(function (x) { return x.id === id; });
|
||||
if (!c) return;
|
||||
c.content = state.editContent;
|
||||
c.priority = state.editPriority;
|
||||
state.editingId = null;
|
||||
render(); applyHighlights();
|
||||
api('PUT', { id: id, content: c.content, priority: c.priority });
|
||||
}
|
||||
|
||||
function submitReply(parentId) {
|
||||
if (!state.replyText.trim() || !state.username) return;
|
||||
var r = {
|
||||
id: genId(), author: state.username, type: 'comment',
|
||||
quote: '', quoteContext: { beforeText: '', afterText: '' },
|
||||
content: state.replyText.trim(), priority: 'want',
|
||||
parentId: parentId, pageUrl: window.location.href,
|
||||
projectSlug: slug, timestamp: Date.now(),
|
||||
};
|
||||
state.comments.push(r);
|
||||
state.replyingTo = null;
|
||||
state.replyText = '';
|
||||
render();
|
||||
api('POST', r);
|
||||
}
|
||||
|
||||
// ===== Text Selection =====
|
||||
function setupTextSelection() {
|
||||
document.addEventListener('mouseup', function (e) {
|
||||
if (e.target.closest('#fb-sidebar,#fb-toggle,#fb-popup')) return;
|
||||
var sel = window.getSelection();
|
||||
var text = sel?.toString().trim();
|
||||
if (!text || !sel || sel.rangeCount === 0 || !state.username) return;
|
||||
var range = sel.getRangeAt(0);
|
||||
state.selectedText = text.replace(/[\s\u00A0]+/g, ' ').substring(0, 200);
|
||||
state.selectedRect = range.getBoundingClientRect();
|
||||
state.selectedQuoteContext = getQuoteContext(range);
|
||||
state.popupContent = '';
|
||||
render();
|
||||
});
|
||||
document.addEventListener('mousedown', function (e) {
|
||||
if (e.target.closest('#fb-popup')) return;
|
||||
if (state.selectedRect) closePopup();
|
||||
});
|
||||
}
|
||||
|
||||
function getQuoteContext(range) {
|
||||
var before = '', after = '';
|
||||
try {
|
||||
var br = document.createRange();
|
||||
br.setStart(document.body, 0);
|
||||
br.setEnd(range.startContainer, range.startOffset);
|
||||
before = br.toString().slice(-50).replace(/[\s\u00A0]+/g, ' ').trim();
|
||||
var ar = document.createRange();
|
||||
ar.setStart(range.endContainer, range.endOffset);
|
||||
ar.setEnd(document.body, document.body.childNodes.length);
|
||||
after = ar.toString().slice(0, 50).replace(/[\s\u00A0]+/g, ' ').trim();
|
||||
} catch (e) { /* ignore */ }
|
||||
return { beforeText: before, afterText: after };
|
||||
}
|
||||
|
||||
// ===== Highlights =====
|
||||
function collectTextNodes() {
|
||||
var nodes = [];
|
||||
var tw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: function (n) {
|
||||
var p = n.parentElement;
|
||||
if (!p) return NodeFilter.FILTER_REJECT;
|
||||
if (p.tagName === 'SCRIPT' || p.tagName === 'STYLE') return NodeFilter.FILTER_REJECT;
|
||||
if (p.closest('#fb-sidebar,#fb-toggle,#fb-popup,.fb-highlight')) return NodeFilter.FILTER_REJECT;
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
}
|
||||
});
|
||||
var n;
|
||||
while ((n = tw.nextNode())) nodes.push(n);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function mapNormToOrig(orig, normStart, normEnd) {
|
||||
var ws = /[\s\u00A0]+/g;
|
||||
var normalized = orig.replace(ws, ' ');
|
||||
if (normStart >= normalized.length) return null;
|
||||
var origIdx = 0, normIdx = 0;
|
||||
var origStart = -1, origEnd = -1;
|
||||
while (origIdx < orig.length) {
|
||||
if (normIdx === normStart && origStart === -1) origStart = origIdx;
|
||||
if (normIdx === normEnd) { origEnd = origIdx; break; }
|
||||
if (normIdx < normalized.length && orig[origIdx] === normalized[normIdx]) {
|
||||
origIdx++; normIdx++;
|
||||
} else {
|
||||
origIdx++;
|
||||
}
|
||||
}
|
||||
if (origEnd === -1 && normIdx >= normEnd) origEnd = origIdx;
|
||||
if (origStart === -1 || origEnd === -1 || origStart >= origEnd) return null;
|
||||
return [origStart, origEnd];
|
||||
}
|
||||
|
||||
function wrapTextRange(node, start, end, comment) {
|
||||
var orig = node.textContent;
|
||||
var before = document.createTextNode(orig.substring(0, start));
|
||||
var mark = document.createElement('mark');
|
||||
mark.className = 'fb-highlight fb-highlight-' + comment.priority;
|
||||
mark.dataset.commentId = comment.id;
|
||||
mark.textContent = orig.substring(start, end);
|
||||
(function (id) {
|
||||
mark.addEventListener('click', function () { scrollToCard(id); });
|
||||
})(comment.id);
|
||||
var after = document.createTextNode(orig.substring(end));
|
||||
node.parentNode.insertBefore(before, node);
|
||||
node.parentNode.insertBefore(mark, node);
|
||||
node.parentNode.insertBefore(after, node);
|
||||
node.parentNode.removeChild(node);
|
||||
}
|
||||
|
||||
function applyHighlights() {
|
||||
document.querySelectorAll('.fb-highlight').forEach(function (el) {
|
||||
var t = document.createTextNode(el.textContent);
|
||||
el.parentNode.replaceChild(t, el);
|
||||
});
|
||||
document.body.normalize();
|
||||
|
||||
state.comments.filter(function (c) { return !c.parentId && !c.resolved && c.quote && c.quote.length >= 2; }).forEach(function (c) {
|
||||
var search = c.quote.replace(/[\s\u00A0]+/g, ' ').trim();
|
||||
var textNodes = collectTextNodes();
|
||||
var found = false;
|
||||
|
||||
for (var i = 0; i < textNodes.length; i++) {
|
||||
var node = textNodes[i];
|
||||
var orig = node.textContent;
|
||||
|
||||
var di = orig.indexOf(search);
|
||||
if (di !== -1) {
|
||||
try { wrapTextRange(node, di, di + search.length, c); found = true; } catch (e) {}
|
||||
break;
|
||||
}
|
||||
|
||||
var norm = orig.replace(/[\s\u00A0]+/g, ' ');
|
||||
var ni = norm.indexOf(search);
|
||||
if (ni === -1) continue;
|
||||
var range = mapNormToOrig(orig, ni, ni + search.length);
|
||||
if (range) {
|
||||
try { wrapTextRange(node, range[0], range[1], c); found = true; } catch (e) {}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
var concat = '';
|
||||
var nodeMap = [];
|
||||
for (var j = 0; j < textNodes.length; j++) {
|
||||
var s = concat.length;
|
||||
concat += textNodes[j].textContent;
|
||||
nodeMap.push({ node: textNodes[j], start: s, end: concat.length });
|
||||
}
|
||||
var concatNorm = concat.replace(/[\s\u00A0]+/g, ' ');
|
||||
var ci = concatNorm.indexOf(search);
|
||||
if (ci !== -1) {
|
||||
var range = mapNormToOrig(concat, ci, ci + search.length);
|
||||
if (range) {
|
||||
var mStart = range[0], mEnd = range[1];
|
||||
for (var k = nodeMap.length - 1; k >= 0; k--) {
|
||||
var nm = nodeMap[k];
|
||||
if (nm.end <= mStart || nm.start >= mEnd) continue;
|
||||
var ls = Math.max(0, mStart - nm.start);
|
||||
var le = Math.min(nm.node.textContent.length, mEnd - nm.start);
|
||||
try { wrapTextRange(nm.node, ls, le, c); } catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var PRIORITY_FLASH = { must: 'rgba(239,68,68,0.4)', better: 'rgba(245,158,11,0.4)', want: 'rgba(34,197,94,0.4)' };
|
||||
|
||||
function scrollToQuote(id) {
|
||||
var mark = document.querySelector('.fb-highlight[data-comment-id="' + id + '"]');
|
||||
if (!mark) return;
|
||||
mark.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
var c = state.comments.find(function (x) { return x.id === id; });
|
||||
var flashColor = c ? (PRIORITY_FLASH[c.priority] || PRIORITY_FLASH.want) : PRIORITY_FLASH.want;
|
||||
var orig = mark.style.backgroundColor;
|
||||
mark.style.backgroundColor = flashColor;
|
||||
mark.style.transition = 'background-color 0.3s';
|
||||
setTimeout(function () { mark.style.backgroundColor = orig; }, 1500);
|
||||
}
|
||||
|
||||
function scrollToCard(id) {
|
||||
var wasClosed = !state.sidebarOpen;
|
||||
if (wasClosed) { toggleSidebar(); }
|
||||
setTimeout(function () {
|
||||
var card = document.querySelector('.fb-card[data-id="' + id + '"]');
|
||||
if (!card) return;
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
card.classList.add('fb-focused');
|
||||
setTimeout(function () { card.classList.remove('fb-focused'); }, 1500);
|
||||
}, wasClosed ? 350 : 0);
|
||||
}
|
||||
|
||||
// ===== Init =====
|
||||
function init() {
|
||||
injectStyles();
|
||||
render();
|
||||
setupTextSelection();
|
||||
loadComments();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
Reference in New Issue
Block a user