Vooglebrowser


<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>Voogle - Mini Browser</title>
<style>
  :root{
    --bg:#0b0f14;
    --panel: rgba(255,255,255,.06);
    --stroke: rgba(255,255,255,.10);
    --ink:#eaf0ff;
    --muted: rgba(234,240,255,.70);
    --accent:#7cf0ff;
    --accent2:#7ca0ff;
    --danger:#ff6b6b;
    --ok:#79ffa7;
    --shadow: 0 18px 50px rgba(0,0,0,.35);
    --radius:16px;
    --radius2:22px;
    --glass: blur(14px) saturate(1.2);
  }
  [data-theme="light"]{
    --bg:#f6f7fb;
    --panel: rgba(0,0,0,.05);
    --stroke: rgba(0,0,0,.10);
    --ink:#101828;
    --muted: rgba(16,24,40,.68);
    --shadow: 0 18px 50px rgba(16,24,40,.12);
  }
  *{box-sizing:border-box}
  html,body{height:100%}
  body{
    margin:0;
    background: radial-gradient(1200px 800px at 20% 10%, rgba(124,240,255,.15), transparent 60%),
                radial-gradient(1200px 800px at 80% 20%, rgba(124,160,255,.14), transparent 60%),
                var(--bg);
    color:var(--ink);
    font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans JP", Arial;
    overflow:hidden;
  }
  .app{
    height:100%;
    display:grid;
    grid-template-columns: 320px 1fr;
    gap: 12px;
    padding: 12px;
  }
  .card{
    background: linear-gradient(180deg, rgba(255,255,255,.08), rgba(255,255,255,.03));
    border: 1px solid var(--stroke);
    border-radius: var(--radius2);
    box-shadow: var(--shadow);
    backdrop-filter: var(--glass);
    overflow:hidden;
    min-height:0;
  }
  .sidebar{display:flex; flex-direction:column; min-height:0;}
  .main{display:flex; flex-direction:column; min-height:0;}

  /* Topbar */
  .topbar{
    display:flex;
    align-items:center;
    gap:10px;
    padding:10px;
    border-bottom:1px solid var(--stroke);
    background: rgba(0,0,0,.08);
  }
  [data-theme="light"] .topbar{ background: rgba(255,255,255,.55); }
  .brand{
    display:flex; align-items:center; gap:10px;
    padding:10px;
    border-bottom:1px solid var(--stroke);
  }
  .logo{
    width:34px; height:34px; border-radius:12px;
    background: radial-gradient(circle at 30% 30%, var(--accent), rgba(124,240,255,.0) 55%),
                radial-gradient(circle at 70% 70%, var(--accent2), rgba(124,160,255,.0) 55%),
                rgba(255,255,255,.06);
    border:1px solid var(--stroke);
    box-shadow: 0 12px 30px rgba(124,240,255,.14);
  }
  .brand h1{
    font-size:14px; margin:0; letter-spacing:.4px;
  }
  .brand p{margin:0; font-size:12px; color:var(--muted)}
  .btn{
    appearance:none;
    border:1px solid var(--stroke);
    background: rgba(255,255,255,.06);
    color:var(--ink);
    padding:8px 10px;
    border-radius: 12px;
    cursor:pointer;
    transition: transform .08s ease, background .15s ease, border-color .15s ease;
    user-select:none;
    white-space:nowrap;
  }
  .btn:hover{ background: rgba(255,255,255,.10); border-color: rgba(124,240,255,.28); }
  .btn:active{ transform: scale(.98); }
  .btn.primary{
    border-color: rgba(124,240,255,.35);
    background: linear-gradient(180deg, rgba(124,240,255,.18), rgba(124,160,255,.10));
  }
  .btn.danger{
    border-color: rgba(255,107,107,.35);
    background: linear-gradient(180deg, rgba(255,107,107,.16), rgba(255,107,107,.08));
  }
  .btn.ok{
    border-color: rgba(121,255,167,.35);
    background: linear-gradient(180deg, rgba(121,255,167,.14), rgba(121,255,167,.07));
  }
  .icon{
    width:18px;height:18px;display:inline-grid;place-items:center;
    font-weight:700; opacity:.9;
  }

  /* Address */
  .addr{
    flex:1;
    display:flex;
    gap:10px;
    align-items:center;
    min-width:0;
  }
  .addr input{
    width:100%;
    min-width:0;
    padding:10px 12px;
    border-radius: 14px;
    border:1px solid var(--stroke);
    background: rgba(0,0,0,.14);
    color:var(--ink);
    outline:none;
  }
  [data-theme="light"] .addr input{ background: rgba(255,255,255,.75); }
  .hint{
    font-size:12px;
    color:var(--muted);
    padding: 0 12px 10px;
  }

  /* Tabs */
  .tabs{
    display:flex;
    gap:8px;
    padding:10px;
    border-bottom:1px solid var(--stroke);
    overflow:auto;
  }
  .tab{
    display:flex; align-items:center; gap:8px;
    padding:8px 10px;
    border-radius: 14px;
    border:1px solid var(--stroke);
    background: rgba(255,255,255,.06);
    cursor:pointer;
    min-width: 160px;
    max-width: 260px;
    flex: 0 0 auto;
  }
  .tab.active{
    border-color: rgba(124,240,255,.45);
    background: linear-gradient(180deg, rgba(124,240,255,.16), rgba(124,160,255,.10));
  }
  .tab .title{
    overflow:hidden;
    text-overflow:ellipsis;
    white-space:nowrap;
    font-size:13px;
    flex:1;
  }
  .pill{
    font-size:11px;
    color:var(--muted);
    border:1px solid var(--stroke);
    padding:2px 8px;
    border-radius:999px;
    background: rgba(0,0,0,.10);
  }
  [data-theme="light"] .pill{ background: rgba(255,255,255,.6); }
  .x{
    width:24px;height:24px; border-radius:10px;
    display:grid; place-items:center;
    border:1px solid var(--stroke);
    background: rgba(0,0,0,.10);
    opacity:.9;
  }
  .x:hover{ border-color: rgba(255,107,107,.5); }
  [data-theme="light"] .x{ background: rgba(255,255,255,.6); }

  /* Viewport */
  .viewport{
    position:relative;
    flex:1;
    min-height:0;
    background: rgba(0,0,0,.10);
  }
  [data-theme="light"] .viewport{ background: rgba(0,0,0,.03); }
  .frame{
    position:absolute; inset:0;
    width:100%; height:100%;
    border:0;
    background: transparent;
  }
  .overlay{
    position:absolute; inset: 14px;
    border-radius: 18px;
    border:1px dashed rgba(124,240,255,.35);
    display:none;
    place-items:center;
    text-align:center;
    padding:18px;
    background: rgba(0,0,0,.35);
    backdrop-filter: blur(10px);
  }
  [data-theme="light"] .overlay{ background: rgba(255,255,255,.78); }
  .overlay.show{ display:grid; }
  .overlay h2{margin:0 0 8px; font-size:16px;}
  .overlay p{margin:0 0 12px; color:var(--muted); font-size:13px;}
  .overlay .row{display:flex; gap:10px; flex-wrap:wrap; justify-content:center}

  /* Sidebar content */
  .section{
    padding:12px;
    border-top:1px solid var(--stroke);
    min-height:0;
    overflow:auto;
  }
  .section h3{
    margin:0 0 10px;
    font-size:12px;
    color:var(--muted);
    letter-spacing:.18em;
  }
  .list{
    display:flex;
    flex-direction:column;
    gap:8px;
  }
  .item{
    display:flex;
    gap:10px;
    align-items:center;
    padding:10px 10px;
    border-radius: 14px;
    border:1px solid var(--stroke);
    background: rgba(255,255,255,.05);
    cursor:pointer;
  }
  .item:hover{ border-color: rgba(124,240,255,.28); background: rgba(255,255,255,.08); }
  .item .meta{flex:1; min-width:0}
  .item .meta .t{
    font-size:13px;
    overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
  }
  .item .meta .s{
    font-size:12px; color:var(--muted);
    overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
  }
  .tag{
    font-size:11px;
    padding:2px 8px;
    border-radius: 999px;
    border:1px solid var(--stroke);
    color: var(--muted);
  }

  .footerbar{
    padding:10px 12px;
    border-top:1px solid var(--stroke);
    font-size:12px;
    color:var(--muted);
    display:flex;
    gap:12px;
    align-items:center;
    justify-content:space-between;
  }
  .kbd{
    border:1px solid var(--stroke);
    border-bottom-width:2px;
    padding:2px 6px;
    border-radius:8px;
    background: rgba(0,0,0,.10);
    font-size:11px;
    color:var(--muted);
    white-space:nowrap;
  }
  [data-theme="light"] .kbd{ background: rgba(255,255,255,.6); }

  .row{
    display:flex; gap:8px; flex-wrap:wrap;
  }
  .mini{
    font-size:12px;
    padding:6px 8px;
    border-radius: 12px;
  }

  .toast{
    position:fixed;
    right:14px; bottom:14px;
    padding:10px 12px;
    border-radius: 14px;
    border:1px solid var(--stroke);
    background: rgba(0,0,0,.50);
    backdrop-filter: blur(10px);
    color:var(--ink);
    box-shadow: var(--shadow);
    transform: translateY(10px);
    opacity:0;
    transition: .22s ease;
    pointer-events:none;
    max-width: min(420px, calc(100vw - 28px));
  }
  [data-theme="light"] .toast{ background: rgba(255,255,255,.86); }
  .toast.show{ transform: translateY(0); opacity:1; }
  .toast .small{ font-size:12px; color:var(--muted); margin-top:2px; }

  @media (max-width: 980px){
    .app{ grid-template-columns: 1fr; }
    .sidebar{ display:none; }
  }
