<!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("&","&").replaceAll("<","<")
.replaceAll(">",">").replaceAll('"',""").replaceAll("'","'");
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>