ミニ百科.html

<!DOCTYPE html>
<html lang="ja" class="scroll-smooth">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>ミニ百科 – シングルファイル版</title>
  <meta name="description" content="検索・カテゴリ・タグ・ブックマーク対応のシングルファイル百科事典。" />
  <link rel="preconnect" href="https://cdn.jsdelivr.net" />
  <!-- TailwindCSS (CDN) -->
  <script src="https://cdn.tailwindcss.com"></script>
  <!-- Font Awesome (icons) -->
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" integrity="sha512-5I0VnK5tQhJ0eZ5Ck1gC3b6h9fJ3k6l9FeI3K6J0q9JtO1Yw1l2Y7N5M6d2xQf8Q2F6mZ8l2s3A=" crossorigin="anonymous" referrerpolicy="no-referrer" />
  <!-- Favicon (inline SVG) -->
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Cpath fill='%234f46e5' d='M32 56c0-13.3 10.7-24 24-24h144c13.3 0 24 10.7 24 24v144c0 13.3-10.7 24-24 24H56c-13.3 0-24-10.7-24-24z'/%3E%3Cpath fill='white' d='M72 80h112v16H72zM72 112h80v16H72zM72 144h112v16H72zM72 176h96v16H72z'/%3E%3C/svg%3E" />
  <style>
    /* 追加の細かなスタイル */
    .line-clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
    .prose h2{scroll-margin-top:6rem}
    .toc a{display:block;padding:.25rem .5rem;border-radius:.5rem}
    .toc a.active{background:rgba(99,102,241,.12)}
  </style>
  <script>
    // ダークモード初期化
    (function(){
      const theme=localStorage.getItem('theme');
      if(theme==='dark'||(!theme&&window.matchMedia('(prefers-color-scheme: dark)').matches)){
        document.documentElement.classList.add('dark');
      }
    })();
  </script>