</style>
</head>
<body data-theme="dark">
  <div class="app">
    <!-- Sidebar -->
    <aside class="card sidebar">
      <div class="brand">
        <div class="logo" aria-hidden="true"></div>
        <div>
          <h1>Voogle</h1>
          <p>Mini Browser (1-file)</p>
        </div>
      </div>

      <div class="section" style="border-top:none">
        <div class="row">
          <button class="btn mini primary" id="btnNewTab"><span class="icon">+</span>新規タブ</button>
          <button class="btn mini" id="btnToggleTheme"><span class="icon">☾</span>テーマ</button>
          <button class="btn mini" id="btnExport"><span class="icon">⤓</span>データ出力</button>
          <label class="btn mini" style="display:inline-flex; align-items:center; gap:8px; cursor:pointer;">
            <span class="icon">⤒</span>データ取込
            <input id="importFile" type="file" accept="application/json" style="display:none" />
          </label>
        </div>
        <div class="hint">※多くの外部サイトは埋め込み禁止。開けない時は「新しいタブで開く」。</div>
      </div>

      <div class="section">
        <h3>クイック</h3>
        <div class="list" id="quickList"></div>
      </div>

      <div class="section">
        <h3>ブックマーク</h3>
        <div class="list" id="bmList"></div>
      </div>

      <div class="section">
        <h3>履歴(最新20件)</h3>
        <div class="list" id="histList"></div>
      </div>

      <div class="footerbar">
        <div class="row" style="gap:6px">
          <span class="kbd">Ctrl</span>+<span class="kbd">L</span> アドレス
          <span class="kbd">Ctrl</span>+<span class="kbd">T</span> 新規
          <span class="kbd">Ctrl</span>+<span class="kbd">W</span> 閉じる
        </div>
        <span id="statusText">Ready</span>
      </div>
    </aside>

    <!-- Main -->
    <main class="card main">
      <div class="topbar">
        <button class="btn" id="btnBack" title="戻る"><span class="icon">←</span></button>
        <button class="btn" id="btnForward" title="進む"><span class="icon">→</span></button>
        <button class="btn" id="btnReload" title="更新"><span class="icon">↻</span></button>

        <div class="addr">
          <input id="addrInput" placeholder="URL または 検索ワード(例: https://example.com / openai)" autocomplete="off" />
        </div>

        <button class="btn primary" id="btnGo" title="移動"><span class="icon">⏎</span></button>
        <button class="btn" id="btnBookmark" title="ブックマーク"><span class="icon">☆</span></button>
        <button class="btn" id="btnOpenExternal" title="新しいタブで開く"><span class="icon">↗</span></button>
        <button class="btn" id="btnPip" title="PiP(対応サイトのみ)"><span class="icon">▣</span></button>
      </div>

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

      <div class="viewport">
        <iframe id="frame" class="frame" sandbox="allow-forms allow-modals allow-popups allow-same-origin allow-scripts allow-downloads"></iframe>

        <div class="overlay" id="overlay">
          <div>
            <h2>このページは埋め込みを拒否してる</h2>
            <p>
              たいていはサイト側のセキュリティ(X-Frame-Options / CSP)です。<br>
              下のボタンで外部タブとして開け。
            </p>
            <div class="row">
              <button class="btn ok" id="overlayOpenExternal"><span class="icon">↗</span>新しいタブで開く</button>
              <button class="btn" id="overlayTrySearch"><span class="icon">⌕</span>検索で開く</button>
              <button class="btn danger" id="overlayClose"><span class="icon">×</span>閉じる</button>
            </div>
          </div>
        </div>
      </div>

      <div class="footerbar">
        <div class="row" style="gap:10px; align-items:center">
          <span class="tag" id="originTag">—</span>
          <span class="tag" id="secureTag">—</span>
          <span class="tag" id="embedTag">—</span>
        </div>
        <div class="row" style="gap:8px">
          <button class="btn mini" id="btnHome">ホーム</button>
          <button class="btn mini" id="btnClearHistory">履歴クリア</button>
          <button class="btn mini danger" id="btnResetAll">全リセット</button>
        </div>
      </div>
    </main>
  </div>

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

