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>

投稿者: chosuke

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

コメントを残す

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