</head>
<body class="bg-slate-50 text-slate-800 dark:bg-slate-900 dark:text-slate-100 min-h-screen">
  <!-- Skip link -->
  <a href="#main" class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:bg-indigo-600 focus:text-white focus:px-3 focus:py-2 focus:rounded">本文へスキップ</a>

  <!-- Header -->
  <header class="sticky top-0 z-40 backdrop-blur border-b border-slate-200/60 dark:border-slate-700/60 bg-white/70 dark:bg-slate-900/70">
    <div class="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8 py-3 flex items-center gap-3">
      <button id="btnHome" class="shrink-0 px-2 py-1 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/40" title="ホーム">
        <i class="fa-solid fa-book-open text-indigo-600"></i>
      </button>
      <h1 class="text-lg sm:text-2xl font-bold tracking-tight">ミニ百科 <span class="text-indigo-600">Mini Encyclopedia</span></h1>
      <div class="ms-auto flex items-center gap-2">
        <button id="btnRandom" class="px-3 py-2 rounded-xl bg-indigo-600 text-white text-sm hover:opacity-90"><i class="fa-solid fa-shuffle me-1"></i>ランダム</button>
        <button id="btnBookmarks" class="px-3 py-2 rounded-xl bg-amber-500 text-white text-sm hover:opacity-90"><i class="fa-solid fa-star me-1"></i>ブックマーク</button>
        <button id="btnDark" class="px-3 py-2 rounded-xl bg-slate-800 text-white text-sm dark:bg-slate-700 hover:opacity-90" title="ダーク/ライト切替"><i class="fa-solid fa-moon"></i></button>
      </div>
    </div>
  </header>

  <!-- Toolbar -->
  <section class="border-b border-slate-200/60 dark:border-slate-700/60 bg-white/60 dark:bg-slate-900/60">
    <div class="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8 py-4 grid gap-3 sm:grid-cols-12 items-end">
      <div class="sm:col-span-6">
        <label for="search" class="block text-sm text-slate-600 dark:text-slate-300 mb-1">記事検索</label>
        <div class="relative">
          <input id="search" type="search" placeholder="キーワード(例: 富士山 / 恐竜 / インターネット)" class="w-full rounded-2xl border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800/80 px-4 py-2 pe-10 outline-none focus:ring-2 focus:ring-indigo-500" />
          <i class="fa-solid fa-magnifying-glass absolute right-3 top-1/2 -translate-y-1/2 text-slate-400"></i>
        </div>
      </div>
      <div class="sm:col-span-3">
        <label for="category" class="block text-sm text-slate-600 dark:text-slate-300 mb-1">カテゴリ</label>
        <select id="category" class="w-full rounded-2xl border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800/80 px-4 py-2 outline-none focus:ring-2 focus:ring-indigo-500">
          <option value="">すべて</option>
        </select>
      </div>
      <div class="sm:col-span-3">
        <label for="sort" class="block text-sm text-slate-600 dark:text-slate-300 mb-1">並び替え</label>
        <select id="sort" class="w-full rounded-2xl border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800/80 px-4 py-2 outline-none focus:ring-2 focus:ring-indigo-500">
          <option value="recent">更新が新しい順</option>
          <option value="title">タイトル順</option>
        </select>
      </div>
      <div class="sm:col-span-12" id="tagBar" aria-label="タグフィルタ" class="flex flex-wrap gap-2"></div>
    </div>
  </section>

  <main id="main" class="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8 py-6">
    <!-- Home / List View -->
    <section id="view-home" class="grid gap-6">
      <div class="flex items-center justify-between">
        <h2 class="text-xl sm:text-2xl font-semibold">記事一覧</h2>
        <div class="text-sm text-slate-500"><span id="resultCount">0</span> 件</div>
      </div>
      <div id="cards" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
        <!-- cards injected -->
      </div>
      <div class="flex items-center justify-center gap-2 pt-2" id="pager"></div>
    </section>

    <!-- Article View -->
    <section id="view-article" class="hidden lg:grid lg:grid-cols-12 gap-8">
      <aside class="lg:col-span-3 order-last lg:order-first">
        <div class="sticky top-[6.5rem] border border-slate-200 dark:border-slate-700 rounded-2xl p-4">
          <h3 class="font-semibold mb-2">目次</h3>
          <nav id="toc" class="toc text-sm space-y-1"></nav>
        </div>
      </aside>
      <article class="lg:col-span-9">
        <nav class="text-sm text-slate-500 mb-3" id="breadcrumb"></nav>
        <header class="mb-4">
          <h1 id="articleTitle" class="text-2xl sm:text-3xl font-bold tracking-tight"></h1>
          <div class="mt-2 flex flex-wrap items-center gap-2 text-sm text-slate-500" id="articleMeta"></div>
          <div class="mt-3 flex items-center gap-2">
            <button id="btnCopyLink" class="px-3 py-2 rounded-xl border border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-800"><i class="fa-solid fa-link me-1"></i>リンクをコピー</button>
            <button id="btnToggleBookmark" class="px-3 py-2 rounded-xl border border-amber-400 text-amber-600 hover:bg-amber-50"><i class="fa-regular fa-star me-1"></i>ブックマーク</button>
          </div>
        </header>
        <div id="articleContent" class="prose prose-slate dark:prose-invert max-w-none"></div>
        <section class="mt-8">
          <h3 class="font-semibold mb-2">関連タグ</h3>
          <div id="articleTags" class="flex flex-wrap gap-2"></div>
        </section>
      </article>
    </section>
  </main>

  <footer class="border-t border-slate-200 dark:border-slate-700 py-8">
    <div class="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8 text-sm text-slate-500 flex flex-wrap items-center gap-2">
      <span>© <span id="year"></span> ミニ百科</span>
      <span class="mx-1">•</span>
      <button id="btnExport" class="underline underline-offset-4">データを書き出す(JSON)</button>
      <span class="mx-1">•</span>
      <label class="cursor-pointer">JSON読み込み <input id="fileImport" type="file" accept="application/json" class="hidden" /></label>
    </div>
  </footer>

  <!-- Structured data placeholder (updated on article view) -->
  <script id="ldjson" type="application/ld+json">{}</script>

  <script>
  // ======================
  //  サンプル記事データ
  // ======================
  /**
   * 記事スキーマ:
   * id, slug, title, category, tags[], summary, updated(ISO), author, content(HTML)
   */
  const ARTICLES = [
    {
      id: 1,
      slug: 'fuji-san',
      title: '富士山',
      category: '地理',
      tags: ['日本','山','世界文化遺産'],
      summary: '日本の象徴ともいわれる成層火山。標高3,776mで日本最高峰。',
      updated: '2025-08-15',
      author: 'ミニ百科編集部',
      content: `
        <p>富士山は本州中部に位置する<span>成層火山</span>で、標高は3,776m。2013年に世界文化遺産に登録されました。古来より信仰の対象であり、芸術や文学にも多く登場します。</p>
        <h2 id="geo">地形と地質</h2>
        <p>富士山は何度もの噴火活動を経て現在の美しい円錐形を形成しました。火口は山頂部にあり、外輪としてお鉢巡りが知られています。</p>
        <h2 id="climb">登山と保全</h2>
        <p>一般的な登山シーズンは夏。登山道の混雑やゴミ問題、低温・高山病などのリスク対策が重要です。</p>
        <h2 id="culture">文化的意義</h2>
        <p>葛飾北斎の『富嶽三十六景』をはじめ、絵画や和歌に頻繁に詠まれ、日本の象徴として国際的にも広く知られています。</p>
      `
    },
    {
      id: 2,
      slug: 'internet-basics',
      title: 'インターネットの基礎',
      category: 'テクノロジー',
      tags: ['ネットワーク','Web','通信'],
      summary: '世界中のコンピュータを相互接続する情報ネットワークの総称。',
      updated: '2025-07-01',
      author: 'ミニ百科編集部',
      content: `
        <p>インターネットは標準化された<span>TCP/IP</span>により機器同士が通信する巨大なネットワークです。Web、メール、動画配信など多様なサービスの土台になっています。</p>
        <h2 id="protocols">主要プロトコル</h2>
        <p>HTTP/HTTPS、DNS、SMTP、FTPなどが代表的。セキュリティ確保には暗号化や認証が重要です。</p>
        <h2 id="web">Webの仕組み</h2>
        <p>ブラウザがURLを解決し、サーバからHTML/CSS/JS等のリソースを取得・表示します。</p>
        <h2 id="safety">安全な利用</h2>
        <p>二要素認証、ソフトウェア更新、フィッシング対策、強力なパスワード管理が基本です。</p>
      `
    },
    {
      id: 3,
      slug: 'dinosaurs',
      title: '恐竜',
      category: '生物',
      tags: ['古生物学','白亜紀','化石'],
      summary: '中生代に栄えた爬虫類のグループ。鳥類は恐竜の系統に含まれると考えられている。',
      updated: '2025-05-28',
      author: 'ミニ百科編集部',
      content: `
        <p>恐竜は約2億3000万年前に出現し、中生代に多様化しました。<span>鳥類</span>は恐竜の一系統とみなされます。</p>
        <h2 id="era">時代区分</h2>
        <p>三畳紀・ジュラ紀・白亜紀に区分され、各時代で特徴的な種が繁栄しました。</p>
        <h2 id="extinction">大量絶滅</h2>
        <p>約6600万年前の大量絶滅で多くが消滅。隕石衝突や火山活動などが要因と考えられています。</p>
      `
    },
    {
      id: 4,
      slug: 'ww2-overview',
      title: '第二次世界大戦(概説)',
      category: '歴史',
      tags: ['20世紀','戦争','国際関係'],
      summary: '1939年から1945年にかけて行われた世界規模の戦争。',
      updated: '2025-03-10',
      author: 'ミニ百科編集部',
      content: `
        <p>第二次世界大戦は多数の国が参戦した世界規模の戦争で、政治・経済・科学技術・社会に長期の影響を与えました。</p>
        <h2 id="fronts">主要戦線</h2>
        <p>ヨーロッパ、太平洋、北アフリカ、東部戦線など多くの戦域に分かれました。</p>
        <h2 id="aftermath">戦後の世界</h2>
        <p>国際連合の設立、冷戦構造の形成、国際秩序の再編などにつながりました。</p>
      `
    },
    {
      id: 5,
      slug: 'ai-basics',
      title: '人工知能の基礎',
      category: 'テクノロジー',
      tags: ['AI','機械学習','深層学習'],
      summary: '知的な処理をコンピュータで実現する研究分野と技術群。',
      updated: '2025-06-12',
      author: 'ミニ百科編集部',
      content: `
        <p>人工知能は探索・推論から機械学習・深層学習まで多様な手法を含みます。現代では大量データと計算資源により実世界応用が拡大。</p>
        <h2 id="ml">機械学習</h2>
        <p>教師あり・教師なし・強化学習などの枠組みがあり、予測や分類に用いられます。</p>
        <h2 id="dl">深層学習</h2>
        <p>多層ニューラルネットワークにより画像・音声・自然言語処理で高精度を実現。</p>
      `
    },
    {
      id: 6,
      slug: 'sakura',
      title: 'サクラ(桜)',
      category: '文化',
      tags: ['日本文化','植物','季節'],
      summary: '日本の春を象徴する花。花見は古くからの季節行事。',
      updated: '2025-04-02',
      author: 'ミニ百科編集部',
      content: `
        <p>桜はバラ科サクラ属の総称。品種が多く、花期は短いものの観賞価値が高いことで知られます。</p>
        <h2 id="hanami">花見の歴史</h2>
        <p>貴族文化から庶民に広がり、現在では地域の祭りや観光資源にもなっています。</p>
      `
    },
    {
      id: 7,
      slug: 'japan-history-outline',
      title: '日本史(概説)',
      category: '歴史',
      tags: ['古代','中世','近代'],
      summary: '古代から現代までの日本の歴史を大まかに概観する。',
      updated: '2025-01-20',
      author: 'ミニ百科編集部',
      content: `
        <p>日本史は縄文・弥生・古墳などの古代から、中世・近世、明治以降の近代・現代に至るまで連続する多様な変化の歴史です。</p>
        <h2 id="ancient">古代</h2>
        <p>稲作の普及、古代国家の形成、律令制の確立など。</p>
        <h2 id="modern">近代・現代</h2>
        <p>近代化、戦後復興、高度経済成長、少子高齢化と新たな課題。</p>
      `
    },
    {
      id: 8,
      slug: 'programming-intro',
      title: 'プログラミング入門',
      category: 'テクノロジー',
      tags: ['コード','アルゴリズム','学習'],
      summary: 'コンピュータに手順を伝えるための技術と考え方の総称。',
      updated: '2025-07-22',
      author: 'ミニ百科編集部',
      content: `
        <p>プログラミングは問題を分解し、再利用可能な手順として表現する作業です。変数、条件分岐、反復、関数などの基本を学ぶと応用が広がります。</p>
        <h2 id="lang">主な言語</h2>
        <p>Python、JavaScript、C#、C++ など用途に応じて選択されます。</p>
      `
    },
    {
      id: 9,
      slug: 'tea-ceremony',
      title: '茶道',
      category: '文化',
      tags: ['日本文化','礼法','芸道'],
      summary: '湯を沸かし茶を点て、客をもてなす総合芸術。',
      updated: '2025-05-03',
      author: 'ミニ百科編集部',
      content: `
        <p>茶道は道具、作法、空間、季節感などが一体となる総合芸術です。<span>和敬清寂</span>の精神が重視されます。</p>
        <h2 id="tools">道具</h2>
        <p>茶碗、茶筅、茶杓、釜、柄杓など。取り扱いには所作と配慮が求められます。</p>
      `
    },
    {
      id: 10,
      slug: 'solar-system',
      title: '太陽系',
      category: '天文学',
      tags: ['惑星','衛星','宇宙'],
      summary: '太陽とその周囲を公転する天体の集まり。',
      updated: '2025-06-30',
      author: 'ミニ百科編集部',
      content: `
        <p>太陽系は太陽を中心に、8つの惑星、準惑星、小惑星、彗星、塵やガスが重力で結びつくシステムです。</p>
        <h2 id="planets">惑星</h2>
        <p>水星・金星・地球・火星・木星・土星・天王星・海王星。各惑星は固有の特徴を持ちます。</p>
      `
    }
  ];

  // ================
  // ユーティリティ
  // ================
  const $ = (sel, root=document) => root.querySelector(sel);
  const $$ = (sel, root=document) => Array.from(root.querySelectorAll(sel));
  const fmtDate = iso => new Date(iso).toLocaleDateString('ja-JP', {year:'numeric', month:'short', day:'numeric'});
  const unique = arr => [...new Set(arr)];
  const slugToArticle = slug => ARTICLES.find(a=>a.slug===slug);
  const STORAGE = {
    bookmarks: 'mini_ency_bookmarks',
    history: 'mini_ency_history'
  };

  function getBookmarks(){
    try{return JSON.parse(localStorage.getItem(STORAGE.bookmarks)||'[]');}catch{ return []; }
  }
  function setBookmarks(list){ localStorage.setItem(STORAGE.bookmarks, JSON.stringify(unique(list))); }
  function isBookmarked(slug){ return getBookmarks().includes(slug); }
  function toggleBookmark(slug){
    const list=getBookmarks();
    if(list.includes(slug)) setBookmarks(list.filter(s=>s!==slug));
    else setBookmarks([...list, slug]);
  }
  function pushHistory(slug){
    try{
      const now = Date.now();
      const hist = JSON.parse(localStorage.getItem(STORAGE.history)||'[]');
      const filtered = hist.filter(h=>h.slug!==slug);
      filtered.unshift({slug, t: now});
      localStorage.setItem(STORAGE.history, JSON.stringify(filtered.slice(0,50)));
    }catch{}
  }

  // ================
  // 検索・フィルタ
  // ================
  let state = {
    q: '',
    category: '',
    tag: '',
    sort: 'recent',
    page: 1,
    perPage: 9
  };

  function normalize(str){ return (str||'').toString().toLowerCase(); }

  function filterArticles(){
    let list = ARTICLES.slice();
    if(state.q){
      const q = normalize(state.q);
      list = list.filter(a => normalize(a.title+" "+a.summary+" "+a.tags.join(' ')+" "+a.content.replace(/<[^>]+>/g,'')).includes(q));
    }
    if(state.category){ list = list.filter(a => a.category===state.category); }
    if(state.tag){ list = list.filter(a => a.tags.includes(state.tag)); }
    if(state.sort==='recent'){ list.sort((a,b)=> new Date(b.updated)-new Date(a.updated)); }
    if(state.sort==='title'){ list.sort((a,b)=> a.title.localeCompare(b.title,'ja')); }
    return list;
  }

  // =============
  //  一覧描画
  // =============
  function renderCategories(){
    const select = $('#category');
    const cats = unique(ARTICLES.map(a=>a.category)).sort((a,b)=>a.localeCompare(b,'ja'));
    select.innerHTML = '<option value="">すべて</option>' + cats.map(c=>`<option value="${c}">${c}</option>`).join('');
  }

  function renderTagsBar(){
    const bar = $('#tagBar');
    const tags = unique(ARTICLES.flatMap(a=>a.tags)).sort((a,b)=>a.localeCompare(b,'ja'));
    bar.innerHTML = '<div class="flex flex-wrap gap-2">' + tags.map(t=>
      `<button data-tag="${t}" class="tag-btn px-3 py-1 rounded-full border border-slate-300 dark:border-slate-700 text-sm hover:bg-slate-100 dark:hover:bg-slate-800 ${state.tag===t?'bg-indigo-600 text-white border-indigo-600':''}">#${t}</button>`
    ).join('') + '</div>';
    $$('.tag-btn').forEach(b=> b.addEventListener('click',()=>{ state.tag = (state.tag===b.dataset.tag? '' : b.dataset.tag); state.page=1; syncList(); }));
  }

  function createCard(a){
    const bookmarked = isBookmarked(a.slug);
    return `
      <article class="border border-slate-200 dark:border-slate-700 rounded-2xl p-4 bg-white/70 dark:bg-slate-800/70 hover:shadow transition">
        <header class="flex items-start justify-between gap-3">
          <h3 class="text-lg font-semibold leading-tight">${a.title}</h3>
          <button class="bookmark inline-flex items-center justify-center w-9 h-9 rounded-full ${bookmarked?'text-amber-500':'text-slate-400'}" title="ブックマーク" data-slug="${a.slug}">
            <i class="${bookmarked?'fa-solid':'fa-regular'} fa-star"></i>
          </button>
        </header>
        <div class="mt-1 text-sm text-slate-500">${a.category}・更新 ${fmtDate(a.updated)}</div>
        <p class="mt-2 text-sm line-clamp-3">${a.summary}</p>
        <div class="mt-3 flex flex-wrap gap-2 text-xs">${a.tags.map(t=>`<span class='px-2 py-1 rounded-full bg-slate-100 dark:bg-slate-700'>#${t}</span>`).join('')}</div>
        <div class="mt-4 flex gap-2">
          <a href="#/a/${a.slug}" class="inline-flex items-center gap-2 px-3 py-2 rounded-xl bg-indigo-600 text-white text-sm hover:opacity-90"><i class="fa-solid fa-circle-info"></i> 詳細</a>
          <button class="copy-link inline-flex items-center gap-2 px-3 py-2 rounded-xl border border-slate-300 dark:border-slate-700 text-sm hover:bg-slate-100 dark:hover:bg-slate-800" data-link="${location.origin+location.pathname}#/a/${a.slug}"><i class="fa-solid fa-link"></i>リンク</button>
        </div>
      </article>
    `;
  }

  function renderPager(total){
    const pager = $('#pager');
    const pages = Math.max(1, Math.ceil(total/state.perPage));
    state.page = Math.min(state.page, pages);
    let html='';
    for(let i=1;i<=pages;i++){
      html += `<button class="px-3 py-1 rounded-lg border ${i===state.page?'bg-indigo-600 text-white border-indigo-600':'border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-800'}" data-page="${i}">${i}</button>`;
    }
    pager.innerHTML = html;
    $$('#pager button').forEach(b=> b.addEventListener('click',()=>{ state.page=Number(b.dataset.page); syncList(false); }));
  }

  function syncList(scrollTop=true){
    const list = filterArticles();
    $('#resultCount').textContent = list.length;
    renderPager(list.length);

    const start=(state.page-1)*state.perPage;
    const pageItems=list.slice(start, start+state.perPage);
    $('#cards').innerHTML = pageItems.map(createCard).join('');

    // events
    $$('.bookmark').forEach(b=> b.addEventListener('click',()=>{ toggleBookmark(b.dataset.slug); syncList(false); }));
    $$('.copy-link').forEach(b=> b.addEventListener('click',()=> copyText(b.dataset.link)));

    if(scrollTop) window.scrollTo({top:0, behavior:'smooth'});
  }

  // =================
  // 記事ページ描画
  // =================
  function renderArticle(slug){
    const a = slugToArticle(slug);
    if(!a){ location.hash = ''; return; }

    // breadcrumb
    $('#breadcrumb').innerHTML = `<a class="underline" href="#">ホーム</a> / <span class="text-slate-600">${a.category}</span>`;

    // title & meta
    $('#articleTitle').textContent = a.title;
    $('#articleMeta').innerHTML = `
      <span><i class="fa-regular fa-calendar"></i> 更新 ${fmtDate(a.updated)}</span>
      <span class="mx-1">•</span>
      <span><i class="fa-regular fa-user"></i> ${a.author}</span>
      <span class="mx-1">•</span>
      <span><i class="fa-solid fa-folder"></i> ${a.category}</span>
    `;

    // content
    const container = $('#articleContent');
    container.innerHTML = a.content;

    // tags
    $('#articleTags').innerHTML = a.tags.map(t=>`<a href="#" data-tag="${t}" class="px-3 py-1 rounded-full border border-slate-300 dark:border-slate-700 text-sm hover:bg-slate-100 dark:hover:bg-slate-800">#${t}</a>`).join('');
    $$('#articleTags a').forEach(el=> el.addEventListener('click',(e)=>{ e.preventDefault(); state.tag=el.dataset.tag; location.hash=''; }));

    // bookmark button
    const btnBM = $('#btnToggleBookmark');
    const setBM = ()=>{
      const marked = isBookmarked(a.slug);
      btnBM.innerHTML = `<i class="${marked?'fa-solid':'fa-regular'} fa-star me-1"></i>${marked?'保存済み':'ブックマーク'}`;
    };
    btnBM.onclick = ()=>{ toggleBookmark(a.slug); setBM(); };
    setBM();

    // copy link
    $('#btnCopyLink').onclick = ()=> copyText(location.href);

    // TOC
    buildTOC();

    // JSON-LD
    updateLDJSON(a);

    // history
    pushHistory(a.slug);
  }

  function buildTOC(){
    const toc = $('#toc');
    const headings = $$('#articleContent h2, #articleContent h3');
    if(headings.length===0){ toc.innerHTML = '<div class="text-slate-500 text-sm">見出しがありません</div>'; return; }
    let html='';
    headings.forEach(h=>{
      if(!h.id) h.id = h.textContent.trim().toLowerCase().replace(/[^a-z0-9一-龥ぁ-んァ-ヶー]+/g,'-');
      const indent = h.tagName==='H3' ? 'ms-4' : '';
      html += `<a href="#${h.id}" class="${indent} hover:text-indigo-600">${h.textContent}</a>`;
    });
    toc.innerHTML = html;

    const observer = new IntersectionObserver((entries)=>{
      entries.forEach(e=>{
        if(e.isIntersecting){
          $$('#toc a').forEach(a=>a.classList.remove('active'));
          const a = $(`#toc a[href="#${e.target.id}"]`);
          if(a) a.classList.add('active');
        }
      });
    }, {rootMargin: '0px 0px -70% 0px'});
    headings.forEach(h=> observer.observe(h));
  }

  function updateLDJSON(a){
    const obj = {
      '@context':'https://schema.org',
      '@type':'Article',
      headline: a.title,
      dateModified: a.updated,
      author: { '@type':'Organization', name: a.author },
      keywords: a.tags.join(','),
      articleSection: a.category,
      url: location.href
    };
    $('#ldjson').textContent = JSON.stringify(obj);
  }

  // ============
  //  ルーター
  // ============
  function route(){
    const hash = location.hash.slice(1);
    if(hash.startsWith('/a/')){
      const slug = hash.split('/')[2];
      $('#view-home').classList.add('hidden');
      $('#view-article').classList.remove('hidden');
      renderArticle(slug);
      window.scrollTo({top:0, behavior:'instant'});
    }else{
      $('#view-article').classList.add('hidden');
      $('#view-home').classList.remove('hidden');
      syncList();
    }
  }

  window.addEventListener('hashchange', route);

  // ============
  //  便利機能
  // ============
  function copyText(text){
    navigator.clipboard.writeText(text).then(()=>{
      toast('リンクをコピーしました');
    }, ()=>{
      prompt('コピーできない場合は手動で選択してコピーしてください:', text);
    });
  }

  function toast(msg){
    const t = document.createElement('div');
    t.textContent = msg;
    t.className = 'fixed left-1/2 -translate-x-1/2 bottom-6 z-50 bg-black/80 text-white px-4 py-2 rounded-xl text-sm';
    document.body.appendChild(t);
    setTimeout(()=>{ t.remove(); }, 1600);
  }

  function randomArticle(){
    const a = ARTICLES[Math.floor(Math.random()*ARTICLES.length)];
    location.hash = `#/a/${a.slug}`;
  }

  // ==============
  //  I/O (JSON)
  // ==============
  function exportJSON(){
    const blob = new Blob([JSON.stringify(ARTICLES, null, 2)], {type:'application/json'});
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url; a.download = 'mini-encyclopedia.json'; a.click();
    URL.revokeObjectURL(url);
  }

  function importJSON(file){
    const reader = new FileReader();
    reader.onload = (e)=>{
      try{
        const data = JSON.parse(e.target.result);
        if(Array.isArray(data)){
          // 形式が正しければ差し替え
          if(data.every(x=>x.slug && x.title && x.content)){
            ARTICLES.length = 0; // 破壊的更新
            data.forEach(x=> ARTICLES.push(x));
            init();
            toast('JSONを読み込みました');
          }else{
            alert('スキーマが不正です。slug/title/content は必須です。');
          }
        }else{
          alert('配列形式のJSONが必要です');
        }
      }catch(err){
        alert('JSONの解析に失敗しました: '+err.message);
      }
    };
    reader.readAsText(file);
  }

  // ============
  //  初期化
  // ============
  function init(){
    // 年
    $('#year').textContent = new Date().getFullYear();

    // カテゴリ・タグバー
    renderCategories();
    renderTagsBar();

    // イベント
    $('#search').addEventListener('input', (e)=>{ state.q = e.target.value.trim(); state.page=1; syncList(); });
    $('#category').addEventListener('change', (e)=>{ state.category = e.target.value; state.page=1; syncList(); });
    $('#sort').addEventListener('change', (e)=>{ state.sort = e.target.value; state.page=1; syncList(); });
    $('#btnRandom').addEventListener('click', randomArticle);
    $('#btnBookmarks').addEventListener('click', ()=>{
      const bms = getBookmarks();
      if(bms.length===0){ toast('ブックマークはまだありません'); return; }
      const first = slugToArticle(bms[0]);
      if(first) location.hash = `#/a/${first.slug}`;
    });
    $('#btnHome').addEventListener('click', ()=>{ location.hash=''; });

    // ダークモード切替
    $('#btnDark').addEventListener('click', ()=>{
      const root = document.documentElement;
      const isDark = root.classList.toggle('dark');
      localStorage.setItem('theme', isDark? 'dark':'light');
    });

    // JSON I/O
    $('#btnExport').addEventListener('click', exportJSON);
    $('#fileImport').addEventListener('change', (e)=>{ const f=e.target.files?.[0]; if(f) importJSON(f); e.target.value=''; });

    // 初回描画
    route();
  }

  document.addEventListener('DOMContentLoaded', init);
  </script>
</body>
</html>

投稿者: chosuke

趣味はゲームやアニメや漫画などです

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です