<script>
(() => {
  // =========================
  // Utilities
  // =========================
  const $ = (sel) => document.querySelector(sel);
  const escapeHTML = (s) => (s ?? "").toString()
    .replaceAll("&","&amp;").replaceAll("<","&lt;")
    .replaceAll(">","&gt;").replaceAll('"',"&quot;").replaceAll("'","&#039;");
  const nowISO = () => new Date().toISOString();
  const isUrlLike = (text) => {
    const t = (text || "").trim();
    if (!t) return false;
    if (/^https?:\/\//i.test(t)) return true;
    if (/^[a-z0-9.-]+\.[a-z]{2,}([\/?#].*)?$/i.test(t)) return true;
    if (/^localhost(:\d+)?(\/.*)?$/i.test(t)) return true;
    if (/^\d{1,3}(\.\d{1,3}){3}(:\d+)?(\/.*)?$/.test(t)) return true;
    return false;
  };
  const normalizeToUrl = (text) => {
    let t = (text || "").trim();
    if (!t) return "";
    if (/^https?:\/\//i.test(t)) return t;
    if (isUrlLike(t)) return "https://" + t;
    return "";
  };
  const buildSearchUrl = (q) => "https://www.google.com/search?q=" + encodeURIComponent(q);

  const toast = (title, detail="") => {
    const el = $("#toast");
    el.innerHTML = `<div><b>${escapeHTML(title)}</b></div>${detail ? `<div class="small">${escapeHTML(detail)}</div>` : ""}`;
    el.classList.add("show");
    clearTimeout(toast._t);
    toast._t = setTimeout(() => el.classList.remove("show"), 2400);
  };

  const setStatus = (s) => { $("#statusText").textContent = s; };

  // =========================
  // Storage
  // =========================
  const KEY = "voogle.v1";
  const defaultState = () => ({
    theme: "dark",
    tabs: [
      { id: crypto.randomUUID(), title: "Home", url: "about:home", history: ["about:home"], hIndex: 0, createdAt: nowISO() }
    ],
    activeTabId: null,
    bookmarks: [
      { title:"OpenAI", url:"https://openai.com", addedAt: nowISO() },
      { title:"Wikipedia", url:"https://ja.wikipedia.org", addedAt: nowISO() },
      { title:"GitHub", url:"https://github.com", addedAt: nowISO() },
      { title:"MDN", url:"https://developer.mozilla.org/ja/", addedAt: nowISO() },
      { title:"YouTube", url:"https://www.youtube.com", addedAt: nowISO() },
    ],
    history: [],
    quick: [
      { title:"ニュース", url:"https://news.google.com/?hl=ja&gl=JP&ceid=JP:ja" },
      { title:"X", url:"https://x.com" },
      { title:"Reddit", url:"https://www.reddit.com" },
      { title:"Qiita", url:"https://qiita.com" },
      { title:"Zenn", url:"https://zenn.dev" },
      { title:"Google", url:"https://www.google.com" },
    ],
    settings: {
      homeUrl: "about:home",
      maxHistory: 200,
      sidebarHistoryView: 20
    }
  });

  const loadState = () => {
    try{
      const raw = localStorage.getItem(KEY);
      if(!raw) return defaultState();
      const s = JSON.parse(raw);
      // minimal migrate
      if(!s.tabs?.length) return defaultState();
      return s;
    }catch(e){
      console.warn(e);
      return defaultState();
    }
  };
  const saveState = () => localStorage.setItem(KEY, JSON.stringify(state));

  let state = loadState();

  // =========================
  // Tabs
  // =========================
  const getActiveTab = () => state.tabs.find(t => t.id === state.activeTabId) || state.tabs[0];
  const setActive = (id) => {
    state.activeTabId = id;
    saveState();
    renderAll();
    loadTabToViewport(getActiveTab());
  };

  const newTab = (url = "about:home", title = "New Tab") => {
    const tab = {
      id: crypto.randomUUID(),
      title,
      url,
      history: [url],
      hIndex: 0,
      createdAt: nowISO()
    };
    state.tabs.push(tab);
    state.activeTabId = tab.id;
    saveState();
    renderAll();
    loadTabToViewport(tab);
    toast("新規タブ", title);
  };

  const closeTab = (id) => {
    if(state.tabs.length <= 1){
      toast("これ以上閉じれない", "最低1タブは残る");
      return;
    }
    const idx = state.tabs.findIndex(t => t.id === id);
    if(idx < 0) return;
    const wasActive = state.activeTabId === id;
    const closed = state.tabs[idx];
    state.tabs.splice(idx,1);
    if(wasActive){
      const fallback = state.tabs[Math.max(0, idx-1)];
      state.activeTabId = fallback.id;
    }
    saveState();
    renderAll();
    loadTabToViewport(getActiveTab());
    toast("タブを閉じた", closed.title);
  };

  const setTabTitle = (tab, title) => {
    tab.title = (title || "Untitled").slice(0, 60);
    saveState();
    renderTabs();
  };

  const pushHistory = (tab, url) => {
    if(tab.history[tab.hIndex] === url) return;
    tab.history = tab.history.slice(0, tab.hIndex + 1);
    tab.history.push(url);
    tab.hIndex = tab.history.length - 1;
  };

  // =========================
  // History + Bookmarks
  // =========================
  const addGlobalHistory = (url, title="") => {
    if(!url || url === "about:home") return;
    state.history.unshift({ url, title, at: nowISO() });
    // de-dupe
    const seen = new Set();
    state.history = state.history.filter(h => {
      const k = h.url;
      if(seen.has(k)) return false;
      seen.add(k);
      return true;
    });
    state.history = state.history.slice(0, state.settings.maxHistory || 200);
    saveState();
    renderSidebar();
  };

  const isBookmarked = (url) => state.bookmarks.some(b => b.url === url);
  const toggleBookmark = () => {
    const tab = getActiveTab();
    const url = tab.url;
    if(!url || url === "about:home") return toast("ホームは登録しない");
    if(isBookmarked(url)){
      state.bookmarks = state.bookmarks.filter(b => b.url !== url);
      saveState();
      renderSidebar();
      toast("ブックマーク削除", url);
    }else{
      state.bookmarks.unshift({ title: tab.title || url, url, addedAt: nowISO() });
      saveState();
      renderSidebar();
      toast("ブックマーク追加", tab.title || url);
    }
    renderIndicators();
  };

  // =========================
  // Viewport Loader
  // =========================
  const frame = $("#frame");
  const overlay = $("#overlay");

  let embedBlockedTimer = null;

  const setOverlay = (show) => {
    overlay.classList.toggle("show", !!show);
  };

  const updateAddressBar = (tab) => {
    $("#addrInput").value = tab.url === "about:home" ? "" : tab.url;
  };

  const renderIndicators = () => {
    const tab = getActiveTab();
    const url = tab.url || "";
    const originTag = $("#originTag");
    const secureTag = $("#secureTag");
    const embedTag = $("#embedTag");

    let origin = "—";
    try{
      if(url.startsWith("about:")) origin = "about";
      else origin = (new URL(url)).hostname;
    }catch(e){ origin = "—"; }

    const secure = url.startsWith("https://") ? "HTTPS" : (url.startsWith("http://") ? "HTTP" : "—");
    originTag.textContent = origin;
    secureTag.textContent = secure;

    const bm = isBookmarked(url) ? "Bookmarked" : "Not bookmarked";
    embedTag.textContent = bm;
  };

  const loadTabToViewport = (tab) => {
    setOverlay(false);
    clearTimeout(embedBlockedTimer);
    renderIndicators();
    updateAddressBar(tab);

    const url = tab.url;

    if(url === "about:home"){
      frame.removeAttribute("src");
      frame.srcdoc = homeHTML();
      setStatus("Home");
      return;
    }

    frame.removeAttribute("srcdoc");
    frame.src = url;

    setStatus("Loading…");

    // "埋め込みブロック" は確実に検知できないが、
    // 一定時間で表示されなければ overlay を出して逃げ道を用意する。
    embedBlockedTimer = setTimeout(() => {
      // about:blank だったり、何も表示されないケースを想定
      // ここは「保険」なので強制表示ではなく、状況を見て出す
      // → タブのURLが外部なら基本出す
      if(getActiveTab().url === url){
        setOverlay(true);
        setStatus("Embed blocked (maybe)");
      }
    }, 1400);
  };

  frame.addEventListener("load", () => {
    clearTimeout(embedBlockedTimer);
    const tab = getActiveTab();
    // タイトルの推定は、クロスオリジンだと取れないのでURLから作る
    if(tab.url.startsWith("about:")){
      setStatus("Ready");
      return;
    }
    setOverlay(false);
    setStatus("Ready");
    addGlobalHistory(tab.url, tab.title);

    // タイトル推定
    let title = tab.title;
    try{
      const u = new URL(tab.url);
      title = u.hostname;
      if(u.pathname && u.pathname !== "/") title += u.pathname.slice(0, 14) + (u.pathname.length > 14 ? "…" : "");
    }catch(e){}
    setTabTitle(tab, title);
    renderIndicators();
  });

  frame.addEventListener("error", () => {
    clearTimeout(embedBlockedTimer);
    setOverlay(true);
    setStatus("Load error");
  });

  const navigate = (input) => {
    const tab = getActiveTab();
    const raw = (input ?? $("#addrInput").value).trim();
    if(!raw){
      tab.url = "about:home";
      pushHistory(tab, tab.url);
      saveState();
      loadTabToViewport(tab);
      renderTabs();
      return;
    }

    const url = normalizeToUrl(raw) || buildSearchUrl(raw);
    tab.url = url;
    pushHistory(tab, url);
    saveState();
    renderTabs();
    loadTabToViewport(tab);
    toast("移動", url);
  };

  const back = () => {
    const tab = getActiveTab();
    if(tab.hIndex <= 0) return toast("戻れない");
    tab.hIndex -= 1;
    tab.url = tab.history[tab.hIndex];
    saveState();
    renderTabs();
    loadTabToViewport(tab);
  };

  const forward = () => {
    const tab = getActiveTab();
    if(tab.hIndex >= tab.history.length - 1) return toast("進めない");
    tab.hIndex += 1;
    tab.url = tab.history[tab.hIndex];
    saveState();
    renderTabs();
    loadTabToViewport(tab);
  };

  const reload = () => {
    const tab = getActiveTab();
    if(tab.url === "about:home"){
      loadTabToViewport(tab);
      return;
    }
    try{
      frame.contentWindow.location.reload();
    }catch(e){
      // クロスオリジンは reload 制限があるので src 再設定
      frame.src = tab.url;
    }
    setStatus("Reloading…");
  };

  const openExternal = () => {
    const tab = getActiveTab();
    const url = tab.url === "about:home" ? buildSearchUrl($("#addrInput").value.trim() || "home") : tab.url;
    window.open(url, "_blank", "noopener,noreferrer");
    toast("外部で開いた", url);
  };

  const tryPip = async () => {
    try{
      const doc = frame.contentDocument;
      if(!doc) throw new Error("Cross-origin");
      const video = doc.querySelector("video");
      if(!video) return toast("動画が見つからない", "このページに <video> がない");
      if(document.pictureInPictureElement) await document.exitPictureInPicture();
      await video.requestPictureInPicture();
      toast("PiP", "Picture-in-Picture");
    }catch(e){
      toast("PiP不可", "多くの外部サイトは制限がある");
    }
  };

  // =========================
  // UI Render
  // =========================
  const renderTabs = () => {
    const el = $("#tabs");
    const activeId = state.activeTabId || state.tabs[0]?.id;
    if(!state.activeTabId) state.activeTabId = activeId;

    el.innerHTML = state.tabs.map(t => {
      const active = t.id === activeId ? "active" : "";
      const pill = t.url === "about:home" ? "HOME" : (t.url.startsWith("https://") ? "HTTPS" : (t.url.startsWith("http://") ? "HTTP" : "—"));
      return `
        <div class="tab ${active}" data-tab="${t.id}">
          <div class="title">${escapeHTML(t.title || "Untitled")}</div>
          <span class="pill">${escapeHTML(pill)}</span>
          <div class="x" title="閉じる" data-close="${t.id}">×</div>
        </div>
      `;
    }).join("");

    el.querySelectorAll(".tab").forEach(tabEl => {
      tabEl.addEventListener("click", (ev) => {
        const closeId = ev.target?.getAttribute?.("data-close");
        if(closeId){
          ev.stopPropagation();
          closeTab(closeId);
          return;
        }
        const id = tabEl.getAttribute("data-tab");
        setActive(id);
      });
    });
  };

  const renderSidebar = () => {
    // Quick
    const q = $("#quickList");
    q.innerHTML = state.quick.map(x => `
      <div class="item" data-url="${escapeHTML(x.url)}">
        <div class="icon">⚡</div>
        <div class="meta">
          <div class="t">${escapeHTML(x.title)}</div>
          <div class="s">${escapeHTML(x.url)}</div>
        </div>
        <span class="tag">OPEN</span>
      </div>
    `).join("");

    // Bookmarks
    const b = $("#bmList");
    b.innerHTML = (state.bookmarks.length ? state.bookmarks : [{title:"(なし)", url:""}]).map(x => `
      <div class="item" data-url="${escapeHTML(x.url || "")}" ${x.url ? "" : "style='opacity:.6; cursor:default'"}>
        <div class="icon">☆</div>
        <div class="meta">
          <div class="t">${escapeHTML(x.title)}</div>
          <div class="s">${escapeHTML(x.url)}</div>
        </div>
        ${x.url ? `<span class="tag">OPEN</span>` : `<span class="tag">—</span>`}
      </div>
    `).join("");

    // History
    const h = $("#histList");
    const max = state.settings.sidebarHistoryView || 20;
    const hist = state.history.slice(0, max);
    h.innerHTML = (hist.length ? hist : [{title:"(なし)", url:""}]).map(x => `
      <div class="item" data-url="${escapeHTML(x.url || "")}" ${x.url ? "" : "style='opacity:.6; cursor:default'"}>
        <div class="icon">⟲</div>
        <div class="meta">
          <div class="t">${escapeHTML(x.title || x.url || "(なし)")}</div>
          <div class="s">${escapeHTML(x.url || "")}</div>
        </div>
        ${x.url ? `<span class="tag">OPEN</span>` : `<span class="tag">—</span>`}
      </div>
    `).join("");

    // Handlers
    const bindOpen = (root) => {
      root.querySelectorAll(".item").forEach(it => {
        it.addEventListener("click", () => {
          const url = it.getAttribute("data-url");
          if(!url) return;
          navigate(url);
        });
      });
    };
    bindOpen(q); bindOpen(b); bindOpen(h);
  };

  const renderTheme = () => {
    document.body.setAttribute("data-theme", state.theme || "dark");
    $("#btnToggleTheme").innerHTML = state.theme === "dark"
      ? `<span class="icon">☾</span>テーマ`
      : `<span class="icon">☀</span>テーマ`;
  };

  const renderAll = () => {
    renderTheme();
    renderTabs();
    renderSidebar();
    renderIndicators();
  };

  // =========================
  // Home page (srcdoc)
  // =========================
  const homeHTML = () => {
    const quick = state.quick.slice(0, 6).map(x => `
      <a class="card" href="${x.url}" target="_blank" rel="noopener noreferrer">
        <div class="t">${escapeHTML(x.title)}</div>
        <div class="s">${escapeHTML(x.url)}</div>
      </a>
    `).join("");

    const bm = state.bookmarks.slice(0, 6).map(x => `
      <a class="card" href="${x.url}" target="_blank" rel="noopener noreferrer">
        <div class="t">☆ ${escapeHTML(x.title)}</div>
        <div class="s">${escapeHTML(x.url)}</div>
      </a>
    `).join("");

    return `<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Home</title>
<style>
  :root{ color-scheme: dark; }
  body{
    margin:0;
    font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans JP", Arial;
    background: radial-gradient(1000px 600px at 20% 20%, rgba(124,240,255,.18), transparent 60%),
                radial-gradient(1000px 600px at 80% 20%, rgba(124,160,255,.16), transparent 60%),
                #0b0f14;
    color:#eaf0ff;
  }
  .wrap{ padding: 18px; }
  h1{ font-size:18px; margin:0 0 8px; }
  p{ margin:0 0 14px; opacity:.75; font-size:13px; }
  .grid{
    display:grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap:10px;
  }
  .card{
    display:block;
    padding:12px 12px;
    border-radius: 16px;
    border:1px solid rgba(255,255,255,.10);
    background: rgba(255,255,255,.06);
    text-decoration:none;
    color:inherit;
  }
  .card:hover{ border-color: rgba(124,240,255,.35); background: rgba(255,255,255,.09); }
  .t{ font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  .s{ font-size:12px; opacity:.70; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-top:4px; }
  .row{ display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
  .pill{
    border:1px solid rgba(255,255,255,.10);
    padding:6px 10px;
    border-radius: 999px;
    background: rgba(255,255,255,.06);
    font-size:12px;
    opacity:.85;
  }
</style>
</head>
<body>
  <div class="wrap">
    <h1>Voogle Home</h1>
    <p>アドレスバーにURLか検索ワードを入れて Enter。埋め込み不可サイトは外部タブで開く。</p>

    <div class="row">
      <span class="pill">Ctrl+L: アドレス</span>
      <span class="pill">Ctrl+T: 新規タブ</span>
      <span class="pill">Ctrl+W: タブ閉じる</span>
      <span class="pill">Ctrl+R: 更新</span>
    </div>

    <h1 style="margin-top:18px">Quick</h1>
    <div class="grid">${quick}</div>

    <h1 style="margin-top:18px">Bookmarks</h1>
    <div class="grid">${bm}</div>
  </div>
</body>
</html>`;
  };

  // =========================
  // Export / Import
  // =========================
  const exportData = () => {
    const data = JSON.stringify(state, null, 2);
    const blob = new Blob([data], {type:"application/json"});
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = "voogle-data.json";
    document.body.appendChild(a);
    a.click();
    a.remove();
    URL.revokeObjectURL(url);
    toast("出力した", "voogle-data.json");
  };

  const importData = async (file) => {
    try{
      const text = await file.text();
      const obj = JSON.parse(text);
      if(!obj || !obj.tabs || !Array.isArray(obj.tabs)) throw new Error("Invalid");
      state = obj;
      saveState();
      renderAll();
      loadTabToViewport(getActiveTab());
      toast("取込完了", "データを復元した");
    }catch(e){
      toast("取込失敗", "JSONが壊れてるか形式が違う");
    }
  };

  const resetAll = () => {
    if(!confirm("全データを初期化する?")) return;
    state = defaultState();
    saveState();
    renderAll();
    loadTabToViewport(getActiveTab());
    toast("初期化した");
  };

  // =========================
  // Bindings
  // =========================
  $("#btnGo").addEventListener("click", () => navigate());
  $("#addrInput").addEventListener("keydown", (e) => {
    if(e.key === "Enter") navigate();
  });

  $("#btnBack").addEventListener("click", back);
  $("#btnForward").addEventListener("click", forward);
  $("#btnReload").addEventListener("click", reload);
  $("#btnBookmark").addEventListener("click", toggleBookmark);
  $("#btnOpenExternal").addEventListener("click", openExternal);
  $("#btnPip").addEventListener("click", tryPip);
  $("#btnNewTab").addEventListener("click", () => newTab("about:home", "Home"));

  $("#btnToggleTheme").addEventListener("click", () => {
    state.theme = (state.theme === "dark") ? "light" : "dark";
    saveState();
    renderTheme();
    toast("テーマ", state.theme);
  });

  $("#btnExport").addEventListener("click", exportData);
  $("#importFile").addEventListener("change", (e) => {
    const f = e.target.files?.[0];
    if(f) importData(f);
    e.target.value = "";
  });

  $("#btnHome").addEventListener("click", () => navigate("about:home"));
  $("#btnClearHistory").addEventListener("click", () => {
    state.history = [];
    saveState();
    renderSidebar();
    toast("履歴クリア");
  });
  $("#btnResetAll").addEventListener("click", resetAll);

  // Overlay buttons
  $("#overlayOpenExternal").addEventListener("click", openExternal);
  $("#overlayTrySearch").addEventListener("click", () => {
    const tab = getActiveTab();
    const q = tab.url && tab.url !== "about:home" ? tab.url : ($("#addrInput").value.trim() || "home");
    newTab(buildSearchUrl(q), "Search");
  });
  $("#overlayClose").addEventListener("click", () => {
    setOverlay(false);
    toast("閉じた");
  });

  // Keyboard shortcuts
  window.addEventListener("keydown", (e) => {
    const ctrl = e.ctrlKey || e.metaKey;
    if(ctrl && e.key.toLowerCase() === "l"){ e.preventDefault(); $("#addrInput").focus(); $("#addrInput").select(); }
    if(ctrl && e.key.toLowerCase() === "t"){ e.preventDefault(); newTab("about:home", "Home"); }
    if(ctrl && e.key.toLowerCase() === "w"){ e.preventDefault(); closeTab(getActiveTab().id); }
    if(ctrl && e.key.toLowerCase() === "r"){ e.preventDefault(); reload(); }
    if(ctrl && e.key === "Enter"){ e.preventDefault(); openExternal(); }
  });

  // Init
  if(!state.activeTabId) state.activeTabId = state.tabs[0].id;
  renderAll();
  loadTabToViewport(getActiveTab());
})();
</script>
</body>
</html>

投稿者: chosuke

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

コメントを残す

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