ミニ百科.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>

XLogpro.html(ツイートまとめサイト)

<!DOCTYPE html>
<html lang="ja" data-theme="light" style="--cols:3; --card-h:640px; --accent:#2563eb">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Xlog Pro — HTMLだけで動く自動ツイートまとめ</title>
  <link rel="preconnect" href="https://platform.twitter.com" crossorigin>
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"/>
  <meta name="description" content="HTMLだけ/APIキー不要のX(Twitter)まとめボード。プロフィール・ハッシュタグ・検索・リストを好きな列で配置し、JSON/HTML書き出しや手動ランキング、ボード切替に対応。">
  <style>
    :root{
      --bg: #0b0e14; --panel:#111827; --muted:#9aa4b2; --text:#e5e7eb; --border:#1f2937; --chip:#141a23; --card:#0f172a; --btn:#1f2937; --btn-text:#e5e7eb; --link:#60a5fa;
    }
    [data-theme="light"]{ --bg:#f8fafc; --panel:#ffffff; --muted:#64748b; --text:#0f172a; --border:#e2e8f0; --chip:#f1f5f9; --card:#ffffff; --btn:#0f172a; --btn-text:#ffffff; --link:#1d9bf0; }
    *{box-sizing:border-box}
    body{margin:0;background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,"Apple Color Emoji","Segoe UI Emoji"}
    header{position:sticky;top:0;z-index:10;background:var(--panel);border-bottom:1px solid var(--border)}
    .wrap{max-width:1280px;margin:0 auto;padding:12px 16px}
    .row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
    .brand{display:flex;gap:10px;align-items:center;font-weight:800}
    .brand i{color:var(--accent)}
    .muted{color:var(--muted)}
    .pill{display:inline-flex;gap:8px;align-items:center;background:var(--chip);border:1px solid var(--border);border-radius:999px;padding:6px 10px}
    .input, select, textarea{background:transparent;border:1px solid var(--border);border-radius:10px;padding:8px 10px;color:var(--text)}
    textarea{min-height:88px;width:100%;}
    input[type="text"].input{min-width:220px}
    button{cursor:pointer;border:none}
    .btn{background:var(--btn);color:var(--btn-text);padding:9px 12px;border-radius:12px}
    .btn.secondary{background:transparent;color:var(--text);border:1px solid var(--border)}
    .btn.ghost{background:transparent;color:var(--text)}
    .btn.badge{padding:6px 10px;border-radius:999px}
    .grid{display:grid;grid-template-columns:320px 1fr;gap:16px}
    @media (max-width:1080px){.grid{grid-template-columns:1fr}}
    aside{background:var(--panel);border:1px solid var(--border);border-radius:16px;padding:14px;position:sticky;top:72px;height:max-content}
    h2{margin:6px 0 12px 0;font-size:18px}
    .list{display:flex;flex-direction:column;gap:12px}
    .card{background:var(--card);border:1px solid var(--border);border-radius:16px;overflow:hidden}
    .card .head{display:flex;justify-content:space-between;align-items:center;padding:12px 14px;border-bottom:1px solid var(--border)}
    .card .head .title{display:flex;gap:8px;align-items:center;font-weight:700}
    .card .body{padding:0;min-height:var(--card-h)}
    .sources{display:flex;flex-wrap:wrap;gap:8px}
    .chip{background:var(--chip);border:1px solid var(--border);border-radius:999px;padding:6px 10px;display:flex;gap:8px;align-items:center}
    .chip b{color:var(--accent)}
    .columns{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:16px}
    @media (max-width:1200px){:root{--cols:2}}
    @media (max-width:860px){:root{--cols:1}}
    .drag{cursor:grab}
    .toolbar{display:flex;gap:8px;flex-wrap:wrap}
    .footer{padding:24px 16px;color:var(--muted);text-align:center}
    .kbd{font-family:ui-monospace, Menlo, Monaco, Consolas; background:var(--chip); border:1px solid var(--border); padding:2px 6px; border-radius:6px}
    .danger{color:#ef4444}
    .accent{color:var(--accent)}
    .section{background:var(--panel);border:1px solid var(--border);border-radius:16px;padding:14px}
    .help{font-size:13px;color:var(--muted)}
    .label{font-size:12px;color:var(--muted)}
    .tiny{font-size:12px}
    .row-wrap{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
    .w-100{width:100%}
    .space{height:8px}
  </style>
</head>
<body>
  <header>
    <div class="wrap row">
      <div class="brand"><i class="fa-solid fa-wave-square"></i> Xlog <span class="muted">Pro</span></div>
      <div class="pill">
        <i class="fa-solid fa-diagram-project"></i>
        <select id="boardSelect" title="ボード切替"></select>
        <button id="boardNew" class="btn badge secondary" title="新規ボード"><i class="fa-solid fa-plus"></i></button>
        <button id="boardRename" class="btn badge secondary" title="名前変更"><i class="fa-solid fa-pen"></i></button>
        <button id="boardDelete" class="btn badge secondary danger" title="削除"><i class="fa-regular fa-trash-can"></i></button>
      </div>
      <div class="pill" title="テーマ切替"><i class="fa-solid fa-circle-half-stroke"></i>
        <label class="row-wrap"><input id="themeToggle" type="checkbox" /> ダーク</label>
      </div>
      <div class="pill" title="アクセントカラー">
        <i class="fa-solid fa-palette"></i>
        <input id="accentPicker" type="color" value="#2563eb" />
      </div>
      <div class="pill" title="列数"><i class="fa-solid fa-table-columns"></i>
        <input id="colsRange" type="range" min="1" max="4" step="1" value="3"/>
        <span id="colsVal" class="tiny"></span>
      </div>
      <div class="pill" title="カード高さ"><i class="fa-solid fa-up-down"></i>
        <input id="heightRange" type="range" min="360" max="1200" step="40" value="640"/>
        <span id="heightVal" class="tiny"></span>
      </div>
      <div class="pill" title="自動再読み込み">
        <label class="row-wrap"><input id="autoRefreshToggle" type="checkbox"/> 自動</label>
        <select id="refreshMinutes">
          <option value="3">3分</option>
          <option value="5" selected>5分</option>
          <option value="10">10分</option>
          <option value="30">30分</option>
        </select>
      </div>
      <div class="pill help tiny">ショートカット: <span class="kbd">N</span> 追加 / <span class="kbd">R</span> 再描画 / <span class="kbd">G</span> グリッド- / <span class="kbd">H</span> 高さ-</div>
    </div>
  </header>

  <main class="wrap grid">
    <aside>
      <div class="list">
        <div class="section">
          <h2>ソースを追加</h2>
          <div class="toolbar row-wrap">
            <select id="sourceType">
              <option value="profile">プロフィール</option>
              <option value="hashtag">ハッシュタグ</option>
              <option value="search">検索キーワード</option>
              <option value="list">リストURL</option>
            </select>
            <input id="sourceValue" class="input" type="text" placeholder="@username / #tag / キーワード / リストURL" />
            <input id="sourceLabel" class="input" type="text" placeholder="表示名(任意)" />
            <button id="addBtn" class="btn"><i class="fa-solid fa-plus"></i> 追加</button>
          </div>
          <div class="space"></div>
          <label class="label">まとめて追加(改行/カンマ区切りOK)</label>
          <textarea id="bulkArea" placeholder="@OpenAI, #UnrealEngine, Unity URP, https://twitter.com/i/lists/123...\n@EpicGames"></textarea>
          <div class="row-wrap">
            <button id="bulkAdd" class="btn secondary"><i class="fa-solid fa-download"></i> 取り込み</button>
            <button id="bulkClear" class="btn ghost"><i class="fa-solid fa-eraser"></i> クリア</button>
          </div>
          <p class="help" style="margin-top:8px">形式は自動判定:<span class="kbd">@id</span> → プロフィール、<span class="kbd">#tag</span> → ハッシュタグ、<span class="kbd">twitter.com/i/lists</span> → リスト、それ以外は検索。</p>
          <div class="space"></div>
          <div class="row-wrap help tiny">クイック追加:</div>
          <div class="row-wrap">
            <button class="btn badge secondary quick" data-type="hashtag" data-val="#UnrealEngine">#UnrealEngine</button>
            <button class="btn badge secondary quick" data-type="hashtag" data-val="#Unity3D">#Unity3D</button>
            <button class="btn badge secondary quick" data-type="search" data-val="VRM OR \"Meta Quest\"">VR/Quest</button>
            <button class="btn badge secondary quick" data-type="profile" data-val="@OpenAI">@OpenAI</button>
          </div>
        </div>

        <div class="section">
          <h2>保存・書き出し</h2>
          <div class="toolbar row-wrap">
            <button id="exportBtn" class="btn secondary"><i class="fa-solid fa-file-export"></i> JSON</button>
            <label class="btn secondary" for="importFile"><i class="fa-solid fa-file-import"></i> JSON読込</label>
            <input id="importFile" type="file" accept="application/json" hidden />
            <button id="exportHtmlBtn" class="btn"><i class="fa-regular fa-file-code"></i> 単一HTML</button>
            <button id="clearBtn" class="btn ghost danger"><i class="fa-regular fa-trash-can"></i> すべて削除</button>
          </div>
          <p class="help">単一HTML: いまのレイアウトと設定を埋め込んだ自立HTMLを生成します。</p>
        </div>

        <div class="section">
          <h2>手動ランキング</h2>
          <div class="toolbar row-wrap">
            <input id="tweetUrl" class="input" type="text" placeholder="ツイートURLを貼り付け" />
            <button id="addTweetBtn" class="btn"><i class="fa-brands fa-x-twitter"></i> 追加</button>
          </div>
          <label class="label">メモ(任意・次回以降も保持)</label>
          <textarea id="tweetNote" placeholder="このツイートの要点やタグ(例: #UE5 #VRM)"></textarea>
          <p class="help">※HTMLのみの制約で自動集計は不可。URLをカード化して手動で順序を決められます。</p>
        </div>

        <div class="section">
          <h2>RSS生成(ランキング→RSS)</h2>
          <div class="toolbar row-wrap">
            <input id="rssTitle" class="input" type="text" placeholder="RSSタイトル(例: Xlogランキング)"/>
            <button id="rssExport" class="btn secondary"><i class="fa-solid fa-rss"></i> RSSを書き出し</button>
          </div>
          <p class="help">ランキングに登録したツイートURLから簡易RSS(XML)を生成し、ファイルとして保存します。</p>
        </div>

        <div class="section">
          <h2>ヘルプ</h2>
          <div class="help">
            ・列の並べ替えはカードの <span class="kbd">⋯</span> アイコンをドラッグ。<br>
            ・<span class="kbd">R</span> で全カラムを再描画。<br>
            ・URLハッシュ <span class="kbd">#data=</span> に設定をBase64で埋め込んで共有可能(メニューから自動生成予定)。
          </div>
        </div>
      </div>
    </aside>

    <section>
      <div class="card" style="margin-bottom:16px">
        <div class="head">
          <div class="title"><i class="fa-solid fa-layer-group drag"></i> マイまとめ <span class="muted tiny" id="boardInfo"></span></div>
          <div class="sources" id="activeChips"></div>
        </div>
        <div class="body" style="padding:14px">
          <div id="columns" class="columns"></div>
        </div>
      </div>

      <div class="card">
        <div class="head">
          <div class="title"><i class="fa-regular fa-star"></i> 手動ランキング</div>
          <div class="help">ドラッグで順序変更/🗑で削除/✎でメモ編集</div>
        </div>
        <div class="body" style="padding:14px">
          <div id="ranking" class="columns"></div>
        </div>
      </div>

      <div class="footer">Xlog Pro v2 — HTML Only / Embedded Timelines. No API keys. <span class="muted">Made for you.</span></div>
    </section>
  </main>

  <script async src="https://platform.twitter.com/widgets.js"></script>
  <script>
    // ========== 基本ユーティリティ ==========
    const $ = (s, d=document)=>d.querySelector(s);
    const $$ = (s, d=document)=>Array.from(d.querySelectorAll(s));

    const defaultBoard = ()=>({sources:[], tweets:[]});
    const defaultState = ()=>({
      version:2,
      dark:false,
      accent:'#2563eb',
      autoRefresh:false,
      minutes:5,
      columns:3,
      cardHeight:640,
      boards:{'Default': defaultBoard()},
      activeBoard:'Default'
    });

    const store = {
      key: 'xlog-pro-v2',
      load(){
        try{ return JSON.parse(localStorage.getItem(this.key)) || defaultState(); }
        catch(e){ return defaultState(); }
      },
      save(v){ localStorage.setItem(this.key, JSON.stringify(v)); }
    };

    function migrate(s){
      const base = defaultState();
      if (!s || typeof s !== 'object') return base;
      // v1互換(sources/tweets直下 → boards.Default)
      if (s.sources || s.tweets){
        base.boards.Default.sources = s.sources||[];
        base.boards.Default.tweets = s.tweets||[];
      }
      // 既存キー上書き
      for (const k of ['dark','accent','autoRefresh','minutes','columns','cardHeight','boards','activeBoard']){
        if (k in s) base[k]=s[k];
      }
      return base;
    }

    // ハッシュ (#data=BASE64) から読み込み
    function loadFromHash(){
      const h = location.hash || '';
      if (!h.startsWith('#data=')) return null;
      try{
        const b64 = decodeURIComponent(h.slice(6));
        const json = atob(b64);
        return JSON.parse(json);
      }catch(e){ return null; }
    }

    let embedded = (typeof window.__XLOG_INITIAL_STATE__!== 'undefined') ? window.__XLOG_INITIAL_STATE__ : null;
    if (!embedded){
      const el = document.getElementById('xlog-init');
      if (el) { try{ embedded = JSON.parse(el.textContent); }catch(_e){} }
    }

    let state = migrate( embedded || loadFromHash() || store.load() );

    // ========== テーマ/アクセント/レイアウト適用 ==========
    function applySkin(){
      document.documentElement.setAttribute('data-theme', state.dark ? 'dark' : 'light');
      document.documentElement.style.setProperty('--cols', state.columns);
      document.documentElement.style.setProperty('--card-h', state.cardHeight+'px');
      document.documentElement.style.setProperty('--accent', state.accent || '#2563eb');
      $('#themeToggle').checked = !!state.dark;
      $('#colsRange').value = String(state.columns);
      $('#colsVal').textContent = state.columns+'列';
      $('#heightRange').value = String(state.cardHeight);
      $('#heightVal').textContent = state.cardHeight+'px';
      $('#accentPicker').value = state.accent || '#2563eb';
    }

    // ========== X埋め込み ==========
    function waitTwttr(){
      return new Promise(res=>{
        if (window.twttr && twttr.widgets) return res();
        const timer = setInterval(()=>{ if(window.twttr && twttr.widgets){ clearInterval(timer); res(); } }, 200);
      });
    }

    function timelineOptions(){
      return {
        height: state.cardHeight,
        theme: state.dark ? 'dark' : 'light',
        chrome: 'nofooter noborders transparent',
        linkColor: getComputedStyle(document.documentElement).getPropertyValue('--link').trim() || '#1d9bf0'
      };
    }

    async function createTimeline(el, src){
      await waitTwttr();
      const opts = timelineOptions();
      const t = (src.type||'profile');
      if (t==='profile'){
        const screenName = src.value.replace(/^@/,'');
        return twttr.widgets.createTimeline({ sourceType:'profile', screenName }, el, opts);
      }
      if (t==='list'){
        return twttr.widgets.createTimeline({ sourceType:'url', url: src.value }, el, opts);
      }
      if (t==='hashtag'){
        const tag = src.value.replace(/^#/,'');
        const url = `https://twitter.com/hashtag/${encodeURIComponent(tag)}?f=live`;
        return twttr.widgets.createTimeline({ sourceType:'url', url }, el, opts);
      }
      if (t==='search'){
        const url = `https://twitter.com/search?q=${encodeURIComponent(src.value)}&f=live`;
        return twttr.widgets.createTimeline({ sourceType:'url', url }, el, opts);
      }
    }

    // ========== 現在ボードの参照 ==========
    function board(){ return state.boards[state.activeBoard] || (state.boards[state.activeBoard]=defaultBoard()); }

    // ========== 描画 ==========
    function chipNode(src, idx){
      const chip = document.createElement('span');
      chip.className='chip';
      const kind = {profile:'@',hashtag:'#',search:'検索:',list:'リスト'}[src.type] || '';
      chip.innerHTML = `<b>${kind}</b> ${src.label || src.value} <a class="muted" href="${openUrl(src)}" target="_blank" title="Xで開く"><i class="fa-solid fa-arrow-up-right-from-square"></i></a> <button title="削除" data-del="${idx}" class="muted"><i class="fa-solid fa-xmark"></i></button>`;
      chip.querySelector('button').onclick = ()=>{ board().sources.splice(idx,1); store.save(state); renderAll(); };
      return chip;
    }

    function openUrl(src){
      if (src.type==='profile') return `https://twitter.com/${src.value.replace(/^@/,'')}`;
      if (src.type==='hashtag') return `https://twitter.com/hashtag/${src.value.replace(/^#/,'')}`;
      if (src.type==='list') return src.value;
      return `https://twitter.com/search?q=${encodeURIComponent(src.value)}&f=live`;
    }

    function columnCard(src, idx){
      const card = document.createElement('div');
      card.className='card';
      card.draggable=true; card.dataset.idx=idx;
      card.innerHTML = `
        <div class="head">
          <div class="title"><i class="fa-solid fa-grip-vertical drag"></i> ${src.label || prettyLabel(src)}</div>
          <div class="toolbar">
            <a class="btn ghost" href="${openUrl(src)}" target="_blank" title="Xで開く"><i class="fa-solid fa-arrow-up-right-from-square"></i></a>
            <button class="btn ghost" title="再読み込み" data-refresh="${idx}"><i class="fa-solid fa-rotate"></i></button>
            <button class="btn ghost danger" title="削除" data-remove="${idx}"><i class="fa-regular fa-trash-can"></i></button>
          </div>
        </div>
        <div class="body"><div class="embed" style="min-height:120px"></div></div>`;

      // DnD 並べ替え
      card.addEventListener('dragstart', e=>{ e.dataTransfer.setData('text/plain', idx); card.style.opacity='0.6'; });
      card.addEventListener('dragend', ()=>{ card.style.opacity='1'; });
      card.addEventListener('dragover', e=>{ e.preventDefault(); card.style.outline='2px dashed var(--accent)'; });
      card.addEventListener('dragleave', ()=>{ card.style.outline='none'; });
      card.addEventListener('drop', e=>{
        e.preventDefault(); card.style.outline='none';
        const from = +e.dataTransfer.getData('text/plain');
        const to = +card.dataset.idx;
        if (from===to) return;
        const arr = board().sources;
        const [moved] = arr.splice(from,1);
        arr.splice(to,0,moved);
        store.save(state); renderAll();
      });

      // 操作
      card.querySelector('[data-remove]')?.addEventListener('click', ()=>{ board().sources.splice(idx,1); store.save(state); renderAll(); });
      card.querySelector('[data-refresh]')?.addEventListener('click', ()=>{ mountTimeline(card, src); });
      // 初回描画
      mountTimeline(card, src);
      return card;
    }

    function mountTimeline(card, src){
      const holder = card.querySelector('.embed');
      holder.innerHTML = '<div style="padding:14px" class="muted">読み込み中…</div>';
      createTimeline(holder, src).catch(()=>{
        holder.innerHTML = '<div style="padding:14px" class="danger">読み込みに失敗しました。値を確認してください。</div>';
      });
    }

    function prettyLabel(src){
      if (src.type==='profile') return '@'+src.value.replace(/^@/,'');
      if (src.type==='hashtag') return '#'+src.value.replace(/^#/,'');
      if (src.type==='search') return '検索: '+src.value;
      if (src.type==='list') return 'リスト';
      return src.value;
    }

    function renderAll(){
      // ボード情報
      const info = $('#boardInfo');
      const b = board();
      info.textContent = `(${state.activeBoard}|${b.sources.length}列 / ${b.tweets.length}件)`;

      // チップ
      const chips = $('#activeChips'); chips.innerHTML='';
      b.sources.forEach((s,i)=> chips.appendChild(chipNode(s,i)) );
      // カラム
      const col = $('#columns'); col.innerHTML='';
      b.sources.forEach((s,i)=> col.appendChild(columnCard(s,i)) );
      // ランキング
      renderRanking();
    }

    // ========== ランキング ==========
    function parseTweetId(url){ const m = (url||'').match(/status\/(\d{5,})/); return m? m[1] : null; }
    function tweetUrlFromId(id){ return `https://twitter.com/i/web/status/${id}`; }

    async function addTweet(url, note){
      const id = parseTweetId(url);
      if (!id) return alert('ツイートURLが正しくありません');
      board().tweets.push({id, note: (note||'')});
      store.save(state); renderRanking();
    }

    async function renderRanking(){
      await waitTwttr();
      const root = $('#ranking'); root.innerHTML='';
      const arr = board().tweets;
      arr.forEach((t, idx)=>{
        const card = document.createElement('div'); card.className='card'; card.draggable=true; card.dataset.idx=idx;
        card.innerHTML = `
          <div class="head">
            <div class="title"><i class="fa-solid fa-grip-vertical drag"></i> エントリ #${idx+1}</div>
            <div class="toolbar">
              <button class="btn ghost" data-edit="${idx}" title="メモ編集"><i class="fa-regular fa-pen-to-square"></i></button>
              <a class="btn ghost" href="${tweetUrlFromId(t.id)}" target="_blank" title="Xで開く"><i class="fa-solid fa-arrow-up-right-from-square"></i></a>
              <button class="btn ghost danger" title="削除" data-del-rank="${idx}"><i class="fa-regular fa-trash-can"></i></button>
            </div>
          </div>
          <div class="body">
            <div class="embed"></div>
            <div style="padding:10px 14px;border-top:1px solid var(--border)" class="tiny"><span class="muted">メモ:</span> <span class="note">${escapeHtml(t.note||'')}</span></div>
          </div>`;

        // イベント
        card.querySelector('[data-del-rank]')?.addEventListener('click', ()=>{ arr.splice(idx,1); store.save(state); renderRanking(); });
        card.querySelector('[data-edit]')?.addEventListener('click', ()=>{
          const newNote = prompt('メモを編集', t.note||'');
          if (newNote!==null){ t.note = newNote; store.save(state); renderRanking(); }
        });

        // DnD 並べ替え
        card.addEventListener('dragstart', e=>{ e.dataTransfer.setData('text/plain', 'rank:'+idx); card.style.opacity='0.6'; });
        card.addEventListener('dragend', ()=>{ card.style.opacity='1'; });
        card.addEventListener('dragover', e=>{ e.preventDefault(); card.style.outline='2px dashed var(--accent)'; });
        card.addEventListener('dragleave', ()=>{ card.style.outline='none'; });
        card.addEventListener('drop', e=>{
          e.preventDefault(); card.style.outline='none';
          const data = e.dataTransfer.getData('text/plain'); if (!data.startsWith('rank:')) return;
          const from = +data.split(':')[1]; const to = +card.dataset.idx;
          const [moved] = arr.splice(from,1); arr.splice(to,0,moved);
          store.save(state); renderRanking();
        });

        const holder = card.querySelector('.embed');
        twttr.widgets.createTweet(t.id, holder, { theme: state.dark ? 'dark' : 'light' });
        root.appendChild(card);
      });
    }

    function escapeHtml(s){ return (s||'').replace(/[&<>"']/g, m=> ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;','\'':'&#39;'}[m])); }

    // ========== 自動再描画 ==========
    let refreshTimer = null;
    function applyAutoRefresh(){
      if (refreshTimer) { clearInterval(refreshTimer); refreshTimer=null; }
      if (state.autoRefresh){
        const ms = Math.max(1, +state.minutes) * 60 * 1000;
        refreshTimer = setInterval(()=>{
          $$('#columns .card').forEach((card, i)=>{
            const src = board().sources[i]; if (src) mountTimeline(card, src);
          });
        }, ms);
      }
    }

    // ========== ボード管理 ==========
    function refreshBoardSelect(){
      const sel = $('#boardSelect'); sel.innerHTML='';
      Object.keys(state.boards).forEach(name=>{
        const opt = document.createElement('option'); opt.value=name; opt.textContent=name; sel.appendChild(opt);
      });
      sel.value = state.activeBoard;
    }

    function addBoard(name){
      if (!name) return;
      if (state.boards[name]) return alert('同名のボードが存在します');
      state.boards[name] = defaultBoard(); state.activeBoard = name; store.save(state);
      refreshBoardSelect(); renderAll();
    }

    function renameBoard(newName){
      if (!newName) return;
      if (state.boards[newName]) return alert('同名のボードが存在します');
      const old = state.activeBoard;
      state.boards[newName] = state.boards[old];
      delete state.boards[old];
      state.activeBoard = newName; store.save(state);
      refreshBoardSelect(); renderAll();
    }

    function deleteBoard(){
      const names = Object.keys(state.boards);
      if (names.length<=1) return alert('最後のボードは削除できません');
      if (!confirm(`ボード「${state.activeBoard}」を削除しますか?`)) return;
      delete state.boards[state.activeBoard];
      state.activeBoard = Object.keys(state.boards)[0];
      store.save(state); refreshBoardSelect(); renderAll();
    }

    // ========== 共有(URLハッシュ生成) ==========
    function exportHashUrl(){
      const cloned = JSON.parse(JSON.stringify(state));
      const json = JSON.stringify(cloned);
      const b64 = btoa(json);
      const url = location.origin + location.pathname + '#data=' + encodeURIComponent(b64);
      return url;
    }

    // ========== 単一HTML出力 ==========
    function download(filename, text){
      const blob = new Blob([text], {type:'text/html'});
      const a = document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=filename; a.click(); URL.revokeObjectURL(a.href);
    }

    function exportSingleHtml(){
      // 現在のHTMLに初期状態スクリプトを差し込む
      let html = document.documentElement.outerHTML;
      const idx = html.indexOf('<head>');
      const inject = `<head>\n  <script>window.__XLOG_INITIAL_STATE__=${JSON.stringify(state)}<\/script>`;
      if (idx>=0){ html = html.replace('<head>', inject); }
      download('xlog-pro.html', '<!DOCTYPE html>\n' + html);
    }

    // ========== RSS生成 ==========
    function exportRss(){
      const title = $('#rssTitle').value.trim() || 'Xlog Ranking';
      const items = board().tweets.map(t=>({
        title: (t.note||'Tweet '+t.id).replace(/[\r\n]+/g,' ').slice(0,120),
        link: tweetUrlFromId(t.id),
        guid: t.id,
        description: escapeHtml(t.note||''),
        pubDate: new Date().toUTCString()
      }));
      const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0"><channel>\n<title>${escapeXml(title)}</title>\n<link>${escapeXml(location.href)}</link>\n<description>Generated by Xlog Pro</description>\n${items.map(i=>`<item><title>${escapeXml(i.title)}</title><link>${escapeXml(i.link)}</link><guid isPermaLink=\"false\">${escapeXml(i.guid)}</guid><description>${escapeXml(i.description)}</description><pubDate>${i.pubDate}</pubDate></item>`).join('')}\n</channel></rss>`;
      const blob = new Blob([xml], {type:'application/rss+xml'});
      const a = document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='xlog-ranking.xml'; a.click(); URL.revokeObjectURL(a.href);
    }

    function escapeXml(s){ return (s||'').replace(/[<>&\"']/g, m=> ({'<':'&lt;','>':'&gt;','&':'&amp;','\"':'&quot;','\'':'&apos;'}[m])); }

    // ========== 入力ヘルパ ==========
    function detectType(v){
      if (/^@/.test(v)) return 'profile';
      if (/^#/.test(v)) return 'hashtag';
      if (/twitter\.com\/i\/lists\//.test(v)) return 'list';
      return 'search';
    }

    function addSource(type, value, label){
      const src = {type, value:value.trim(), label:(label||'').trim()};
      board().sources.push(src); store.save(state); renderAll();
    }

    function bulkAddFromText(txt){
      const parts = txt.split(/[\n,]+/).map(s=>s.trim()).filter(Boolean);
      let count = 0;
      for (const p of parts){ addSource(detectType(p), p, ''); count++; }
      return count;
    }

    // ========== キーイベント ==========
    function setupShortcuts(){
      window.addEventListener('keydown', (e)=>{
        if (['INPUT','TEXTAREA','SELECT'].includes(document.activeElement.tagName)) return;
        if (e.key==='n' || e.key==='N'){ $('#sourceValue').focus(); }
        if (e.key==='r' || e.key==='R'){ renderAll(); }
        if (e.key==='g' || e.key==='G'){ state.columns=Math.max(1,state.columns-1); applySkin(); store.save(state); }
        if (e.key==='h' || e.key==='H'){ state.cardHeight=Math.max(360,state.cardHeight-40); applySkin(); store.save(state); renderAll(); }
      });
    }

    // ========== 設定と起動 ==========
    window.addEventListener('DOMContentLoaded', ()=>{
      // スキン
      applySkin();

      // ボード選択
      refreshBoardSelect();
      $('#boardSelect').addEventListener('change', (e)=>{ state.activeBoard = e.target.value; store.save(state); renderAll(); });
      $('#boardNew').addEventListener('click', ()=>{ const name = prompt('新しいボード名','Board '+(Object.keys(state.boards).length+1)); addBoard(name); });
      $('#boardRename').addEventListener('click', ()=>{ const name = prompt('新しい名前', state.activeBoard); if (name) renameBoard(name); });
      $('#boardDelete').addEventListener('click', deleteBoard);

      // テーマ/アクセント/レイアウト
      $('#themeToggle').addEventListener('change', e=>{ state.dark = e.target.checked; store.save(state); applySkin(); renderAll(); });
      $('#accentPicker').addEventListener('input', e=>{ state.accent = e.target.value; store.save(state); applySkin(); });
      $('#colsRange').addEventListener('input', e=>{ state.columns = +e.target.value; store.save(state); applySkin(); });
      $('#heightRange').addEventListener('input', e=>{ state.cardHeight = +e.target.value; store.save(state); applySkin(); renderAll(); });

      // ソース追加
      $('#addBtn').addEventListener('click', ()=>{
        const type = $('#sourceType').value;
        const val = $('#sourceValue').value.trim();
        const label = $('#sourceLabel').value.trim();
        if (!val) return alert('値を入力してください');
        addSource(type, val, label);
        $('#sourceValue').value=''; $('#sourceLabel').value='';
      });
      $$('.quick').forEach(btn=> btn.addEventListener('click', ()=> addSource(btn.dataset.type, btn.dataset.val, '')) );

      // まとめて追加
      $('#bulkAdd').addEventListener('click', ()=>{ const n = bulkAddFromText($('#bulkArea').value); alert(n+'件追加しました'); $('#bulkArea').value=''; });
      $('#bulkClear').addEventListener('click', ()=> $('#bulkArea').value='' );

      // ランキング
      $('#addTweetBtn').addEventListener('click', ()=>{
        const url = $('#tweetUrl').value.trim();
        const note = $('#tweetNote').value.trim();
        if (!url) return;
        addTweet(url, note); $('#tweetUrl').value=''; $('#tweetNote').value='';
      });

      // 設定
      $('#autoRefreshToggle').checked = !!state.autoRefresh;
      $('#refreshMinutes').value = String(state.minutes||5);
      $('#autoRefreshToggle').addEventListener('change', e=>{ state.autoRefresh = e.target.checked; store.save(state); applyAutoRefresh(); });
      $('#refreshMinutes').addEventListener('change', e=>{ state.minutes = +e.target.value; store.save(state); applyAutoRefresh(); });

      // 書き出し/読み込み
      $('#exportBtn').addEventListener('click', ()=>{
        const blob = new Blob([JSON.stringify(state,null,2)], {type:'application/json'});
        const a = document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='xlog-pro-config.json'; a.click(); URL.revokeObjectURL(a.href);
      });
      $('#importFile').addEventListener('change', e=>{
        const file = e.target.files?.[0]; if (!file) return;
        const fr = new FileReader();
        fr.onload = () => {
          try{ const obj = JSON.parse(fr.result); state = migrate(obj); store.save(state); applySkin(); refreshBoardSelect(); renderAll(); applyAutoRefresh(); }
          catch(err){ alert('JSONの読み込みに失敗しました'); }
        };
        fr.readAsText(file);
      });
      $('#exportHtmlBtn').addEventListener('click', exportSingleHtml);
      $('#clearBtn').addEventListener('click', ()=>{
        if (!confirm('現在のボードのソースとランキングを削除しますか?')) return;
        const b = board(); b.sources = []; b.tweets=[]; store.save(state); renderAll();
      });

      // RSS
      $('#rssExport').addEventListener('click', exportRss);

      // 初期描画
      renderAll();
      applyAutoRefresh();
      setupShortcuts();
    });
  </script>
</body>
</html>

AIイラストプロンプトメーカー

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>AIイラストプロンプトメーカー</title>
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <style>
    :root {
      --primary: #6c63ff;
      --bg: #f2f2f2;
      --text: #333;
      --card: white;
    }

    body {
      margin: 0;
      font-family: 'Segoe UI', sans-serif;
      background: var(--bg);
      color: var(--text);
    }

    header {
      background: var(--primary);
      color: white;
      padding: 20px;
      text-align: center;
      font-size: 1.8em;
    }

    .container {
      max-width: 1000px;
      margin: 30px auto;
      background: var(--card);
      padding: 30px;
      border-radius: 12px;
      box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
    }

    .form-group {
      margin-bottom: 15px;
    }

    label {
      display: block;
      font-weight: bold;
      margin-bottom: 5px;
    }

    select, button, textarea, input[type="file"] {
      width: 100%;
      padding: 10px;
      font-size: 1em;
      border-radius: 8px;
      border: 1px solid #ccc;
      box-sizing: border-box;
    }

    .output {
      background: #fafafa;
      padding: 15px;
      border-radius: 10px;
      margin-top: 20px;
      white-space: pre-wrap;
    }

    button {
      background: var(--primary);
      color: white;
      border: none;
      margin-top: 15px;
      cursor: pointer;
    }

    button:hover {
      background: #574fd9;
    }

    .image-preview {
      margin-top: 20px;
      text-align: center;
    }

    .image-preview img {
      max-width: 100%;
      border-radius: 12px;
      box-shadow: 0 4px 12px rgba(0,0,0,0.1);
    }

    .gallery {
      margin-top: 40px;
    }

    .gallery img {
      max-width: 100%;
      margin: 10px 0;
      border-radius: 12px;
      box-shadow: 0 4px 8px rgba(0,0,0,0.1);
    }

    @media (max-width: 600px) {
      .container {
        padding: 20px;
        margin: 10px;
      }
    }
  </style>
</head>
<body>

<header>🎨 AIイラストプロンプトメーカー</header>
<div class="container">
  <div class="form-group">
    <label>キャラクター</label>
    <select id="subject">
      <option>狐の少女</option>
      <option>魔法少女</option>
      <option>サイバーパンクの戦士</option>
      <option>猫耳の少年</option>
      <option>吸血鬼の姫</option>
      <option>天使の戦士</option>
      <option>竜騎士</option>
      <option>宇宙探検家</option>
      <option>妖精</option>
      <option>獣人の女王</option>
      <option>機械生命体</option>
      <option>デジタル妖精</option>
      <option>スチームパンクの発明家</option>
      <option>氷の精霊</option>
      <option>砂漠の王女</option>
      <option>未来の警察官</option>
      <option>忍者少女</option>
      <option>異世界の商人</option>
      <option>獣耳魔法使い</option>
      <option>雷の神</option>
      <option>闇の王子</option>
      <option>炎の踊り子</option>
      <option>時間を操る司書</option>
      <option>ポストアポカリプスの旅人</option>
      <option>音楽を操る精霊</option>
      <option>風の精霊使い</option>
      <option>森の守護者</option>
      <option>未来の芸術家</option>
      <option>魔界の王女</option>
      <option>電脳世界のハッカー</option>
      <option>サーカス団の団長</option>
      <option>時計仕掛けの人形</option>
      <option>図書館の魔導師</option>
      <option>宇宙アイドル</option>
      <option>夢の案内人</option>
      <option>四季を司る女神</option>
      <option>時間旅行者</option>
      <option>戦場の傭兵</option>
      <option>星を読む預言者</option>
      <option>電脳巫女</option>
      <option>古代の王</option>
      <option>未来の料理人</option>
      <option>天空の案内人</option>
      <option>人魚の王女</option>
      <option>炎を纏う戦士</option>
      <option>異界の騎士</option>
      <option>霧の中の影</option>
      <option>雷獣の化身</option>
      <option>植物を操る錬金術師</option>
      <option>おとぎ話の語り部</option>
      <option>重力を操る少女</option>
      <option>古の預言者</option>
      <option>空を旅する郵便屋</option>
      <option>眠りを司る精霊</option>
      <option>お祭りの踊り子</option>
      <option>砂嵐の遊牧民</option>
      <option>泡の海の守護者</option>
      <option>魔法道具職人</option>
      <option>氷と炎の二重人格者</option>
      <option>デジタル世界の探偵</option>
      <option>孤独な塔の詩人</option>
      <option>空飛ぶ書斎の管理人</option>
      <option>鏡の中の分身</option>
      <option>古城に棲む亡霊</option>
      <option>地下世界の旅人</option>
      <option>異星文明の観測者</option>
      <option>時間停止の魔術師</option>
      <option>夢の記録者</option>
      <option>霧の海の漁師</option>
      <option>重力反転の案内人</option>
      <option>時空の管理者</option>
      <option>流星に乗る観測者</option>
      <option>伝説の召喚士</option>
      <option>影を操る使者</option>
      <option>魔法にかけられた人形</option>
      <option>未来都市のDJ</option>
      <option>空想世界の画家</option>
      <option>炎の道化師</option>
      <option>廃墟に住む猫型ロボット</option>
      <option>雲の牧場の飼育員</option>
      <option>月光に踊る騎士</option>
      <option>泡でできた人間</option>
      <option>時の狭間に生きる者</option>
    </select>
  </div>

  <div class="form-group">
    <label>衣装</label>
    <select id="clothing">
      <option>着物</option>
      <option>ゴスロリ</option>
      <option>セーラー服</option>
      <option>鎧</option>
      <option>メイド服</option>
      <option>学生服</option>
      <option>忍者装束</option>
      <option>水着</option>
      <option>アイドル衣装</option>
      <option>宇宙服</option>
      <option>ウェディングドレス</option>
      <option>スーツ</option>
      <option>チャイナドレス</option>
      <option>パーカーとジーンズ</option>
      <option>ボロボロの服</option>
      <option>軍服</option>
      <option>ドレスアーマー</option>
      <option>モダンファッション</option>
      <option>魔導士のローブ</option>
      <option>未来的スーツ</option>
      <option>フリルのついたロングドレス</option>
      <option>サイバースーツ</option>
      <option>狩人の装束</option>
      <option>伝統的な王族の衣装</option>
      <option>修道女の服</option>
      <option>ポンチョスタイル</option>
      <option>レザージャケット</option>
      <option>花柄のワンピース</option>
      <option>ホログラムドレス</option>
      <option>羽付きの礼装</option>
      <option>ロリータドレス</option>
      <option>海賊風コート</option>
      <option>スポーツユニフォーム</option>
      <option>カウガールスタイル</option>
      <option>研究者の白衣</option>
      <option>錬金術師のローブ</option>
    </select>
  </div>

  <div class="form-group">
    <label>シチュエーション</label>
    <select id="scene">
      <option>桜の下</option>
      <option>未来都市</option>
      <option>夕焼けの海辺</option>
      <option>廃墟の寺院</option>
      <option>暗い森</option>
      <option>宇宙船の中</option>
      <option>雪山</option>
      <option>草原</option>
      <option>古代遺跡</option>
      <option>空中庭園</option>
      <option>星空の下</option>
      <option>雨の街角</option>
      <option>異世界の市場</option>
      <option>火山地帯</option>
      <option>地下の書庫</option>
      <option>空港の滑走路</option>
      <option>無重力空間</option>
      <option>深海の遺跡</option>
      <option>サーカス会場</option>
      <option>魔法学園の中庭</option>
      <option>闘技場</option>
      <option>王宮のバルコニー</option>
      <option>雲の上</option>
      <option>ネオン輝く夜の街</option>
      <option>幽霊船の甲板</option>
      <option>滝の裏の洞窟</option>
    </select>
  </div>

  <div class="form-group">
    <label>表情</label>
    <select id="emotion">
      <option>微笑んでいる</option>
      <option>驚いている</option>
      <option>泣いている</option>
      <option>真剣な表情</option>
      <option>照れている</option>
      <option>怒っている</option>
      <option>眠そう</option>
      <option>無表情</option>
      <option>ウィンクしている</option>
      <option>笑いながら泣いている</option>
      <option>楽しそうに笑っている</option>
      <option>不機嫌そうな顔</option>
      <option>恥ずかしそうに俯いている</option>
      <option>驚愕している</option>
      <option>勝ち誇っている</option>
      <option>安心している</option>
      <option>寂しげな表情</option>
      <option>苦悩している</option>
    </select>
  </div>

  <div class="form-group">
    <label>スタイル</label>
    <select id="style">
      <option>アニメ調</option>
      <option>リアル調</option>
      <option>水彩風</option>
      <option>ピクセルアート</option>
      <option>モノクロスケッチ</option>
      <option>デフォルメ</option>
      <option>シネマティック</option>
      <option>油絵風</option>
      <option>幻想的</option>
      <option>ミッドセンチュリー</option>
      <option>ドット絵</option>
      <option>イラスト風3D</option>
      <option>和風アート</option>
      <option>ポップアート</option>
      <option>ネオンアート</option>
      <option>墨絵風</option>
      <option>ミニマリスト</option>
      <option>ダークファンタジー</option>
    </select>
  </div>

  <div class="form-group">
    <label>ライティング</label>
    <select id="lighting">
      <option>夕日</option>
      <option>月明かり</option>
      <option>逆光</option>
      <option>スポットライト</option>
      <option>柔らかい光</option>
      <option>ネオンライト</option>
      <option>キャンドルライト</option>
      <option>青白い光</option>
      <option>モノクローム</option>
      <option>ファンタジー風</option>
      <option>雷光</option>
      <option>神秘的な光</option>
      <option>焚き火の明かり</option>
      <option>都市の夜明かり</option>
      <option>レーザーライト</option>
      <option>朝焼け</option>
      <option>逆光のシルエット</option>
    </select>
  </div>

  <div class="form-group">
    <label>構図</label>
    <select id="aspect">
      <option>全身</option>
      <option>バストアップ</option>
      <option>クローズアップ</option>
      <option>後ろ姿</option>
      <option>斜め上から</option>
      <option>ローアングル</option>
      <option>ハイアングル</option>
      <option>俯瞰図</option>
      <option>対面</option>
      <option>ポートレート</option>
      <option>シルエット</option>
      <option>鏡越しの視点</option>
      <option>一部だけ見せる</option>
      <option>肩越し視点</option>
      <option>手元のアップ</option>
      <option>足元のアップ</option>
      <option>寝転んだ構図</option>
      <option>振り返った構図</option>
    </select>
  </div>


  <button onclick="generatePrompt()">✨ プロンプト生成</button>
  <button onclick="copyPrompt()">📋 コピー</button>

  <div class="output" id="japaneseOutput"></div>
  <div class="output" id="englishOutput"></div>
  <div class="output"><strong>🚫 Negative Prompt:</strong><br><span id="negativePrompt"></span></div>

  <div class="form-group">
    <label>🎨 画像アップロード(作品ギャラリー)</label>
    <input type="file" accept="image/*" onchange="previewUpload(event)">
  </div>

  <div class="gallery" id="gallery"></div>
</div>

<script>
  function generatePrompt() {
    const subject = document.getElementById("subject").value;
    const clothing = document.getElementById("clothing").value;
    const scene = document.getElementById("scene").value;
    const emotion = document.getElementById("emotion").value;
    const style = document.getElementById("style").value;
    const lighting = document.getElementById("lighting").value;
    const aspect = document.getElementById("aspect").value;

    const ja = `「${scene}」で「${clothing}」を着た「${subject}」が「${emotion}」表情をしている。「${style}」「${lighting}」で「${aspect}」構図。`;
    const en = `${translate(subject)}, wearing ${translate(clothing)}, ${translate(scene)}, ${translate(emotion)}, ${translate(style)}, ${translate(lighting)}, ${translate(aspect)}, masterpiece, best quality`;

    document.getElementById("japaneseOutput").textContent = "📝 日本語説明:\n" + ja;
    document.getElementById("englishOutput").textContent = "🧩 English Tags:\n" + en;
    document.getElementById("negativePrompt").textContent = "low quality, bad anatomy, blurry, extra limbs, deformed, watermark, text, signature";
  }

  function copyPrompt() {
    const ja = document.getElementById("japaneseOutput").textContent;
    const en = document.getElementById("englishOutput").textContent;
    const neg = document.getElementById("negativePrompt").textContent;
    const full = `${ja}\n\n${en}\n\nNegative Prompt:\n${neg}`;
    navigator.clipboard.writeText(full).then(() => alert("プロンプトをコピーしました!"));
  }

  function previewUpload(event) {
    const files = event.target.files;
    const gallery = document.getElementById("gallery");
    for (let i = 0; i < files.length; i++) {
      const reader = new FileReader();
      reader.onload = function(e) {
        const img = document.createElement("img");
        img.src = e.target.result;
        gallery.appendChild(img);
      }
      reader.readAsDataURL(files[i]);
    }
  }

  function translate(text) {
    const dict = {
      "狐の少女": "fox girl", "魔法少女": "magical girl", "サイバーパンクの戦士": "cyberpunk warrior",
      "猫耳の少年": "catboy", "吸血鬼の姫": "vampire princess",
      "着物": "kimono", "ゴスロリ": "gothic lolita", "セーラー服": "sailor uniform", "鎧": "armor", "メイド服": "maid outfit",
      "桜の下": "under cherry blossoms", "未来都市": "in futuristic city", "夕焼けの海辺": "on sunset beach", "廃墟の寺院": "in ruined temple", "暗い森": "in dark forest",
      "微笑んでいる": "smiling", "驚いている": "surprised", "泣いている": "crying", "真剣な表情": "serious", "照れている": "blushing",
      "アニメ調": "anime style", "リアル調": "realistic", "水彩風": "watercolor", "ピクセルアート": "pixel art", "モノクロスケッチ": "monochrome sketch",
      "夕日": "sunset lighting", "月明かり": "moonlight", "逆光": "backlight", "スポットライト": "spotlight", "柔らかい光": "soft light",
      "全身": "full body", "バストアップ": "bust-up", "クローズアップ": "close-up"
    };
    return dict[text] || text;
  }
</script>

</body>
</html>

GSAP入門 Tween編

index.html

<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>My GSAP</title>
    <link rel="stylesheet" href="style.css">
</head>

<body>
    <header>
        <h1>MySite</h1>
        <nav>
            <ul>
                <li>Menu</li>
                <li>Menu</li>
                <li>Menu</li>
            </ul>
        </nav>
    </header>

    <script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>
    <script src="main.js"></script>
</body>

</html>

main.js

'use strict';

{
    gsap.from('h1', {
        y: -32,
        opacity: 0,
    });

    gsap.from('li', {
        y: 32,
        opacity: 0,
        stagger: 0.3,
    });
}

style.css

@charset "utf-8";

header {
    padding: 16px;
    display: flex;
    justify-content: space-between;
    align-items: center;
}

h1 {
    margin: 0;
}

ul {
    margin: 0;
    padding: 0;
    list-style: none;
    display: flex;
    gap: 32px;
}

Javascript 迷路

index.html

<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="utf-8">
    <title>My Maze</title>
</head>

<body>
    <canvas>
        Canvas not supported ...
    </canvas>

    <script src="js/main.js"></script>
</body>

</html>

main.js

'use strict';

(() => {
    class MazeRenderer {
        constructor(canvas) {
            this.ctx = canvas.getContext('2d');
            this.WALL_SIZE = 10;
        }

        render(data) {
            canvas.height = data.length * this.WALL_SIZE;
            canvas.width = data[0].length * this.WALL_SIZE;

            for (let row = 0; row < data.length; row++) {
                for (let col = 0; col < data[0].length; col++) {
                    if (data[row][col] === 1) {
                        this.ctx.fillRect(
                            col * this.WALL_SIZE,
                            row * this.WALL_SIZE,
                            this.WALL_SIZE,
                            this.WALL_SIZE
                        );
                    }
                }
            }
        }
    }

    class Maze {
        constructor(row, col, renderer) {
            if (row < 5 || col < 5 || row % 2 === 0 || col % 2 === 0) {
                alert('Size not valid!');
                return;
            }

            this.renderer = renderer;
            this.row = row;
            this.col = col;
            this.data = this.getData();
        }

        getData() {
            const data = [];

            for (let row = 0; row < this.row; row++) {
                data[row] = [];
                for (let col = 0; col < this.col; col++) {
                    data[row][col] = 1;
                }
            }

            for (let row = 1; row < this.row - 1; row++) {
                for (let col = 1; col < this.col - 1; col++) {
                    data[row][col] = 0;
                }
            }

            for (let row = 2; row < this.row - 2; row += 2) {
                for (let col = 2; col < this.col - 2; col += 2) {
                    data[row][col] = 1;
                }
            }

            for (let row = 2; row < this.row - 2; row += 2) {
                for (let col = 2; col < this.col - 2; col += 2) {
                    let destRow;
                    let destCol;

                    do {
                        const dir = row === 2 ?
                            Math.floor(Math.random() * 4) :
                            Math.floor(Math.random() * 3) + 1;
                        switch (dir) {
                            case 0: // up
                                destRow = row - 1;
                                destCol = col;
                                break;
                            case 1: // down
                                destRow = row + 1;
                                destCol = col;
                                break;
                            case 2: // left
                                destRow = row;
                                destCol = col - 1;
                                break;
                            case 3: // right
                                destRow = row;
                                destCol = col + 1;
                                break;
                        }
                    } while (data[destRow][destCol] === 1);

                    data[destRow][destCol] = 1;
                }
            }

            return data;
        }

        render() {
            this.renderer.render(this.data);
        }
    }

    const canvas = document.querySelector('canvas');
    if (typeof canvas.getContext === 'undefined') {
        return;
    }

    const maze = new Maze(21, 15, new MazeRenderer(canvas));
    maze.render();
})();

MyCarousel

index.html

<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>My Carousel</title>
    <link rel="stylesheet" href="css/style.css">
</head>

<body>
    <section class="carousel">
        <div class="container">
            <ul>
                <li><img src="img/pic1.png"></li>
                <li><img src="img/pic2.png"></li>
                <li><img src="img/pic3.png"></li>
                <li><img src="img/pic4.png"></li>
            </ul>

            <button id="prev">&laquo;</button>
            <button id="next">&raquo;</button>
        </div>

        <nav>
        </nav>
    </section>

    <script src="js/main.js"></script>
</body>

</html>

js/main.js

'use strict';

{
    const next = document.getElementById('next');
    const prev = document.getElementById('prev');
    const ul = document.querySelector('ul');
    const slides = ul.children;
    const dots = [];
    let currentIndex = 0;

    function updateButtons() {
        prev.classList.remove('hidden');
        next.classList.remove('hidden');

        if (currentIndex === 0) {
            prev.classList.add('hidden');
        }
        if (currentIndex === slides.length - 1) {
            next.classList.add('hidden');
        }
    }

    function moveSlides() {
        const slideWidth = slides[0].getBoundingClientRect().width;
        ul.style.transform = `translateX(${-1 * slideWidth * currentIndex}px)`;
    }

    function setupDots() {
        for (let i = 0; i < slides.length; i++) {
            const button = document.createElement('button');
            button.addEventListener('click', () => {
                currentIndex = i;
                updateDots();
                updateButtons();
                moveSlides();
            });
            dots.push(button);
            document.querySelector('nav').appendChild(button);
        }

        dots[0].classList.add('current');
    }

    function updateDots() {
        dots.forEach(dot => {
            dot.classList.remove('current');
        });
        dots[currentIndex].classList.add('current');
    }

    updateButtons();
    setupDots();

    next.addEventListener('click', () => {
        currentIndex++;
        updateButtons();
        updateDots();
        moveSlides();
    });

    prev.addEventListener('click', () => {
        currentIndex--;
        updateButtons();
        updateDots();
        moveSlides();
    });

    window.addEventListener('resize', () => {
        moveSlides();
    });
}

css/style.css

.carousel {
    width: 80%;
    margin: 16px auto;
}

.container {
    width: 100%;
    height: 220px;
    overflow: hidden;
    position: relative;
}

ul {
    list-style: none;
    margin: 0;
    padding: 0;
    height: 100%;
    display: flex;
    transition: transform .3s;
}

li {
    height: 100%;
    min-width: 100%;
}

li img {
    width: 100%;
    height: 100%;
    object-fit: cover;
}

#prev,
#next {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    border: none;
    background: rgba(0, 0, 0, .8);
    color: #fff;
    font-size: 24px;
    padding: 0 8px 4px;
    cursor: pointer;
}

#prev:hover,
#next:hover {
    opacity: .8;
}

#prev {
    left: 0;
}

#next {
    right: 0;
}

.hidden {
    display: none;
}

nav {
    margin-top: 16px;
    text-align: center;
}

nav button+button {
    margin-left: 8px;
}

nav button {
    border: none;
    width: 16px;
    height: 16px;
    background: #ddd;
    border-radius: 50%;
    cursor: pointer;
}

nav .current {
    background: #999;
}

JavaScriptモーダルウィンドウ

index.html

<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="utf-8">
    <title>Modal Window</title>
    <link rel="stylesheet" href="css/styles.css">
</head>

<body>
    <div id="open">
        詳細を見る
    </div>

    <div id="mask" class="hidden"></div>

    <section id="modal" class="hidden">
        <p>こんにちは。こんにちは。こんにちは。こんにちは。こんにちは。こんにちは。こんにちは。こんにちは。こんにちは。こんにちは。</p>
        <div id="close">
            閉じる
        </div>
    </section>

    <script src="js/main.js"></script>
</body>

</html>

css/style.css

body {
    font-size: 14px;
}

#open,
#close {
    cursor: pointer;
    width: 200px;
    border: 1px solid #ccc;
    border-radius: 4px;
    text-align: center;
    padding: 12px 0;
    margin: 16px auto 0;
}

#mask {
    background: rgba(0, 0, 0, 0.4);
    position: fixed;
    top: 0;
    bottom: 0;
    right: 0;
    left: 0;
    z-index: 1;
}

#modal {
    background: #fff;
    width: 300px;
    padding: 20px;
    border-radius: 4px;
    position: absolute;
    top: 40px;
    left: 0;
    right: 0;
    margin: 0 auto;
    transition: transform 0.4s;
    z-index: 2;
}

#modal>p {
    margin: 0 0 20px;
}

#mask.hidden {
    display: none;
}

#modal.hidden {
    transform: translate(0, -500px);
}

/js/main.js

'use strict';

{
    const open = document.getElementById('open');
    const close = document.getElementById('close');
    const modal = document.getElementById('modal');
    const mask = document.getElementById('mask');

    open.addEventListener('click', () => {
        modal.classList.remove('hidden');
        mask.classList.remove('hidden');
    });

    close.addEventListener('click', () => {
        modal.classList.add('hidden');
        mask.classList.add('hidden');
    });

    mask.addEventListener('click', () => {
        // modal.classList.add('hidden');
        // mask.classList.add('hidden');
        close.click();
    });
}

MyTabMenu

index.html

<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="utf-8">
    <title>Tab Menu</title>
    <link rel="stylesheet" href="css/styles.css">
</head>

<body>
    <div class="container">
        <ul class="menu">
            <li><a href="#" class="active" data-id="about">サイトの概要</a></li>
            <li><a href="#" data-id="service">サービス内容</a></li>
            <li><a href="#" data-id="contact">お問い合わせ</a></li>
        </ul>

        <section class="content active" id="about">
            サイトの概要。サイトの概要。サイトの概要。サイトの概要。サイトの概要。サイトの概要。サイトの概要。サイトの概要。サイトの概要。サイトの概要。サイトの概要。サイトの概要。
        </section>

        <section class="content" id="service">
            サービス内容。サービス内容。サービス内容。サービス内容。サービス内容。サービス内容。サービス内容。サービス内容。サービス内容。サービス内容。サービス内容。サービス内容。
        </section>

        <section class="content" id="contact">
            お問い合わせ。お問い合わせ。お問い合わせ。お問い合わせ。お問い合わせ。お問い合わせ。お問い合わせ。お問い合わせ。お問い合わせ。お問い合わせ。お問い合わせ。お問い合わせ。
        </section>
    </div>

    <script src="js/main.js"></script>
</body>

</html>

css/styles.css

body {
    font-size: 14px;
}

.container {
    margin: 30px auto;
    width: 500px;
}

.menu {
    list-style: none;
    padding: 0;
    margin: 0;
    display: flex;
}

.menu li a {
    display: inline-block;
    width: 100px;
    text-align: center;
    padding: 8px 0;
    color: #333;
    text-decoration: none;
    border-radius: 4px 4px 0 0;
}

.menu li a.active {
    background: #333;
    color: #fff;
}

.menu li a:not(.active):hover {
    opacity: 0.5;
    transition: opacity 0.4s;
}

.content.active {
    background: #333;
    color: #fff;
    min-height: 150px;
    padding: 12px;
    display: block;
}

.content {
    display: none;
}

js/main.js

'use strict';

{
    const menuItems = document.querySelectorAll('.menu li a');
    const contents = document.querySelectorAll('.content');

    menuItems.forEach(clickedItem => {
        clickedItem.addEventListener('click', e => {
            e.preventDefault();

            menuItems.forEach(item => {
                item.classList.remove('active');
            });
            clickedItem.classList.add('active');

            contents.forEach(content => {
                content.classList.remove('active');
            });
            document.getElementById(clickedItem.dataset.id).classList.add('active');
        });
    });
}

MyHamburgerMenu

index.html

<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="utf-8">
  <title>FAQ</title>
  <link rel="stylesheet" href="css/styles.css">
</head>

<body>
  <h1>FAQ</h1>
  <dl>
    <div>
      <dt>質問です</dt>
      <dd>回答です。回答です。回答です。回答です。回答です。回答です。回答です。回答です。回答です。回答です。回答です。回答です。回答です。</dd>
    </div>
    <div>
      <dt>質問です</dt>
      <dd>回答です。回答です。回答です。回答です。回答です。回答です。回答です。回答です。回答です。回答です。回答です。回答です。回答です。</dd>
    </div>
    <div>
      <dt>質問です</dt>
      <dd>回答です。回答です。回答です。回答です。回答です。回答です。回答です。回答です。回答です。回答です。回答です。回答です。回答です。</dd>
    </div>
  </dl>

  <script src="js/main.js"></script>
</body>

</html>

style.css

h1 {
    font-size: 18px;
    border-bottom: 1px solid;
    padding: 8px 16px;
    margin-bottom: 16px;
}

dl {
    margin: 0;
}

dl>div {
    margin-bottom: 8px;
}

dt {
    padding: 8px;
    cursor: pointer;
    user-select: none;
    position: relative;
}

dt::before {
    content: 'Q. ';
}

dt::after {
    content: '+';
    position: absolute;
    top: 8px;
    right: 16px;
    transition: transform .3s;
}

dl>div.appear dt::after {
    transform: rotate(45deg);
}

dd {
    padding: 8px;
    margin: 0;
    display: none;
}

dd::before {
    content: 'A .';
}

dl>div.appear dd {
    display: block;
    animation: .3s fadeIn;
}

@keyframes fadeIn {
    0% {
        opacity: 0;
        transform: translateY(-10px);
    }

    100% {
        opacity: 1;
        transform: none;
    }
}

main.js

'use strict';

{
  const dts = document.querySelectorAll('dt');

  dts.forEach(dt => {
    dt.addEventListener('click', () => {
      dt.parentNode.classList.toggle('appear');
    });
  });
}

Javascript RPG


<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Epic RPG</title>
  <style>
    body {
      font-family: Arial, sans-serif;
      margin: 20px;
    }
    #game-log {
      background: #f4f4f4;
      padding: 10px;
      margin-bottom: 20px;
      height: 200px;
      overflow-y: auto;
      border: 1px solid #ddd;
    }
    button {
      margin: 5px;
      padding: 10px;
    }
    #player-stats {
      margin-bottom: 20px;
    }
  </style>
</head>
<body>
  <h1>Epic RPG</h1>
  <div id="player-stats">
    <p><strong>Name:</strong> <span id="player-name">Hero</span></p>
    <p><strong>HP:</strong> <span id="player-hp">100</span>/<span id="player-max-hp">100</span></p>
    <p><strong>MP:</strong> <span id="player-mp">50</span>/<span id="player-max-mp">50</span></p>
    <p><strong>Level:</strong> <span id="player-level">1</span></p>
    <p><strong>EXP:</strong> <span id="player-exp">0</span>/100</p>
    <p><strong>Gold:</strong> <span id="player-gold">0</span></p>
    <p><strong>Inventory:</strong> <span id="player-inventory">Potion x1</span></p>
    <p><strong>Skills:</strong> <span id="player-skills">Fireball</span></p>
    <p><strong>Equipped Weapon:</strong> <span id="player-weapon">None</span></p>
    <p><strong>Equipped Armor:</strong> <span id="player-armor">None</span></p>
    <p><strong>Current Quest:</strong> <span id="player-quest">None</span></p>
    <p><strong>Stage:</strong> <span id="current-stage">1</span></p>
  </div>
  <div id="game-log"></div>
  <button onclick="attack()">Attack</button>
  <button onclick="useSkill()">Use Skill</button>
  <button onclick="heal()">Heal</button>
  <button onclick="openShop()">Shop</button>
  <button onclick="acceptQuest()">Quest</button>
  <button onclick="craftItem()">Craft Item</button>
  <button onclick="nextStage()">Next Stage</button>
  <button onclick="restart()">Restart</button>
  <script>
    // プレイヤーと敵のデータ
    let player = {
      name: "Hero",
      hp: 100,
      maxHp: 100,
      mp: 50,
      maxMp: 50,
      attackPower: 10,
      defense: 5,
      exp: 0,
      level: 1,
      gold: 50,
      inventory: ["Potion", "Iron Ore"],
      skills: ["Fireball"],
      weapon: null,
      armor: null,
      quest: null,
      stage: 1,
    };

    let enemy = {
      name: "Goblin",
      hp: 50,
      maxHp: 50,
      attackPower: 8,
      defense: 3,
    };

    const quests = [
      { name: "Defeat 3 Goblins", progress: 0, goal: 3, reward: 100 },
      { name: "Collect 2 Potions", progress: 0, goal: 2, reward: 50 },
    ];

    const stages = [
      { stage: 1, description: "The Forest of Beginnings", enemies: ["Goblin", "Orc"] },
      { stage: 2, description: "The Cursed Mines", enemies: ["Dark Bat", "Skeleton"] },
      { stage: 3, description: "The Dragon's Lair", enemies: ["Fire Dragon"] },
    ];

    // ゲームログ表示関数
    function log(message) {
      const logDiv = document.getElementById("game-log");
      logDiv.innerHTML += `<p>${message}</p>`;
      logDiv.scrollTop = logDiv.scrollHeight;
    }

    // プレイヤーの攻撃
    function attack() {
      const damage = Math.max(Math.floor(Math.random() * player.attackPower) - enemy.defense, 1);
      enemy.hp -= damage;
      log(`You attack the ${enemy.name} for ${damage} damage!`);
      if (enemy.hp <= 0) {
        log(`You defeated the ${enemy.name}!`);
        gainExp(20);
        gainGold(Math.floor(Math.random() * 20) + 10);
        updateQuestProgress("Defeat 3 Goblins");
        spawnNewEnemy();
        return;
      }
      enemyAttack();
    }

    // 敵の攻撃
    function enemyAttack() {
      const damage = Math.max(Math.floor(Math.random() * enemy.attackPower) - player.defense, 0);
      player.hp -= damage;
      log(`The ${enemy.name} attacks you for ${damage} damage! Current HP: ${player.hp}`);
      if (player.hp <= 0) {
        log("You have been defeated...");
        log("Press 'Restart' to try again.");
      }
      updateStats();
    }

    // ステージ移動
    function nextStage() {
      player.stage++;
      const currentStage = stages.find(stage => stage.stage === player.stage);
      if (!currentStage) {
        log("Congratulations! You have completed the game!");
        return;
      }
      log(`You enter the ${currentStage.description}.`);
      spawnNewEnemy();
      updateStats();
    }

    // 新しい敵を生成
    function spawnNewEnemy() {
      const currentStage = stages.find(stage => stage.stage === player.stage);
      const randomEnemyName = currentStage.enemies[Math.floor(Math.random() * currentStage.enemies.length)];
      enemy = {
        name: randomEnemyName,
        hp: Math.floor(Math.random() * 30 + 50),
        maxHp: Math.floor(Math.random() * 30 + 50),
        attackPower: Math.floor(Math.random() * 5 + 10),
        defense: Math.floor(Math.random() * 5),
      };
      log(`A wild ${enemy.name} appears with ${enemy.hp} HP!`);
    }

    // アイテムクラフト
    function craftItem() {
      if (player.inventory.includes("Iron Ore")) {
        player.inventory.splice(player.inventory.indexOf("Iron Ore"), 1);
        player.weapon = "Iron Sword";
        player.attackPower += 5;
        log("You crafted an Iron Sword! Attack power increased by 5.");
      } else {
        log("You don't have the required materials to craft an item.");
      }
      updateStats();
    }

    // ステータス更新
    function updateStats() {
      document.getElementById("player-name").innerText = player.name;
      document.getElementById("player-hp").innerText = player.hp;
      document.getElementById("player-max-hp").innerText = player.maxHp;
      document.getElementById("player-mp").innerText = player.mp;
      document.getElementById("player-max-mp").innerText = player.maxMp;
      document.getElementById("player-level").innerText = player.level;
      document.getElementById("player-exp").innerText = player.exp;
      document.getElementById("player-gold").innerText = player.gold;
      document.getElementById("player-inventory").innerText = player.inventory.join(", ") || "Empty";
      document.getElementById("player-skills").innerText = player.skills.join(", ") || "None";
      document.getElementById("player-weapon").innerText = player.weapon || "None";
      document.getElementById("player-armor").innerText = player.armor || "None";
      document.getElementById("player-quest").innerText = player.quest ? player.quest.name : "None";
      document.getElementById("current-stage").innerText = player.stage;
    }

    // 初期化
    log("Welcome to the RPG! A Goblin appears!");
    updateStats();
    spawnNewEnemy();
  </script>
</body>
</html>