<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Tsumugi</title>
<!-- ✅ Favicon(初期): ブラウザ互換のため複数relで用意(確実版) -->
<!-- ★ここに貼る★ -->
<link rel="icon" type="image/svg+xml"
href="data:image/svg+xml,
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'>
<defs>
<linearGradient id='g' x1='0' y1='0' x2='1' y2='1'>
<stop offset='0' stop-color='%2300eaff'/>
<stop offset='1' stop-color='%238a5cff'/>
</linearGradient>
</defs>
<polygon points='32,4 56,18 56,46 32,60 8,46 8,18' fill='url(%23g)'/>
<circle cx='32' cy='32' r='10' fill='%230b0f2a'/>
<circle cx='32' cy='32' r='16' fill='none' stroke='%23ffffff' stroke-width='2' opacity='0.7'/>
<line x1='32' y1='12' x2='32' y2='20' stroke='%23ffffff' stroke-width='2'/>
<line x1='32' y1='44' x2='32' y2='52' stroke='%23ffffff' stroke-width='2'/>
<line x1='12' y1='32' x2='20' y2='32' stroke='%23ffffff' stroke-width='2'/>
<line x1='44' y1='32' x2='52' y2='32' stroke='%23ffffff' stroke-width='2'/>
</svg>">
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet"/>
<!-- Tailwind CSS v2 -->
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet"/>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.4.0/css/all.min.css"/>
<!-- A-Frame (VR 試験) -->
<script src="https://aframe.io/releases/1.5.0/aframe.min.js"></script>
<!-- ✅ Favicon -->
<link id="dynamic-favicon" rel="shortcut icon"
href="data:image/svg+xml,%3Csvg xmlns='https://www.w3.org/2000/svg' width='64' height='64' viewBox='0 0 64 64'%3E%3Cdefs%3E%3CradialGradient id='g' cx='30%25' cy='20%25' r='90%25'%3E%3Cstop offset='0%25' stop-color='%237c3aed'/%3E%3Cstop offset='55%25' stop-color='%235b5fe6'/%3E%3Cstop offset='100%25' stop-color='%230b1020'/%3E%3C/radialGradient%3E%3ClinearGradient id='glow' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%23a78bfa'/%3E%3Cstop offset='100%25' stop-color='%2360a5fa'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect x='2' y='2' width='60' height='60' rx='16' fill='url(%23g)'/%3E%3Cpath d='M20 18h24a2 2 0 0 1 0 4H36v26a2 2 0 0 1-4 0V22h-8a2 2 0 0 1 0-4z' fill='white'/%3E%3Cpath d='M18 20h28' stroke='url(%23glow)' stroke-width='3' stroke-linecap='round' opacity='.85'/%3E%3Cpath d='M32 22v26' stroke='url(%23glow)' stroke-width='3' stroke-linecap='round' opacity='.65'/%3E%3Crect x='2' y='2' width='60' height='60' rx='16' fill='none' stroke='rgba(255,255,255,.22)'/%3E%3C/svg%3E"/>
<link id="favicon-shortcut" rel="shortcut icon" href="">
<link id="favicon-any" rel="icon" sizes="any" href="">
<link id="apple-touch" rel="apple-touch-icon" href="">
<meta name="theme-color" content="#5b5fe6">
<!-- Tailwind CSS v2 -->
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet"/>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.4.0/css/all.min.css"/>
<!-- A-Frame (VR 試験) -->
<script src="https://aframe.io/releases/1.5.0/aframe.min.js"></script>
<style>
:root{
--grad-a:#5b5fe6; --grad-b:#7c3aed;
--bg-a:#f4f7ff; --bg-b:#eef2ff;
--ink:#0f172a; --muted:#64748b;
}
body{
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
background: radial-gradient(circle at top left,var(--bg-a) 0%,var(--bg-b) 45%,#ffffff 100%);
color: var(--ink);
min-height:100vh;
}
.glass{ background: rgba(255,255,255,.78); backdrop-filter: blur(18px); border: 1px solid rgba(15,23,42,.10); border-radius: 18px; }
.card-hover{ transition:.18s ease; }
.card-hover:hover{ transform: translateY(-2px); box-shadow: 0 18px 35px -18px rgba(2,6,23,.25); }
.gradient-text{ background: linear-gradient(90deg,var(--grad-a),var(--grad-b)); -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent; }
.btn-primary{ background: linear-gradient(135deg,var(--grad-a),var(--grad-b)); color:#fff; border:none; transition:.18s ease; }
.btn-primary:hover{ transform: translateY(-1px); box-shadow: 0 14px 26px -18px rgba(2,6,23,.35); }
.pill{ border:1px solid rgba(15,23,42,.10); background: rgba(255,255,255,.9); border-radius: 999px; }
.mini-avatar{ width:44px;height:44px;border-radius:999px;object-fit:cover; border:2px solid rgba(255,255,255,.95); box-shadow: 0 10px 22px rgba(2,6,23,.14); }
.avatar{ width:96px;height:96px;border-radius:999px;object-fit:cover; border:3px solid rgba(255,255,255,.95); box-shadow: 0 14px 30px rgba(2,6,23,.18); }
.timeline-post{ background: rgba(255,255,255,.96); border-radius: 18px; border:1px solid rgba(15,23,42,.08); box-shadow: 0 16px 30px -20px rgba(2,6,23,.25); border-left: 4px solid var(--grad-a); transition:.18s ease; }
.timeline-post:hover{ transform: translateX(3px); }
.badge{ font-size: 11px; padding: 2px 10px; border-radius: 999px; background: rgba(91,95,230,.10); border:1px solid rgba(91,95,230,.25); color: #3730a3; display:inline-flex;align-items:center;gap:6px; white-space:nowrap; }
.status-dot{ width:8px;height:8px;border-radius:999px; display:inline-block; }
.on{ background:#10b981; box-shadow:0 0 0 3px rgba(16,185,129,.15); }
.off{ background:#94a3b8; }
.log{ max-height: 190px; overflow:auto; background: rgba(241,245,249,.85); border: 1px solid rgba(15,23,42,.12); border-radius: 12px; padding: 10px; font-size: 12px; color:#0f172a; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.log .err{ background: rgba(254,202,202,.8); color:#7f1d1d; padding:6px 8px; border-radius:10px; margin:6px 0; }
.log .ok{ background: rgba(187,247,208,.8); color:#065f46; padding:6px 8px; border-radius:10px; margin:6px 0; }
.log .info{ color:#334155; padding:2px 0; }
.header-bg{ background-size: cover; background-position: center; background-repeat: no-repeat; }
.soft-input{ background:#fff; border:1px solid rgba(15,23,42,.14); border-radius: 12px; padding: 10px 12px; outline:none; width:100%; color:#0f172a; }
.soft-input:focus{ box-shadow: 0 0 0 3px rgba(91,95,230,.18); border-color: rgba(91,95,230,.35); }
.muted{ color: var(--muted); }
/* Toggle (ON/OFF) */
.toggle{
width: 54px; height: 30px; border-radius: 999px;
border: 1px solid rgba(15,23,42,.16);
background: rgba(148,163,184,.35);
position: relative; cursor: pointer; transition:.16s ease;
display:inline-flex; align-items:center;
}
.toggle.on{ background: rgba(16,185,129,.22); border-color: rgba(16,185,129,.35); }
.toggle > span{
width: 24px; height: 24px; border-radius: 999px;
background: #fff; position:absolute; left: 3px; transition:.16s ease;
box-shadow: 0 12px 20px -14px rgba(2,6,23,.55);
border: 1px solid rgba(15,23,42,.12);
}
.toggle.on > span{ left: 27px; }
/* VR overlay */
#vr-overlay{ position:fixed; inset:0; background:#000; z-index:9999; display:none; }
#vr-topbar{ position:absolute; top:10px; left:10px; right:10px; display:flex; align-items:center; justify-content:space-between; z-index:10000; pointer-events:auto; }
#vr-topbar .btn{ background: rgba(255,255,255,.10); border:1px solid rgba(255,255,255,.18); color:#fff; padding:8px 12px; border-radius:999px; font-size:12px; backdrop-filter: blur(10px); }
#vr-topbar .btn:hover{ background: rgba(255,255,255,.16); }
#vr-hint{ color:#fff; font-size:12px; opacity:.85; background: rgba(255,255,255,.08); padding:8px 12px; border-radius:999px; border:1px solid rgba(255,255,255,.14); backdrop-filter: blur(10px); }
/* ✅ Auth overlay (Login/Register/Logout) */
#auth-overlay{
position:fixed; inset:0;
background: rgba(2,6,23,.62);
backdrop-filter: blur(10px);
z-index: 100000;
display:none;
}
#auth-card{
max-width: 520px;
margin: 6vh auto;
padding: 22px;
border-radius: 22px;
background: rgba(255,255,255,.92);
border: 1px solid rgba(148,163,184,.35);
box-shadow: 0 30px 70px rgba(2,6,23,.35);
}
.tab-btn{
border:1px solid rgba(15,23,42,.10);
background: rgba(255,255,255,.9);
border-radius: 999px;
padding: 8px 12px;
font-size: 12px;
transition:.12s ease;
}
.tab-btn.active{
background: linear-gradient(135deg,var(--grad-a),var(--grad-b));
color:#fff; border-color: transparent;
}
/* ✅ WEBアイコン */
.icon-tile{
border-radius: 16px;
border: 1px solid rgba(15,23,42,.10);
background: rgba(255,255,255,.92);
padding: 10px;
transition: .14s ease;
cursor: pointer;
position: relative;
overflow: hidden;
}
.icon-tile:hover{
transform: translateY(-2px);
box-shadow: 0 18px 32px -22px rgba(2,6,23,.35);
}
.icon-tile.active{
outline: 3px solid rgba(91,95,230,.28);
box-shadow: 0 0 0 6px rgba(91,95,230,.10);
}
.icon-preview{
width: 44px; height: 44px;
border-radius: 14px;
border: 1px solid rgba(15,23,42,.10);
background: rgba(241,245,249,.8);
display:flex; align-items:center; justify-content:center;
}
.icon-preview img{ width: 36px; height: 36px; }
.icon-tag{
font-size: 11px;
color: #0f172a;
font-weight: 700;
letter-spacing: .02em;
}
.icon-sub{
font-size: 11px;
color: var(--muted);
}
.icon-apply{
display:flex;
gap:8px;
margin-top:8px;
flex-wrap: wrap;
}
.mini-btn{
border-radius: 999px;
border: 1px solid rgba(15,23,42,.12);
background: rgba(255,255,255,.9);
padding: 6px 10px;
font-size: 11px;
transition: .12s ease;
display:inline-flex; align-items:center; gap:6px;
}
.mini-btn:hover{ background: rgba(248,250,252,1); }
.mini-btn.primary{
border-color: transparent;
color:#fff;
background: linear-gradient(135deg,var(--grad-a),var(--grad-b));
}
@media (max-width: 768px){
.mini-avatar{ width:40px;height:40px; }
.avatar{ width:88px;height:88px; }
#auth-card{ margin: 4vh 14px; }
}
</style>
</head>
<body class="text-gray-900">
<!-- Hidden file inputs -->
<input id="file-profile" type="file" accept="image/*" class="hidden">
<input id="file-headerbg" type="file" accept="image/*" class="hidden">
<input id="file-appicon" type="file" accept="image/*" class="hidden">
<input id="file-feedicon" type="file" accept="image/*" class="hidden">
<input id="file-media" type="file" accept="image/*,video/*" multiple class="hidden">
<!-- ✅ Auth Overlay -->
<div id="auth-overlay">
<div id="auth-card">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-2xl flex items-center justify-center"
style="background:linear-gradient(135deg,var(--grad-a),var(--grad-b)); color:#fff;">
<i class="fas fa-comments"></i>
</div>
<div>
<div class="text-xl font-extrabold tracking-tight"><span class="gradient-text">Tsumugi</span></div>
<div class="text-xs muted">ログイン / 会員登録 / ログアウト(localStorage保持)</div>
</div>
</div>
<button id="btn-auth-close" class="pill px-4 py-2 text-xs hover:bg-slate-50">
<i class="fas fa-times mr-2"></i>閉じる
</button>
</div>
<div class="mt-4 flex items-center gap-2">
<button id="tab-login" class="tab-btn active"><i class="fas fa-right-to-bracket mr-2"></i>ログイン</button>
<button id="tab-register" class="tab-btn"><i class="fas fa-user-plus mr-2"></i>会員登録</button>
<div class="ml-auto flex items-center gap-2">
<button id="btn-guest" class="pill px-4 py-2 text-xs hover:bg-slate-50" title="ゲストで入る(登録不要)">
<i class="fas fa-user-ninja mr-2"></i>ゲスト
</button>
</div>
</div>
<div class="mt-4 glass p-4 border border-slate-200">
<!-- Login -->
<div id="panel-login">
<div class="text-sm font-bold mb-2"><i class="fas fa-right-to-bracket mr-2 text-indigo-600"></i>ログイン</div>
<div class="space-y-3">
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1">メールアドレス</label>
<input id="login-email" type="email" class="soft-input" placeholder="you@example.com" autocomplete="email">
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1">パスワード</label>
<input id="login-pass" type="password" class="soft-input" placeholder="password" autocomplete="current-password">
</div>
<div class="flex items-center justify-between gap-3">
<label class="flex items-center gap-2 text-xs">
<input id="login-remember" type="checkbox" class="mr-1">
ログイン状態を保持
</label>
<button id="btn-login" class="btn-primary px-5 py-2 rounded-lg text-xs font-semibold">
<i class="fas fa-unlock mr-2"></i>ログイン
</button>
</div>
</div>
</div>
<!-- Register -->
<div id="panel-register" style="display:none;">
<div class="text-sm font-bold mb-2"><i class="fas fa-user-plus mr-2 text-indigo-600"></i>会員登録</div>
<div class="space-y-3">
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1">メールアドレス</label>
<input id="reg-email" type="email" class="soft-input" placeholder="you@example.com" autocomplete="email">
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1">パスワード</label>
<input id="reg-pass" type="password" class="soft-input" placeholder="password" autocomplete="new-password">
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1">確認</label>
<input id="reg-pass2" type="password" class="soft-input" placeholder="confirm" autocomplete="new-password">
</div>
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1">ユーザー名(20文字まで)</label>
<input id="reg-username" type="text" maxlength="20" class="soft-input" placeholder="表示名">
</div>
<div class="flex items-center justify-between gap-3">
<div class="text-xs muted">※パスワードは端末(localStorage)に保存されます(簡易SNS)</div>
<button id="btn-register" class="btn-primary px-5 py-2 rounded-lg text-xs font-semibold">
<i class="fas fa-check mr-2"></i>登録
</button>
</div>
</div>
</div>
</div>
<div class="mt-3">
<div id="auth-msg" class="text-xs muted"></div>
</div>
</div>
</div>
<!-- VR Overlay (試験) -->
<div id="vr-overlay">
<div id="vr-topbar">
<div class="flex items-center gap-2">
<span id="vr-hint"><i class="fas fa-vr-cardboard mr-2"></i>VR試験モード(投稿カードを3D表示)</span>
</div>
<div class="flex items-center gap-2">
<button id="btn-vr-enter" class="btn"><i class="fas fa-play mr-2"></i>VR開始</button>
<button id="btn-vr-exit" class="btn"><i class="fas fa-times mr-2"></i>終了</button>
</div>
</div>
<a-scene id="vr-scene" embedded background="color: #0b1020">
<a-entity position="0 1.6 3">
<a-camera wasd-controls-enabled="true" look-controls="pointerLockEnabled: true"></a-camera>
</a-entity>
<a-entity light="type: ambient; intensity: 0.7"></a-entity>
<a-entity light="type: directional; intensity: 0.8" position="-1 2 1"></a-entity>
<a-entity id="vr-wall"></a-entity>
<a-plane position="0 0 -3" rotation="-90 0 0" width="30" height="30" color="#111827" opacity="0.9"></a-plane>
<a-sky color="#0b1020"></a-sky>
</a-scene>
</div>
<!-- HEADER -->
<header id="app-header" class="glass mx-4 mt-4 p-6 border border-indigo-500/20 header-bg">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div>
<div class="flex items-center gap-3">
<i class="fas fa-comments text-indigo-500 text-2xl"></i>
<h1 class="text-3xl md:text-4xl font-extrabold tracking-tight">
<span class="gradient-text">Tsumugi</span>
<span class="badge ml-2"><i class="fas fa-bolt"></i> LocalSNS</span>
</h1>
</div>
<p class="text-sm md:text-base muted mt-1">
フォローTL / データDL / 投稿URL / RSS投稿ON/OFF / VR試験 / ログイン
</p>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<div class="pill px-3 py-2 flex items-center gap-3">
<img id="header-avatar" class="mini-avatar" src="https://via.placeholder.com/80" alt="avatar">
<div class="text-left leading-tight">
<div class="font-semibold text-sm" id="header-username">未設定</div>
<div class="text-xs muted" id="header-email"></div>
</div>
</div>
<button id="btn-auth-open" class="pill px-4 py-2 text-xs flex items-center gap-2 hover:bg-slate-50" title="ログイン/登録">
<i class="fas fa-user-shield text-slate-700"></i> アカウント
</button>
<button id="btn-logout" class="pill px-4 py-2 text-xs flex items-center gap-2 hover:bg-slate-50" title="ログアウト">
<i class="fas fa-right-from-bracket text-slate-700"></i> ログアウト
</button>
<button id="btn-vr" class="pill px-4 py-2 text-xs flex items-center gap-2 hover:bg-slate-50">
<i class="fas fa-vr-cardboard text-slate-700"></i> VR試験
</button>
<button id="btn-clear-all" class="bg-yellow-300 hover:bg-yellow-400 px-4 py-2 rounded-full text-black text-xs flex items-center gap-2">
<i class="fas fa-broom"></i> 全消去
</button>
</div>
</div>
</header>
<!-- MAIN -->
<main class="max-w-6xl mx-auto px-4 py-6" id="main-content">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- LEFT -->
<section class="lg:col-span-1 space-y-6">
<!-- Account / Profile -->
<div class="glass p-6 card-hover border border-slate-200">
<div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-bold flex items-center gap-2">
<i class="fas fa-user-circle text-indigo-500"></i>
アカウント
</h3>
<span class="badge"><i class="fas fa-home"></i> Home=Follow</span>
</div>
<div class="text-center mb-5">
<img id="profile-avatar" class="avatar mx-auto mb-3" src="https://via.placeholder.com/100" alt="avatar">
<button id="btn-change-profile" class="btn-primary px-4 py-2 rounded-full text-white text-sm">
<i class="fas fa-camera mr-2"></i>プロフィール画像
</button>
</div>
<div class="grid grid-cols-3 gap-2 mb-4">
<div class="glass p-3 text-center border border-slate-200">
<div class="text-xs muted">フォロー</div>
<div id="stat-following" class="text-lg font-extrabold">0</div>
</div>
<div class="glass p-3 text-center border border-slate-200">
<div class="text-xs muted">フォロワー</div>
<div id="stat-followers" class="text-lg font-extrabold">0</div>
</div>
<div class="glass p-3 text-center border border-slate-200">
<div class="text-xs muted">投稿</div>
<div id="stat-posts" class="text-lg font-extrabold">0</div>
</div>
</div>
<div class="space-y-3">
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1">ユーザー名(20文字まで)</label>
<input id="inp-username" type="text" maxlength="20" class="soft-input" placeholder="ユーザー名">
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 mb-1">自己紹介</label>
<textarea id="inp-bio" rows="4" class="soft-input" placeholder="自己紹介"></textarea>
</div>
<button id="btn-save-profile" class="btn-primary w-full py-2 rounded-lg text-white text-sm font-semibold">
<i class="fas fa-save mr-2"></i>保存
</button>
<div class="glass p-3 border border-slate-200">
<div class="text-xs font-semibold text-slate-800 mb-1">プレビュー</div>
<div id="preview-username" class="font-semibold">未設定</div>
<div id="preview-bio" class="text-xs muted whitespace-pre-line mt-1">まだ自己紹介がありません</div>
</div>
</div>
</div>
<!-- Header / Icons -->
<div class="glass p-6 card-hover border border-slate-200">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-bold flex items-center gap-2">
<i class="fas fa-palette text-indigo-500"></i> 見た目(ファイル)
</h3>
<span class="text-xs muted">localStorage保存</span>
</div>
<div class="space-y-2 text-sm">
<button id="btn-change-headerbg" class="pill w-full px-4 py-2 text-left hover:bg-slate-50">
<i class="fas fa-image mr-2 text-slate-700"></i> ヘッダー背景を変更
</button>
<button id="btn-change-appicon" class="pill w-full px-4 py-2 text-left hover:bg-slate-50">
<i class="fas fa-icons mr-2 text-slate-700"></i> Faviconを変更
</button>
<button id="btn-change-feedicon" class="pill w-full px-4 py-2 text-left hover:bg-slate-50">
<i class="fas fa-rss mr-2 text-slate-700"></i> Feedアイコン変更
</button>
<div class="grid grid-cols-2 gap-2 mt-2">
<button id="btn-reset-headerbg" class="pill px-4 py-2 hover:bg-slate-50">
<i class="fas fa-undo mr-2"></i>ヘッダー戻す
</button>
<button id="btn-reset-appicon" class="pill px-4 py-2 hover:bg-slate-50">
<i class="fas fa-undo mr-2"></i>Favicon戻す
</button>
</div>
</div>
<!-- ✅ WEBアイコン -->
<div class="mt-5">
<div class="flex items-center justify-between">
<div class="text-sm font-extrabold flex items-center gap-2">
<i class="fas fa-sparkles text-indigo-600"></i> WEBアイコン
</div>
<span class="text-xs muted">クリックで選択 → 適用</span>
</div>
<div class="mt-3 grid grid-cols-2 gap-2" id="icon-grid"></div>
<div class="mt-3 glass p-4 border border-slate-200">
<div class="flex items-center justify-between gap-2">
<div class="text-xs font-semibold flex items-center gap-2">
<i class="fas fa-wand-magic-sparkles text-indigo-600"></i>
選択中アイコン
</div>
<button id="btn-icon-random" class="pill px-3 py-2 text-xs hover:bg-slate-50" title="ランダムに選ぶ">
<i class="fas fa-dice mr-2"></i>ランダム
</button>
</div>
<div class="mt-3 flex items-center gap-3">
<div class="icon-preview">
<img id="icon-selected-preview" src="" alt="selected">
</div>
<div class="flex-1">
<div id="icon-selected-name" class="icon-tag">未選択</div>
<div id="icon-selected-desc" class="icon-sub">アイコンをクリックしてください</div>
</div>
</div>
<div class="icon-apply mt-3">
<button id="btn-apply-favicon" class="mini-btn primary">
<i class="fas fa-icons"></i>Faviconに適用
</button>
<button id="btn-apply-feed" class="mini-btn">
<i class="fas fa-rss"></i>Feedに適用
</button>
<button id="btn-apply-profile" class="mini-btn">
<i class="fas fa-user-circle"></i>プロフィールに適用
</button>
</div>
<div class="mt-3 text-[11px] muted">
※SVG(dataURL)なので、端末内(localStorage)だけで完結します(外部画像不要)
</div>
</div>
</div>
</div>
<!-- ✅ RSS(投稿機能 ON/OFF 完全) -->
<div class="glass p-6 card-hover border border-amber-500/20">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-bold flex items-center gap-2">
<i class="fas fa-rss text-amber-500"></i> RSS投稿
</h3>
<span class="badge">
<span class="status-dot" id="rss-dot"></span>
<span id="rss-status-text">停止中</span>
</span>
</div>
<div class="flex items-center justify-between gap-3 mb-3">
<div class="text-xs muted">
<b>ON</b>:一定間隔でRSSを取得し、<b>Feed投稿</b>としてタイムラインへ自動投稿します
</div>
<div class="flex items-center gap-2">
<div class="text-xs font-bold text-slate-700">自動</div>
<div id="rss-toggle" class="toggle" role="switch" aria-checked="false" tabindex="0" title="RSS自動投稿 ON/OFF">
<span></span>
</div>
</div>
</div>
<div class="space-y-2">
<input id="rss-url" class="soft-input text-xs" placeholder="RSS URL(https://...)">
<div class="grid grid-cols-2 gap-2">
<button id="btn-add-rss" class="btn-primary py-2 rounded-lg text-xs">
<i class="fas fa-plus mr-2"></i>追加
</button>
<button id="btn-fetch-rss" class="pill py-2 text-xs hover:bg-slate-50">
<i class="fas fa-sync mr-2"></i>今すぐ取得
</button>
</div>
<div class="grid grid-cols-2 gap-2">
<input id="rss-interval" type="number" min="10" max="3600" value="120" class="soft-input text-xs" placeholder="間隔(秒)">
<button id="btn-set-rss-interval" class="pill py-2 text-xs hover:bg-slate-50">
<i class="fas fa-clock mr-2"></i>間隔設定
</button>
</div>
<div class="grid grid-cols-2 gap-2">
<button id="btn-rss-clear-seen" class="pill py-2 text-xs hover:bg-slate-50">
<i class="fas fa-eraser mr-2"></i>既読リセット
</button>
<button id="btn-rss-test-one" class="pill py-2 text-xs hover:bg-slate-50">
<i class="fas fa-flask mr-2"></i>URL検査
</button>
</div>
<div class="mt-2">
<div class="text-xs font-semibold mb-1">RSS一覧</div>
<div id="rss-list" class="space-y-2 text-xs"></div>
</div>
<div class="mt-3">
<div class="text-xs font-semibold mb-1">RSSログ</div>
<div id="rss-log" class="log"></div>
</div>
</div>
</div>
</section>
<!-- RIGHT -->
<section class="lg:col-span-2 space-y-6">
<!-- New Post -->
<div class="glass p-6 card-hover border border-slate-200">
<div class="flex items-center justify-between mb-3">
<h3 class="text-xl font-bold flex items-center gap-2">
<i class="fas fa-edit text-indigo-500"></i> 新規投稿
</h3>
<div class="flex gap-2">
<button id="btn-home" class="btn-primary px-3 py-2 rounded-full text-xs">
<i class="fas fa-house mr-2"></i>Home
</button>
<button id="btn-explore" class="pill px-3 py-2 rounded-full text-xs hover:bg-slate-50">
<i class="fas fa-compass mr-2"></i>探索
</button>
</div>
</div>
<textarea id="post-text" class="soft-input text-sm" rows="4"
placeholder="いまどうしてる?(Ctrl+Enterで投稿)" maxlength="600"></textarea>
<div class="mt-3 flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div class="text-xs muted flex items-center gap-2 flex-wrap">
<i class="fas fa-circle-info"></i>
<span id="char-count">(0/600)</span>
<label class="pill px-3 py-2 text-xs cursor-pointer hover:bg-slate-50" id="btn-add-media">
<i class="fas fa-paperclip mr-2"></i>画像/動画
</label>
<button id="btn-clear-media" class="pill px-3 py-2 text-xs hover:bg-slate-50">
<i class="fas fa-trash mr-2"></i>クリア
</button>
<span class="badge"><i class="fas fa-link"></i>投稿URL</span>
<span class="badge"><i class="fas fa-rss"></i>RSS投稿</span>
</div>
<div class="flex gap-2">
<button id="btn-post" class="btn-primary px-5 py-2 rounded-lg text-xs font-semibold">
<i class="fas fa-paper-plane mr-2"></i>投稿
</button>
<button id="btn-delete-all-posts" class="bg-red-600 hover:bg-red-700 px-5 py-2 rounded-lg text-white text-xs font-semibold">
<i class="fas fa-trash mr-2"></i>全投稿削除
</button>
</div>
</div>
<div class="mt-4 glass p-4 border border-slate-200">
<div class="flex items-center justify-between">
<div class="text-xs font-semibold flex items-center gap-2">
<i class="fas fa-link text-indigo-600"></i> 直近の投稿URL
</div>
<button id="btn-copy-post-url" class="pill px-3 py-2 text-xs hover:bg-slate-50">
<i class="fas fa-copy mr-2"></i>コピー
</button>
</div>
<div class="mt-2 text-sm">
<div id="latest-post-url" class="muted break-all">(まだ投稿していません)</div>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button id="btn-open-post-url" class="pill px-4 py-2 rounded-lg text-xs hover:bg-slate-50">
<i class="fas fa-arrow-up-right-from-square mr-2"></i>開く
</button>
</div>
</div>
<div class="mt-4">
<div class="text-xs font-semibold mb-1">添付</div>
<div id="media-preview" class="grid grid-cols-2 md:grid-cols-3 gap-3"></div>
</div>
</div>
<!-- Timeline -->
<div class="glass p-6 border border-slate-200">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3 mb-3">
<h3 class="text-xl font-bold flex items-center gap-2">
<i class="fas fa-stream text-indigo-500"></i> タイムライン
<span id="post-count" class="text-sm font-normal muted">(0件)</span>
</h3>
<div class="flex items-center gap-2">
<input id="search" class="soft-input text-xs w-full md:w-72" placeholder="検索(本文/ユーザー)">
<button id="btn-refresh" class="pill px-4 py-2 text-xs hover:bg-slate-50">
<i class="fas fa-rotate mr-2"></i>更新
</button>
<button id="btn-export" class="bg-emerald-600 hover:bg-emerald-700 px-4 py-2 rounded-lg text-white text-xs">
<i class="fas fa-download mr-2"></i>データDL
</button>
<button id="btn-import" class="pill px-4 py-2 text-xs hover:bg-slate-50">
<i class="fas fa-upload mr-2"></i>読み込み
</button>
</div>
</div>
<div class="flex flex-wrap gap-2 text-xs mb-3">
<button class="btn-primary px-3 py-2 rounded-full" data-filter="all"><i class="fas fa-globe mr-2"></i>すべて</button>
<button class="pill px-3 py-2 rounded-full hover:bg-slate-50" data-filter="user"><i class="fas fa-user mr-2"></i>ユーザー</button>
<button class="pill px-3 py-2 rounded-full hover:bg-slate-50" data-filter="feed"><i class="fas fa-rss mr-2"></i>Feed</button>
</div>
<div id="timeline" class="space-y-4"></div>
<div id="empty" class="text-center py-14 muted">
<i class="fas fa-comments text-4xl mb-4 text-indigo-500"></i>
<div class="text-lg font-semibold">まだ投稿がありません</div>
<div class="text-xs mt-1">投稿かRSS取得をすると、ここに表示されます</div>
</div>
</div>
</section>
</div>
</main>
<footer class="glass mx-4 mb-4 p-4 text-center border border-slate-200">
<div class="text-xs muted">
<i class="fas fa-copyright mr-1"></i> 2026 Tsumugi – Local SNS(ログイン/登録/ログアウト + RSS投稿ON/OFF)
</div>
</footer>
<script>
/* =========================================================
✅ 会員登録が機能しない問題を修正(欠落なし全体コード)
主因になりやすいポイントを潰しています:
1) crypto.subtle が http / file:// で使えず例外 → 登録が止まる
→ ✅ sha256 失敗時はフォールバック(簡易ハッシュ)で必ず進む
2) users 読み込み前に findUser が走る / 破損データで落ちる
→ ✅ loadUsers を最初に実行&sanitize
3) 登録後にアプリがロック解除されない/ UI未更新
→ ✅ afterAuthBootstrap を確実に呼ぶ
========================================================= */
/* ---------- Storage helpers ---------- */
const LS = {
get(k, fb=null){ try{ const v=localStorage.getItem(k); return (v===null||v===undefined)?fb:v; }catch(e){ return fb; } },
set(k, v){ try{ localStorage.setItem(k, v); }catch(e){} },
del(k){ try{ localStorage.removeItem(k); }catch(e){} }
};
const KEY = {
users:"ts_users",
current:"ts_current_user", // {email}
session:"ts_session", // {email, remember, ts}
posts:"ts_posts",
icons:"ts_icons",
headerBg:"ts_header_bg",
favicon:"ts_favicon",
rss:"ts_rss",
rssSeen:"ts_rss_seen",
latest:"ts_latest_post_url",
webIconSelected:"ts_webicon_selected" // {id}
};
const PRESET_RSS = [
"https://uploadvr.com/feed/",
"https://www.theverge.com/rss/index.xml",
"https://www3.nhk.or.jp/rss/news/cat0.xml"
];
/* ---------- State ---------- */
let users = [];
let currentUser = null; // {email}
let posts = [];
let icons = {};
let homeMode = "home";
let currentFilter = "all";
let searchQuery = "";
let pendingMedia = [];
let rssTimer = null;
/* ✅ WEBアイコン state */
let WEB_ICONS = [];
let selectedWebIconId = null;
/* ---------- Utilities ---------- */
function uid(){ return Date.now().toString(36)+"-"+Math.random().toString(36).slice(2,8); }
function escapeHtml(s){ return (s||"").replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); }
function nowJp(){ return new Date().toLocaleString("ja-JP"); }
function safeUrl(u){
const s = String(u||"").trim();
if(!s) return "";
if(/^https?:\/\//i.test(s)) return s;
return "";
}
async function copyText(t){
try{
if(navigator.clipboard){ await navigator.clipboard.writeText(t); return true; }
}catch(e){}
const ta=document.createElement("textarea"); ta.value=t; document.body.appendChild(ta);
ta.select(); try{ document.execCommand("copy"); }catch(e){}
document.body.removeChild(ta);
return true;
}
function openPopup(url){ window.open(url, "_blank", "noopener,noreferrer,width=980,height=740"); }
function bytesToMB(b){ return Math.round((b/1024/1024)*10)/10; }
function addLog(msg, type="info"){
const el = document.getElementById("rss-log");
if(!el) return;
const div = document.createElement("div");
div.className = type==="err" ? "err" : type==="ok" ? "ok" : "info";
div.textContent = `[${new Date().toLocaleTimeString("ja-JP")}] ${msg}`;
el.appendChild(div);
el.scrollTop = el.scrollHeight;
while(el.children.length > 160) el.removeChild(el.firstChild);
}
/* =========================================================
✅ WEBアイコン(SVG生成)
========================================================= */
function toDataUrlSvg(svg){
const enc = encodeURIComponent(svg)
.replace(/%0A/g,"")
.replace(/%20/g," ")
.replace(/%3D/g,"=")
.replace(/%3A/g,":")
.replace(/%2F/g,"/")
.replace(/%2C/g,",");
return "data:image/svg+xml;charset=utf-8," + enc;
}
function makeIconSvg({bg1,bg2,shape,mark,accent1,accent2}){
const bg = `
<defs>
<radialGradient id="rg" cx="30%" cy="20%" r="90%">
<stop offset="0%" stop-color="${bg2}"/>
<stop offset="55%" stop-color="${bg1}"/>
<stop offset="100%" stop-color="#0b1020"/>
</radialGradient>
<linearGradient id="lg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${accent1}"/>
<stop offset="100%" stop-color="${accent2}"/>
</linearGradient>
<filter id="shadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="10" stdDeviation="8" flood-color="rgba(2,6,23,.45)"/>
</filter>
</defs>
`;
let base;
if(shape==="hex"){
base = `<path d="M32 4 54 16v32L32 60 10 48V16Z" fill="url(#rg)" />`;
}else if(shape==="round"){
base = `<circle cx="32" cy="32" r="28" fill="url(#rg)" />`;
}else{
base = `<rect x="4" y="4" width="56" height="56" rx="16" fill="url(#rg)" />`;
}
let glyph="";
if(mark==="T"){
glyph = `<path d="M20 18h24a2 2 0 0 1 0 4H36v26a2 2 0 0 1-4 0V22h-8a2 2 0 0 1 0-4z" fill="white"/>`;
}else if(mark==="V"){
glyph = `<path d="M18 18h8l6 22 6-22h8L36 50h-8L18 18z" fill="white"/>`;
}else if(mark==="C"){
glyph = `<path d="M44.5 22.5c-2.3-2.3-5.5-3.7-9-3.7-6.9 0-12.5 5.6-12.5 12.5S28.6 43.8 35.5 43.8c3.5 0 6.7-1.4 9-3.7l-3.2-3.2c-1.5 1.5-3.6 2.4-5.8 2.4-4.4 0-8-3.6-8-8s3.6-8 8-8c2.3 0 4.3.9 5.8 2.4l3.2-3.2z" fill="white"/>`;
}else if(mark==="star"){
glyph = `<path d="M32 18l3.9 8.2 9.1 1.3-6.6 6.4 1.6 9.1L32 39.7 23.9 43l1.6-9.1-6.6-6.4 9.1-1.3L32 18z" fill="white"/>`;
}else if(mark==="bolt"){
glyph = `<path d="M36 14 20 36h10l-2 14 16-22H34l2-14z" fill="white"/>`;
}else if(mark==="eye"){
glyph = `<path d="M32 22c-10 0-18 10-18 10s8 10 18 10 18-10 18-10-8-10-18-10zm0 16a6 6 0 1 1 0-12 6 6 0 0 1 0 12z" fill="white"/><circle cx="32" cy="32" r="2.6" fill="white"/>`;
}else if(mark==="crown"){
glyph = `<path d="M20 40l-2-16 8 8 6-10 6 10 8-8-2 16H20z" fill="white"/><path d="M22 42h20" stroke="url(#lg)" stroke-width="3" stroke-linecap="round" opacity=".8"/>`;
}
const accents = `
<path d="M18 20h28" stroke="url(#lg)" stroke-width="3" stroke-linecap="round" opacity=".85"/>
<path d="M32 22v26" stroke="url(#lg)" stroke-width="3" stroke-linecap="round" opacity=".65"/>
`;
const border = shape==="round"
? `<circle cx="32" cy="32" r="28" fill="none" stroke="rgba(255,255,255,.24)"/>`
: shape==="hex"
? `<path d="M32 4 54 16v32L32 60 10 48V16Z" fill="none" stroke="rgba(255,255,255,.24)"/>`
: `<rect x="4" y="4" width="56" height="56" rx="16" fill="none" stroke="rgba(255,255,255,.22)"/>`;
return `
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 64 64">
${bg}
<g filter="url(#shadow)">
${base}
${accents}
${glyph}
${border}
</g>
</svg>
`.trim();
}
function buildWebIconPack(){
const presets = [
{ id:"tsumugi_T", name:"Tsumugi T", desc:"シグネチャー / Squircle", shape:"squircle", mark:"T", bg1:"#5b5fe6", bg2:"#7c3aed", accent1:"#a78bfa", accent2:"#60a5fa" },
{ id:"verse_V", name:"Verse V", desc:"SNS感 / Round", shape:"round", mark:"V", bg1:"#06b6d4", bg2:"#3b82f6", accent1:"#67e8f9", accent2:"#a78bfa" },
{ id:"chron_C", name:"Chronicle C", desc:"アーカイブ / Squircle", shape:"squircle", mark:"C", bg1:"#22c55e", bg2:"#10b981", accent1:"#bbf7d0", accent2:"#60a5fa" },
{ id:"spark_star", name:"Spark Star", desc:"キラッ / Round", shape:"round", mark:"star", bg1:"#f59e0b", bg2:"#ef4444", accent1:"#fde68a", accent2:"#fca5a5" },
{ id:"bolt_hex", name:"Neon Bolt", desc:"稲妻 / Hex", shape:"hex", mark:"bolt", bg1:"#a855f7", bg2:"#ec4899", accent1:"#c4b5fd", accent2:"#93c5fd" },
{ id:"eye_squ", name:"Observer", desc:"収集 / Squircle", shape:"squircle", mark:"eye", bg1:"#0ea5e9", bg2:"#22c55e", accent1:"#7dd3fc", accent2:"#86efac" },
{ id:"crown_squ", name:"Crown", desc:"上品 / Squircle", shape:"squircle", mark:"crown", bg1:"#f97316", bg2:"#a855f7", accent1:"#fdba74", accent2:"#c4b5fd" }
];
WEB_ICONS = presets.map(p=>{
const svg = makeIconSvg(p);
return { id:p.id, name:p.name, desc:p.desc, dataUrl: toDataUrlSvg(svg) };
});
}
function saveSelectedWebIcon(id){
selectedWebIconId = id;
LS.set(KEY.webIconSelected, JSON.stringify({id}));
}
function loadSelectedWebIcon(){
const obj = JSON.parse(LS.get(KEY.webIconSelected, "null") || "null");
selectedWebIconId = (obj && obj.id) ? obj.id : (WEB_ICONS[0]?.id || null);
}
function getSelectedWebIcon(){
if(!selectedWebIconId) return WEB_ICONS[0] || null;
return WEB_ICONS.find(x=>x.id===selectedWebIconId) || WEB_ICONS[0] || null;
}
function renderWebIconGrid(){
const grid = document.getElementById("icon-grid");
if(!grid) return;
grid.innerHTML = "";
WEB_ICONS.forEach(icon=>{
const tile = document.createElement("div");
tile.className = "icon-tile";
tile.setAttribute("data-iconid", icon.id);
tile.innerHTML = `
<div class="flex items-center gap-3">
<div class="icon-preview"><img src="${icon.dataUrl}" alt="${escapeHtml(icon.name)}"></div>
<div class="min-w-0">
<div class="icon-tag truncate">${escapeHtml(icon.name)}</div>
<div class="icon-sub truncate">${escapeHtml(icon.desc)}</div>
</div>
</div>
`;
tile.onclick = ()=>{ selectWebIcon(icon.id); };
grid.appendChild(tile);
});
selectWebIcon(selectedWebIconId, false);
}
function selectWebIcon(id, persist=true){
const icon = WEB_ICONS.find(x=>x.id===id) || WEB_ICONS[0];
if(!icon) return;
if(persist) saveSelectedWebIcon(icon.id);
document.querySelectorAll(".icon-tile").forEach(el=>{
el.classList.toggle("active", el.getAttribute("data-iconid")===icon.id);
});
document.getElementById("icon-selected-preview").src = icon.dataUrl;
document.getElementById("icon-selected-name").textContent = icon.name;
document.getElementById("icon-selected-desc").textContent = icon.desc;
}
/* =========================================================
✅ Favicon(確実版):複数relを全部更新する
========================================================= */
function applyFavicon(dataUrl){
const url = String(dataUrl || "").trim();
if(!url) return;
const setHref = (id, rel, type=null, sizes=null)=>{
let link = document.getElementById(id);
if(!link){
link = document.createElement("link");
link.id = id;
link.rel = rel;
if(type) link.type = type;
if(sizes) link.sizes = sizes;
document.head.appendChild(link);
}
link.href = url;
};
setHref("favicon-svg", "icon", "image/svg+xml", null);
setHref("favicon-shortcut", "shortcut icon", null, null);
setHref("favicon-any", "icon", null, "any");
setHref("apple-touch", "apple-touch-icon", null, null);
LS.set(KEY.favicon, url);
}
function resetFavicon(){
LS.del(KEY.favicon);
const base = document.getElementById("favicon-svg")?.href;
if(base) applyFavicon(base);
alert("Faviconを初期に戻しました(反映されない場合は Ctrl+F5)");
}
function applySelectedToFavicon(){
const icon = getSelectedWebIcon(); if(!icon) return;
applyFavicon(icon.dataUrl);
alert("Faviconに適用しました");
}
function applySelectedToFeed(){
const icon = getSelectedWebIcon(); if(!icon) return;
icons.feed = icon.dataUrl;
LS.set(KEY.icons, JSON.stringify(icons));
renderTimeline();
rebuildVRWall();
alert("Feedアイコンに適用しました");
}
function applySelectedToProfile(){
const icon = getSelectedWebIcon(); if(!icon) return;
const me = getMe();
if(!me) return alert("ログインしてください");
me.profile.avatar = icon.dataUrl;
saveUsers();
syncProfileUI();
renderTimeline();
rebuildVRWall();
alert("プロフィール画像に適用しました");
}
function randomPickIcon(){
if(!WEB_ICONS.length) return;
const i = Math.floor(Math.random()*WEB_ICONS.length);
selectWebIcon(WEB_ICONS[i].id);
}
/* =========================================================
✅ Auth(会員登録/ログイン)— 修正点あり
========================================================= */
function normalizeEmail(s){ return String(s||"").trim().toLowerCase(); }
/* ✅ 修正:sha256 が使えない環境でも必ず動くようにする */
async function sha256(text){
const str = String(text);
try{
if(!(window.crypto && crypto.subtle && window.isSecureContext)){
// http / file:// だと subtle が動かない → fallback
return simpleHash(str);
}
const enc = new TextEncoder().encode(str);
const buf = await crypto.subtle.digest("SHA-256", enc);
const arr = Array.from(new Uint8Array(buf));
return arr.map(b=>b.toString(16).padStart(2,"0")).join("");
}catch(e){
// 例外が出ても登録を止めない
return simpleHash(str);
}
}
/* ✅ 追加:簡易ハッシュ(強度は低いが “動かない” を防ぐ) */
function simpleHash(str){
let h1 = 0xdeadbeef ^ str.length, h2 = 0x41c6ce57 ^ str.length;
for(let i=0;i<str.length;i++){
const ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = (h1 ^ (h1>>>16)) >>> 0;
h2 = (h2 ^ (h2>>>16)) >>> 0;
return "fh_" + h1.toString(16).padStart(8,"0") + h2.toString(16).padStart(8,"0");
}
/* ユーザー配列の読み書き */
function loadUsers(){
users = JSON.parse(LS.get(KEY.users, "[]") || "[]");
if(!Array.isArray(users)) users = [];
users = users.map(u=>{
if(!u || !u.email) return null;
if(!u.profile) u.profile = { username:"", bio:"", avatar:"https://via.placeholder.com/100" };
if(!u.following) u.following = [];
if(!u.blocked) u.blocked = [];
if(!u.muted) u.muted = [];
if(!u.createdAt) u.createdAt = Date.now();
if(typeof u.passHash !== "string") u.passHash = u.passHash ? String(u.passHash) : "";
u.email = normalizeEmail(u.email);
return u;
}).filter(Boolean);
LS.set(KEY.users, JSON.stringify(users));
}
function saveUsers(){ LS.set(KEY.users, JSON.stringify(users)); }
function loadSession(){
const s = JSON.parse(LS.get(KEY.session, "null") || "null");
if(!s || !s.email) return null;
if(!s.remember){
LS.del(KEY.session);
return null;
}
return s;
}
function saveSession(email, remember){
const s = { email: normalizeEmail(email), remember: !!remember, ts: Date.now() };
LS.set(KEY.session, JSON.stringify(s));
}
function clearSession(){ LS.del(KEY.session); }
function setCurrentUser(email){
currentUser = { email: normalizeEmail(email) };
LS.set(KEY.current, JSON.stringify(currentUser));
}
function clearCurrentUser(){
currentUser = null;
LS.del(KEY.current);
}
function ensureGuest(){
const guestEmail="guest@local";
if(!users.find(u=>u.email===guestEmail)){
users.push({
email:guestEmail,
passHash:"",
profile:{ username:"ゲスト", bio:"", avatar:"https://via.placeholder.com/100" },
following:[], blocked:[], muted:[],
createdAt: Date.now()
});
saveUsers();
}
setCurrentUser(guestEmail);
}
function findUser(email){
const e = normalizeEmail(email);
return users.find(u=>normalizeEmail(u.email)===e) || null;
}
function getMe(){
if(!currentUser || !currentUser.email) return null;
return findUser(currentUser.email);
}
function authMsg(text, kind="info"){
const el = document.getElementById("auth-msg");
if(!el) return;
if(!text){ el.textContent=""; el.className="text-xs muted"; return; }
el.textContent = text;
el.className = "text-xs " + (kind==="err" ? "text-red-700" : kind==="ok" ? "text-emerald-700" : "muted");
}
function openAuth(){
document.getElementById("auth-overlay").style.display="block";
authMsg("");
}
function closeAuth(){
document.getElementById("auth-overlay").style.display="none";
authMsg("");
}
function setAuthTab(tab){
const tLogin = document.getElementById("tab-login");
const tReg = document.getElementById("tab-register");
const pLogin = document.getElementById("panel-login");
const pReg = document.getElementById("panel-register");
const onLogin = tab==="login";
tLogin.classList.toggle("active", onLogin);
tReg.classList.toggle("active", !onLogin);
pLogin.style.display = onLogin ? "" : "none";
pReg.style.display = onLogin ? "none" : "";
authMsg("");
}
async function doRegister(){
loadUsers();
const email = normalizeEmail(document.getElementById("reg-email").value);
const pass = String(document.getElementById("reg-pass").value || "");
const pass2 = String(document.getElementById("reg-pass2").value || "");
const username = String(document.getElementById("reg-username").value || "").trim();
if(!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)){
authMsg("メールアドレスが不正です", "err"); return;
}
if(pass.length < 4){
authMsg("パスワードは4文字以上にしてください", "err"); return;
}
if(pass !== pass2){
authMsg("パスワード確認が一致しません", "err"); return;
}
if(username.length > 20){
authMsg("ユーザー名は20文字以内です", "err"); return;
}
if(findUser(email)){
authMsg("このメールアドレスは既に登録済みです", "err"); return;
}
let passHash;
try{
passHash = await sha256(pass);
}catch(e){
passHash = simpleHash(pass);
}
users.push({
email,
passHash,
profile:{
username: username || email.split("@")[0] || "User",
bio:"",
avatar: (getSelectedWebIcon() && getSelectedWebIcon().dataUrl) ? getSelectedWebIcon().dataUrl : "https://via.placeholder.com/100"
},
following:[],
blocked:[],
muted:[],
createdAt: Date.now()
});
saveUsers();
setCurrentUser(email);
saveSession(email, true);
authMsg("登録しました。ログイン完了!", "ok");
document.getElementById("reg-email").value="";
document.getElementById("reg-pass").value="";
document.getElementById("reg-pass2").value="";
document.getElementById("reg-username").value="";
closeAuth();
afterAuthBootstrap();
}
async function doLogin(){
loadUsers();
const email = normalizeEmail(document.getElementById("login-email").value);
const pass = String(document.getElementById("login-pass").value || "");
const remember = !!document.getElementById("login-remember").checked;
if(!email){
authMsg("メールアドレスを入力してください", "err"); return;
}
const u = findUser(email);
if(!u || !u.passHash){
authMsg("ユーザーが見つかりません(会員登録してください)", "err"); return;
}
const passHash = await sha256(pass);
if(passHash !== u.passHash){
authMsg("パスワードが違います", "err"); return;
}
setCurrentUser(email);
if(remember) saveSession(email, true);
else clearSession();
authMsg("ログインしました", "ok");
closeAuth();
afterAuthBootstrap();
}
function doLogout(){
if(!confirm("ログアウトしますか?")) return;
stopRss(true);
clearCurrentUser();
clearSession();
openAuth();
setAuthTab("login");
lockAppForAuth(true);
syncProfileUI();
renderTimeline();
rebuildVRWall();
}
function lockAppForAuth(locked){
const main = document.getElementById("main-content");
const header = document.getElementById("app-header");
const footer = document.querySelector("footer");
const disable = (el)=>{
if(!el) return;
el.style.filter = locked ? "blur(2px)" : "";
el.style.opacity = locked ? "0.65" : "";
el.style.pointerEvents = locked ? "none" : "";
el.style.userSelect = locked ? "none" : "";
};
disable(main); disable(header); disable(footer);
}
function restoreAuthState(){
const s = loadSession();
if(s && s.email && findUser(s.email)){
setCurrentUser(s.email);
return true;
}
const cur = JSON.parse(LS.get(KEY.current, "null") || "null");
if(cur && cur.email && findUser(cur.email)){
setCurrentUser(cur.email);
return true;
}
clearCurrentUser();
return false;
}
/* ---------- Posts ---------- */
function loadPosts(){ posts = JSON.parse(LS.get(KEY.posts, "[]") || "[]"); if(!Array.isArray(posts)) posts=[]; }
function savePosts(){ LS.set(KEY.posts, JSON.stringify(posts)); }
function countMyPosts(){
const me=getMe(); if(!me) return 0;
return posts.filter(p=>p.authorEmail===me.email && p.type==="user").length;
}
/* ---------- Appearance ---------- */
function applyHeaderBg(dataUrl){ const h=document.getElementById("app-header"); if(h) h.style.backgroundImage=`url('${dataUrl}')`; LS.set(KEY.headerBg, dataUrl); }
function resetHeaderBg(){ const h=document.getElementById("app-header"); if(h) h.style.backgroundImage=""; LS.del(KEY.headerBg); }
function loadAppearance(){
const defaultFeed = (WEB_ICONS.find(x=>x.id==="eye_squ") || WEB_ICONS[0] || null);
const DEFAULT_ICONS = { feed: defaultFeed ? defaultFeed.dataUrl : "https://via.placeholder.com/40" };
const ico = JSON.parse(LS.get(KEY.icons, "null") || "null");
if(ico && typeof ico==="object") icons = { ...DEFAULT_ICONS, ...ico };
else icons = { ...DEFAULT_ICONS };
const hb = LS.get(KEY.headerBg, null); if(hb) applyHeaderBg(hb);
const fav = LS.get(KEY.favicon, null); if(fav) applyFavicon(fav); else applyFavicon(document.getElementById("favicon-svg")?.href);
const me = getMe();
document.getElementById("header-avatar").src = (me && me.profile && me.profile.avatar) ? me.profile.avatar : (getSelectedWebIcon()?.dataUrl || "https://via.placeholder.com/80");
}
/* ---------- Profile UI ---------- */
function syncProfileUI(){
const me = getMe();
const isLoggedIn = !!me;
document.getElementById("header-email").textContent = me ? me.email : "";
document.getElementById("header-username").textContent = me ? (me.profile.username||"未設定") : "未ログイン";
document.getElementById("header-avatar").src = me ? (me.profile.avatar||"https://via.placeholder.com/80") : (getSelectedWebIcon()?.dataUrl || "https://via.placeholder.com/80");
document.getElementById("profile-avatar").src = me ? (me.profile.avatar||"https://via.placeholder.com/100") : (getSelectedWebIcon()?.dataUrl || "https://via.placeholder.com/100");
document.getElementById("inp-username").value = me ? (me.profile.username||"") : "";
document.getElementById("inp-bio").value = me ? (me.profile.bio||"") : "";
document.getElementById("preview-username").textContent = me ? (me.profile.username||"未設定") : "未ログイン";
document.getElementById("preview-bio").textContent = me ? (me.profile.bio||"まだ自己紹介がありません") : "ログインしてください";
document.getElementById("btn-logout").style.display = isLoggedIn ? "" : "none";
}
function saveProfile(){
const me=getMe();
if(!me) return alert("ログインしてください");
const name=(document.getElementById("inp-username").value||"").trim();
const bio=(document.getElementById("inp-bio").value||"").trim();
if(name.length>20) return alert("ユーザー名は20文字以内です");
me.profile.username=name||me.profile.username||"User";
me.profile.bio=bio;
saveUsers();
syncProfileUI();
syncStats();
renderTimeline();
rebuildVRWall();
alert("保存しました");
}
function syncStats(){
const me=getMe();
if(!me){
document.getElementById("stat-followers").textContent="0";
document.getElementById("stat-following").textContent="0";
document.getElementById("stat-posts").textContent="0";
return;
}
const followers=users.filter(u=>(u.following||[]).includes(me.email)).length;
document.getElementById("stat-followers").textContent=String(followers);
document.getElementById("stat-following").textContent=String((me.following||[]).length);
document.getElementById("stat-posts").textContent=String(countMyPosts());
}
/* ---------- Post URL ---------- */
function postUrl(postId){
const base = location.href.split("#")[0];
return `${base}#post=${encodeURIComponent(postId)}`;
}
function setLatestPostUrl(postId){
const u = postUrl(postId);
LS.set(KEY.latest, u);
document.getElementById("latest-post-url").textContent = u;
}
function loadLatestPostUrl(){
const u = LS.get(KEY.latest, "");
document.getElementById("latest-post-url").textContent = u || "(まだ投稿していません)";
}
function highlightFromHash(scroll=true){
const h = location.hash || "";
const m = h.match(/post=([^&]+)/);
if(!m) return;
const pid = decodeURIComponent(m[1]);
const el = document.getElementById("post-"+pid);
if(!el) return;
el.style.outline="3px solid rgba(91,95,230,.35)";
el.style.boxShadow="0 0 0 6px rgba(91,95,230,.10)";
if(scroll) el.scrollIntoView({behavior:"smooth", block:"center"});
setTimeout(()=>{ el.style.outline=""; el.style.boxShadow=""; }, 2200);
}
/* ---------- Media attach ---------- */
function refreshMediaPreview(){
const wrap=document.getElementById("media-preview");
wrap.innerHTML="";
if(pendingMedia.length===0) return;
pendingMedia.forEach((m,idx)=>{
const card=document.createElement("div");
card.className="glass p-3 border border-slate-200";
const top=document.createElement("div");
top.className="flex items-center justify-between mb-2";
top.innerHTML=`<div class="text-xs font-semibold">${escapeHtml(m.name)} <span class="muted">(${bytesToMB(m.size)}MB)</span></div>
<button class="pill px-3 py-1 text-xs hover:bg-slate-50">削除</button>`;
top.querySelector("button").onclick=()=>{ pendingMedia.splice(idx,1); refreshMediaPreview(); };
const body=document.createElement("div");
if((m.type||"").startsWith("image/")) body.innerHTML=`<img src="${m.dataUrl}" class="w-full rounded-xl border border-slate-200 object-cover" style="max-height:180px">`;
else body.innerHTML=`<video src="${m.dataUrl}" controls class="w-full rounded-xl border border-slate-200" style="max-height:180px"></video>`;
card.appendChild(top); card.appendChild(body); wrap.appendChild(card);
});
}
function clearMedia(){ pendingMedia=[]; refreshMediaPreview(); }
/* =========================================================
RSS(ON/OFF)
========================================================= */
function loadRss(){
let rss = JSON.parse(LS.get(KEY.rss, "null") || "null");
if(!rss){
rss = { feeds: PRESET_RSS.slice(), intervalSec: 120, enabled: false, lastRun: null };
LS.set(KEY.rss, JSON.stringify(rss));
}
if(!Array.isArray(rss.feeds)) rss.feeds = PRESET_RSS.slice();
rss.intervalSec = Math.max(10, Number(rss.intervalSec)||120);
rss.enabled = !!rss.enabled;
return rss;
}
function saveRss(rss){ LS.set(KEY.rss, JSON.stringify(rss)); }
function loadSeen(){ let seen=JSON.parse(LS.get(KEY.rssSeen,"{}")||"{}"); if(!seen||typeof seen!=="object") seen={}; return seen; }
function saveSeen(seen){ LS.set(KEY.rssSeen, JSON.stringify(seen)); }
function setRssIndicator(running){
document.getElementById("rss-dot").className = "status-dot " + (running ? "on":"off");
document.getElementById("rss-status-text").textContent = running ? "動作中":"停止中";
}
function setToggleUI(enabled){
const t = document.getElementById("rss-toggle");
t.classList.toggle("on", !!enabled);
t.setAttribute("aria-checked", enabled ? "true":"false");
}
function rssUI(){
const rss=loadRss();
document.getElementById("rss-interval").value=rss.intervalSec||120;
const wrap=document.getElementById("rss-list");
wrap.innerHTML="";
(rss.feeds||[]).forEach((u,i)=>{
const row=document.createElement("div");
row.className="glass p-3 border border-slate-200 flex items-center gap-2";
row.innerHTML=`<div class="text-[11px] flex-1 truncate" title="${escapeHtml(u)}">${escapeHtml(u)}</div>
<button class="pill px-3 py-2 text-xs hover:bg-slate-50" title="削除"><i class="fas fa-trash"></i></button>`;
row.querySelector("button").onclick=()=>{
if(!confirm("このRSSを削除しますか?")) return;
const r=loadRss(); r.feeds.splice(i,1); saveRss(r); rssUI();
addLog("削除: "+u,"info");
};
wrap.appendChild(row);
});
setToggleUI(rss.enabled);
setRssIndicator(!!rssTimer);
}
function setRssEnabled(enabled){
const rss = loadRss();
rss.enabled = !!enabled;
saveRss(rss);
setToggleUI(rss.enabled);
if(rss.enabled) startRss();
else stopRss(true);
}
function addRssFeed(){
const inp=document.getElementById("rss-url");
const url=(inp.value||"").trim();
if(!/^https?:\/\/.+/i.test(url)) return addLog("正しいRSS URLを入れてください","err");
const rss=loadRss();
if(rss.feeds.includes(url)) return addLog("すでに登録済みです","err");
rss.feeds.push(url); saveRss(rss); inp.value="";
rssUI();
addLog("追加: "+url,"ok");
}
function setRssInterval(){
const v=Number(document.getElementById("rss-interval").value)||120;
if(v<10) return alert("10秒以上にしてください");
const rss=loadRss(); rss.intervalSec=v; saveRss(rss);
addLog("間隔設定: "+v+"秒","ok");
if(rssTimer){ startRss(); }
}
function clearSeen(){
LS.del(KEY.rssSeen);
addLog("既読リセット: ts_rss_seen を削除","ok");
}
/* RSS fetch */
async function fetchTextDirect(url){
const res = await fetch(url, { cache:"no-store" });
if(!res.ok) throw new Error("Direct HTTP "+res.status);
return await res.text();
}
async function fetchTextViaAllOriginsRaw(url){
const endpoint = "https://api.allorigins.win/raw?url="+encodeURIComponent(url);
const res=await fetch(endpoint,{cache:"no-store"});
if(!res.ok) throw new Error("AllOrigins HTTP "+res.status);
return await res.text();
}
async function fetchTextViaThingProxy(url){
const endpoint = "https://thingproxy.freeboard.io/fetch/"+url;
const res=await fetch(endpoint,{cache:"no-store"});
if(!res.ok) throw new Error("ThingProxy HTTP "+res.status);
return await res.text();
}
async function fetchJsonViaRss2Json(url){
const endpoint="https://api.rss2json.com/v1/api.json?rss_url="+encodeURIComponent(url);
const res=await fetch(endpoint,{cache:"no-store"});
if(!res.ok) throw new Error("rss2json HTTP "+res.status);
const j=await res.json();
if(j && j.status && j.status!=="ok") throw new Error("rss2json status="+j.status+" msg="+(j.message||""));
return j;
}
function parseXmlToItems(xmlText){
const dp=new DOMParser();
const doc=dp.parseFromString(xmlText,"text/xml");
if(doc.querySelector("parsererror")) throw new Error("XML parse error");
const nodes = Array.from(doc.querySelectorAll("item, entry")).slice(0, 20);
const pickText = (n, sels) => {
for(const s of sels){
const el = n.querySelector(s);
if(el && el.textContent) return el.textContent.trim();
}
return "";
};
return nodes.map(n=>{
const title = pickText(n, ["title"]);
let link = "";
const linkEl = n.querySelector("link");
if(linkEl){
link = (linkEl.getAttribute("href")||"").trim() || (linkEl.textContent||"").trim();
}
if(!link){ link = pickText(n, ["id"]); }
const pub = pickText(n, ["pubDate","published","updated","dc\\:date","date"]);
return { title: title || "(no title)", link, pubDate: pub };
}).filter(x=>x.title || x.link);
}
function normalizeItems(items){
return (items||[]).map(it=>{
const title=(it.title||"").trim();
const link=(it.link||"").trim();
const pubDate=(it.pubDate||it.pub||"").trim();
return { title:title||"(no title)", link, pubDate };
}).filter(it=>it.title || it.link);
}
async function fetchFeedItems(feedUrl, debug=false){
const url = feedUrl.trim();
let lastErr = null;
try{
const xml = await fetchTextDirect(url);
const items = normalizeItems(parseXmlToItems(xml));
if(items.length){ if(debug) addLog(`取得成功(Direct): ${items.length}件`,"ok"); return items; }
}catch(e){ lastErr=e; if(debug) addLog("Direct失敗: "+(e.message||e),"err"); }
try{
const xml = await fetchTextViaAllOriginsRaw(url);
const items = normalizeItems(parseXmlToItems(xml));
if(debug) addLog(`取得成功(AllOrigins): ${items.length}件`,"ok");
return items;
}catch(e){ lastErr=e; if(debug) addLog("AllOrigins失敗: "+(e.message||e),"err"); }
try{
const xml = await fetchTextViaThingProxy(url);
const items = normalizeItems(parseXmlToItems(xml));
if(debug) addLog(`取得成功(ThingProxy): ${items.length}件`,"ok");
return items;
}catch(e){ lastErr=e; if(debug) addLog("ThingProxy失敗: "+(e.message||e),"err"); }
try{
const j = await fetchJsonViaRss2Json(url);
const items = normalizeItems((j.items||[]).map(x=>({ title:x.title, link:x.link, pubDate:x.pubDate })));
if(debug) addLog(`取得成功(rss2json): ${items.length}件`,"ok");
return items;
}catch(e){ lastErr=e; if(debug) addLog("rss2json失敗: "+(e.message||e),"err"); }
throw lastErr || new Error("RSS fetch failed");
}
function startRss(){
stopRss(false);
const rss = loadRss();
if(!rss.enabled){
rss.enabled = true; saveRss(rss); setToggleUI(true);
}
setRssIndicator(true);
addLog("RSS自動投稿: ON(定期取得開始)","ok");
fetchAllFeedsOnce(true);
rssTimer = setInterval(()=>{ fetchAllFeedsOnce(false); }, (rss.intervalSec||120)*1000);
}
function stopRss(writeState=true){
if(rssTimer){ clearInterval(rssTimer); rssTimer=null; }
setRssIndicator(false);
if(writeState){
const rss = loadRss();
rss.enabled = false;
saveRss(rss);
setToggleUI(false);
addLog("RSS自動投稿: OFF(停止)","info");
}
}
function createPost({text, type="user", link="", media=[], authorEmail=null}){
const me=getMe();
if(type==="user" && !me){
alert("ログインしてください");
openAuth();
return null;
}
const post={
id: uid(),
text:(text||"").trim(),
type,
link,
media,
time: nowJp(),
authorEmail: authorEmail || (me ? me.email : "anonymous@local")
};
posts.unshift(post);
savePosts();
setLatestPostUrl(post.id);
syncStats();
renderTimeline();
return post;
}
async function fetchAllFeedsOnce(verbose=false){
const rss = loadRss();
if(!rss.feeds || rss.feeds.length===0){
addLog("RSSが未登録です","err");
return;
}
if(verbose) addLog(`取得開始: feeds=${rss.feeds.length}`,"info");
const seen = loadSeen();
let newCount = 0;
for(const feedUrl of rss.feeds){
try{
const items = await fetchFeedItems(feedUrl, verbose);
for(const it of items.slice(0, 10)){
const link = safeUrl(it.link) || "";
const key = link || (feedUrl + "::" + (it.title||""));
if(!key) continue;
if(seen[key]) continue;
seen[key]=true;
createPost({
text: it.title || "(no title)",
type: "feed",
link: link || "",
media: [],
authorEmail: "feed@local"
});
newCount++;
if(newCount >= 20) break;
}
}catch(e){
addLog(`失敗: ${feedUrl} / ${(e.message||e)}`,"err");
}
if(newCount >= 20) break;
}
saveSeen(seen);
if(verbose || newCount>0){
addLog(`取得完了: 新規 ${newCount}件` , newCount ? "ok" : "info");
}
const rr = loadRss();
rr.lastRun = Date.now();
saveRss(rr);
}
async function rssTestOne(){
const url=(document.getElementById("rss-url").value||"").trim();
const target=url || (loadRss().feeds[0]||"");
if(!target) return alert("テストするURLがありません");
addLog("URL検査: "+target,"info");
try{
const items = await fetchFeedItems(target, true);
addLog(`検査OK: items=${items.length}(先頭: ${items[0]?items[0].title:"なし"})`, items.length? "ok":"info");
}catch(e){
addLog("検査失敗: "+(e.message||e),"err");
alert("RSS検査で失敗しました。ログを見てください。");
}
}
/* =========================================================
Timeline rendering
========================================================= */
function badgeForType(p){
if(p.type==="feed") return `<span class="badge"><i class="fas fa-rss"></i>FEED</span>`;
return `<span class="badge"><i class="fas fa-user"></i>ユーザー</span>`;
}
function iconForPost(p){
if(p.type==="feed") return icons.feed;
const u=users.find(x=>x.email===p.authorEmail);
return (u && u.profile && u.profile.avatar) ? u.profile.avatar : (getSelectedWebIcon()?.dataUrl || "https://via.placeholder.com/40");
}
function usernameForPost(p){
if(p.type==="feed") return "Feed";
const u=users.find(x=>x.email===p.authorEmail);
return (u && u.profile && u.profile.username) ? u.profile.username : "Unknown";
}
function visibleInHome(p){
if(homeMode!=="home") return true;
const me=getMe();
if(!me){ return true; }
if((me.blocked||[]).includes(p.authorEmail)) return false;
if((me.muted||[]).includes(p.authorEmail)) return false;
if(p.type==="feed") return true;
if(p.authorEmail===me.email) return true;
return (me.following||[]).includes(p.authorEmail);
}
function filterPosts(){
let arr=posts.slice();
arr=arr.filter(p=>visibleInHome(p));
arr=arr.filter(p=>{
if(currentFilter==="all") return true;
if(currentFilter==="user") return p.type==="user";
if(currentFilter==="feed") return p.type==="feed";
return true;
});
if(searchQuery.trim()){
const q=searchQuery.trim().toLowerCase();
arr=arr.filter(p=>((p.text||"")+" "+usernameForPost(p)).toLowerCase().includes(q));
}
return arr;
}
async function copyArticleUrl(pid){
const p=posts.find(x=>String(x.id)===String(pid));
const u=p?safeUrl(p.link):"";
if(!u) return alert("記事URLがありません");
await copyText(u);
alert("記事URLをコピーしました");
}
function openArticleUrl(pid){
const p=posts.find(x=>String(x.id)===String(pid));
const u=p?safeUrl(p.link):"";
if(!u) return alert("記事URLがありません");
openPopup(u);
}
function renderTimeline(){
const tl=document.getElementById("timeline");
const emp=document.getElementById("empty");
const cnt=document.getElementById("post-count");
const arr=filterPosts();
cnt.textContent=`(${arr.length}件)`;
if(arr.length===0){ tl.innerHTML=""; emp.style.display=""; return; }
emp.style.display="none";
tl.innerHTML = arr.map(p=>{
const pid=String(p.id);
const myPostUrl=postUrl(pid);
const articleUrl=safeUrl(p.link);
const mainText = articleUrl
? `<a href="${escapeHtml(articleUrl)}" target="_blank" rel="noopener" class="text-sky-600 underline">${escapeHtml(p.text)}</a>`
: escapeHtml(p.text);
const articleRow = articleUrl ? `
<div class="mt-3 glass p-3 border border-slate-200">
<div class="flex items-center justify-between gap-2">
<div class="text-xs font-semibold flex items-center gap-2">
<i class="fas fa-link text-amber-600"></i> 記事URL
</div>
<div class="flex items-center gap-2">
<button class="pill px-3 py-2 text-xs hover:bg-slate-50" onclick="copyArticleUrl('${escapeHtml(pid)}')">
<i class="fas fa-copy mr-2"></i>コピー
</button>
<button class="pill px-3 py-2 text-xs hover:bg-slate-50" onclick="openArticleUrl('${escapeHtml(pid)}')">
<i class="fas fa-arrow-up-right-from-square mr-2"></i>開く
</button>
</div>
</div>
<div class="mt-2 text-xs muted break-all">${escapeHtml(articleUrl)}</div>
</div>
` : "";
const mediaHtml=(p.media && p.media.length) ? `
<div class="grid grid-cols-2 md:grid-cols-3 gap-3 mt-3">
${p.media.map(m=>{
if((m.type||"").startsWith("image/")) return `<img src="${m.dataUrl}" class="w-full rounded-xl border border-slate-200 object-cover" style="max-height:220px">`;
return `<video src="${m.dataUrl}" controls class="w-full rounded-xl border border-slate-200" style="max-height:220px"></video>`;
}).join("")}
</div>` : "";
return `
<article class="timeline-post p-6" id="post-${escapeHtml(pid)}" data-postid="${escapeHtml(pid)}">
<div class="flex items-start justify-between gap-3">
<div class="flex items-center gap-3">
<img src="${escapeHtml(iconForPost(p))}" class="mini-avatar" onerror="this.src='https://via.placeholder.com/40'">
<div>
<div class="flex items-center gap-2">
<div class="font-semibold">${escapeHtml(usernameForPost(p))}</div>
${badgeForType(p)}
</div>
<div class="text-xs muted">${escapeHtml(p.time)}</div>
</div>
</div>
</div>
<div class="mt-3 text-sm leading-relaxed">${mainText}</div>
${articleRow}
${mediaHtml}
<div class="mt-3 text-xs muted break-all">
<i class="fas fa-link mr-2 text-indigo-600"></i>投稿URL: ${escapeHtml(myPostUrl)}
</div>
</article>
`;
}).join("");
highlightFromHash(false);
}
/* ---------- Export/Import/Clear ---------- */
function deleteAllPosts(){
if(!confirm("全投稿を削除しますか?")) return;
posts=[]; savePosts(); syncStats(); renderTimeline(); rebuildVRWall();
}
function exportData(){
const payload = {
users, posts, icons,
headerBg: LS.get(KEY.headerBg, null),
favicon: LS.get(KEY.favicon, null),
rss: JSON.parse(LS.get(KEY.rss, "null") || "null"),
session: JSON.parse(LS.get(KEY.session, "null") || "null"),
current: JSON.parse(LS.get(KEY.current, "null") || "null"),
webIconSelected: JSON.parse(LS.get(KEY.webIconSelected, "null") || "null")
};
const blob=new Blob([JSON.stringify(payload,null,2)],{type:"application/json"});
const url=URL.createObjectURL(blob);
const a=document.createElement("a");
a.href=url; a.download="tsumugi_backup.json"; a.click();
URL.revokeObjectURL(url);
}
function importData(){
const input=document.createElement("input");
input.type="file"; input.accept="application/json";
input.onchange=()=>{
const f=input.files && input.files[0]; if(!f) return;
const r=new FileReader();
r.onload=()=>{
try{
const obj=JSON.parse(String(r.result||"{}"));
if(obj.users){ users=obj.users; saveUsers(); }
if(obj.posts){ posts=obj.posts; savePosts(); }
if(obj.icons){ icons=obj.icons; LS.set(KEY.icons, JSON.stringify(icons)); }
if(obj.headerBg){ applyHeaderBg(obj.headerBg); }
if(obj.favicon){ applyFavicon(obj.favicon); }
if(obj.rss){ LS.set(KEY.rss, JSON.stringify(obj.rss)); }
if(obj.session){ LS.set(KEY.session, JSON.stringify(obj.session)); }
if(obj.current){ LS.set(KEY.current, JSON.stringify(obj.current)); }
if(obj.webIconSelected){ LS.set(KEY.webIconSelected, JSON.stringify(obj.webIconSelected)); }
alert("読み込み完了");
init();
}catch(e){
alert("読み込み失敗: "+(e.message||e));
}
};
r.readAsText(f);
};
input.click();
}
function clearAllCache(){
if(!confirm("Tsumugiのローカルデータを全消去します。よろしいですか?")) return;
Object.keys(localStorage).forEach(k=>{ if(k.startsWith("ts_")) localStorage.removeItem(k); });
alert("全消去しました。再読み込みします。");
location.reload();
}
/* =========================================================
VR試験
========================================================= */
function openVR(){
document.getElementById("vr-overlay").style.display="block";
rebuildVRWall();
}
function closeVR(){
document.getElementById("vr-overlay").style.display="none";
}
function rebuildVRWall(){
const wall = document.getElementById("vr-wall");
if(!wall) return;
wall.innerHTML="";
const arr = filterPosts().slice(0, 10);
const startX = -3.6, startY = 2.2, gapX = 2.5, gapY = 1.35, cols = 3;
arr.forEach((p, idx)=>{
const c = idx % cols;
const r = Math.floor(idx / cols);
const x = startX + c*gapX;
const y = startY - r*gapY;
const z = -4.0;
const title = (p.text||"").slice(0, 46);
const link = safeUrl(p.link);
const line2 = link ? "URL: "+link.slice(0, 40) : "";
const text = `${usernameForPost(p)}\n${title}${line2?("\n"+line2):""}`;
const card = document.createElement("a-entity");
card.setAttribute("position", `${x} ${y} ${z}`);
const plane = document.createElement("a-plane");
plane.setAttribute("width","2.3");
plane.setAttribute("height","1.2");
plane.setAttribute("color","#111827");
plane.setAttribute("opacity","0.88");
plane.setAttribute("material","side:double");
card.appendChild(plane);
const txt = document.createElement("a-text");
txt.setAttribute("value", text);
txt.setAttribute("color","#e5e7eb");
txt.setAttribute("width","2.2");
txt.setAttribute("position","-1.05 0.42 0.01");
txt.setAttribute("baseline","top");
txt.setAttribute("wrap-count","28");
card.appendChild(txt);
if(link){
card.addEventListener("click", ()=>{
closeVR();
openPopup(link);
});
}
wall.appendChild(card);
});
}
/* =========================================================
Events / Init
========================================================= */
function wireEvents(){
document.getElementById("btn-vr").onclick = ()=>openVR();
document.getElementById("btn-clear-all").onclick = ()=>clearAllCache();
document.getElementById("btn-auth-open").onclick = ()=>openAuth();
document.getElementById("btn-logout").onclick = ()=>doLogout();
// Auth overlay
document.getElementById("btn-auth-close").onclick = ()=>closeAuth();
document.getElementById("tab-login").onclick = ()=>setAuthTab("login");
document.getElementById("tab-register").onclick = ()=>setAuthTab("register");
document.getElementById("btn-register").onclick = ()=>doRegister();
document.getElementById("btn-login").onclick = ()=>doLogin();
document.getElementById("btn-guest").onclick = ()=>{
loadUsers();
ensureGuest();
saveSession("guest@local", true);
closeAuth();
afterAuthBootstrap();
};
// VR
document.getElementById("btn-vr-exit").onclick = ()=>closeVR();
document.getElementById("btn-vr-enter").onclick = ()=>{
rebuildVRWall();
alert("VR試験: マウスで視点、WASD移動(記事カードはクリックで記事を開きます)");
};
// profile
document.getElementById("btn-save-profile").onclick = ()=>saveProfile();
document.getElementById("btn-change-profile").onclick = ()=>document.getElementById("file-profile").click();
document.getElementById("file-profile").onchange = (e)=>{
const me=getMe();
if(!me) return alert("ログインしてください");
const f=e.target.files && e.target.files[0]; if(!f) return;
const r=new FileReader();
r.onload=()=>{
me.profile.avatar=String(r.result||"");
saveUsers();
syncProfileUI();
renderTimeline();
rebuildVRWall();
};
r.readAsDataURL(f);
};
// headerbg / favicon / feed icon(ファイル)
document.getElementById("btn-change-headerbg").onclick = ()=>document.getElementById("file-headerbg").click();
document.getElementById("file-headerbg").onchange = (e)=>{
const f=e.target.files && e.target.files[0]; if(!f) return;
const r=new FileReader();
r.onload=()=>applyHeaderBg(String(r.result||""));
r.readAsDataURL(f);
};
document.getElementById("btn-reset-headerbg").onclick = ()=>resetHeaderBg();
document.getElementById("btn-change-appicon").onclick = ()=>document.getElementById("file-appicon").click();
document.getElementById("file-appicon").onchange = (e)=>{
const f=e.target.files && e.target.files[0]; if(!f) return;
const r=new FileReader();
r.onload=()=>applyFavicon(String(r.result||""));
r.readAsDataURL(f);
};
document.getElementById("btn-reset-appicon").onclick = ()=>resetFavicon();
document.getElementById("btn-change-feedicon").onclick = ()=>document.getElementById("file-feedicon").click();
document.getElementById("file-feedicon").onchange = (e)=>{
const f=e.target.files && e.target.files[0]; if(!f) return;
const r=new FileReader();
r.onload=()=>{
icons.feed=String(r.result||icons.feed);
LS.set(KEY.icons, JSON.stringify(icons));
renderTimeline();
rebuildVRWall();
};
r.readAsDataURL(f);
};
// WEBアイコン適用
document.getElementById("btn-apply-favicon").onclick = ()=>applySelectedToFavicon();
document.getElementById("btn-apply-feed").onclick = ()=>applySelectedToFeed();
document.getElementById("btn-apply-profile").onclick = ()=>applySelectedToProfile();
document.getElementById("btn-icon-random").onclick = ()=>randomPickIcon();
// RSS toggle
const toggle = document.getElementById("rss-toggle");
const toggleFlip = ()=>{
const rss = loadRss();
setRssEnabled(!rss.enabled);
rssUI();
};
toggle.onclick = toggleFlip;
toggle.onkeydown = (e)=>{ if(e.key==="Enter" || e.key===" "){ e.preventDefault(); toggleFlip(); } };
// RSS buttons
document.getElementById("btn-add-rss").onclick = ()=>addRssFeed();
document.getElementById("btn-fetch-rss").onclick = ()=>fetchAllFeedsOnce(true);
document.getElementById("btn-set-rss-interval").onclick = ()=>setRssInterval();
document.getElementById("btn-rss-clear-seen").onclick = ()=>clearSeen();
document.getElementById("btn-rss-test-one").onclick = ()=>rssTestOne();
// posting
const postText=document.getElementById("post-text");
postText.addEventListener("input", ()=>{ document.getElementById("char-count").textContent=`(${postText.value.length}/600)`; });
postText.addEventListener("keydown", (e)=>{ if(e.ctrlKey && e.key==="Enter"){ document.getElementById("btn-post").click(); } });
document.getElementById("btn-post").onclick = ()=>{
const t=(postText.value||"").trim();
if(!t && pendingMedia.length===0) return alert("本文か添付が必要です");
const p = createPost({ text:t||"(添付)", type:"user", media: pendingMedia.slice() });
if(!p) return;
postText.value=""; document.getElementById("char-count").textContent="(0/600)";
clearMedia();
};
document.getElementById("btn-delete-all-posts").onclick = ()=>deleteAllPosts();
// media
document.getElementById("btn-add-media").onclick = ()=>document.getElementById("file-media").click();
document.getElementById("file-media").onchange = async (e)=>{
const me=getMe();
if(!me){ alert("ログインしてください"); openAuth(); e.target.value=""; return; }
const files = Array.from(e.target.files||[]);
if(!files.length) return;
for(const f of files){
const dataUrl = await new Promise((res,rej)=>{
const r=new FileReader();
r.onload=()=>res(String(r.result||""));
r.onerror=()=>rej(new Error("file read error"));
r.readAsDataURL(f);
});
pendingMedia.push({ name:f.name, type:f.type||"application/octet-stream", size:f.size||0, dataUrl });
if(pendingMedia.length>9){ pendingMedia=pendingMedia.slice(0,9); break; }
}
refreshMediaPreview();
e.target.value="";
};
document.getElementById("btn-clear-media").onclick = ()=>clearMedia();
// latest post url
document.getElementById("btn-copy-post-url").onclick = async ()=>{
const u=document.getElementById("latest-post-url").textContent.trim();
if(!u || u.startsWith("(")) return alert("まだ投稿URLがありません");
await copyText(u); alert("投稿URLをコピーしました");
};
document.getElementById("btn-open-post-url").onclick = ()=>{
const u=document.getElementById("latest-post-url").textContent.trim();
if(!u || u.startsWith("(")) return alert("まだ投稿URLがありません");
location.href = u;
};
// timeline filter/search
document.querySelectorAll("[data-filter]").forEach(btn=>{
btn.onclick = ()=>{
currentFilter = btn.getAttribute("data-filter") || "all";
document.querySelectorAll("[data-filter]").forEach(b=>{
b.classList.remove("btn-primary"); b.classList.add("pill","hover:bg-slate-50");
});
btn.classList.remove("pill","hover:bg-slate-50");
btn.classList.add("btn-primary");
renderTimeline();
rebuildVRWall();
};
});
document.getElementById("search").addEventListener("input",(e)=>{
searchQuery = e.target.value || "";
renderTimeline();
rebuildVRWall();
});
document.getElementById("btn-refresh").onclick = ()=>{ renderTimeline(); rebuildVRWall(); };
document.getElementById("btn-export").onclick = ()=>exportData();
document.getElementById("btn-import").onclick = ()=>importData();
// home/explore
document.getElementById("btn-home").onclick = ()=>{ homeMode="home"; renderTimeline(); rebuildVRWall(); };
document.getElementById("btn-explore").onclick = ()=>{ homeMode="explore"; renderTimeline(); rebuildVRWall(); };
}
/* ログイン後にUIを再同期する */
function afterAuthBootstrap(){
lockAppForAuth(false);
loadAppearance();
syncProfileUI();
syncStats();
renderTimeline();
loadLatestPostUrl();
rssUI();
rebuildVRWall();
renderWebIconGrid();
const rss = loadRss();
if(rss.enabled){
startRss();
}else{
stopRss(false);
setToggleUI(false);
setRssIndicator(false);
}
}
/* 起動 */
function init(){
buildWebIconPack();
loadSelectedWebIcon();
loadUsers();
loadPosts();
const loggedIn = restoreAuthState();
loadAppearance();
renderWebIconGrid();
syncProfileUI();
syncStats();
renderTimeline();
loadLatestPostUrl();
rssUI();
highlightFromHash(false);
rebuildVRWall();
if(!loggedIn){
lockAppForAuth(true);
openAuth();
setAuthTab("login");
}else{
lockAppForAuth(false);
const rss = loadRss();
if(rss.enabled) startRss();
else{
stopRss(false);
setToggleUI(false);
setRssIndicator(false);
}
}
}
/* boot */
wireEvents();
init();
window.addEventListener("hashchange", ()=>highlightFromHash(true));
</script>
</body>
</html>
カテゴリー: WEB
Sora 3 企画書(たたき台)
Sora 3 企画書(たたき台)
企画名:Sora 3 — AI Movie Studio
コンセプト
「プロンプトを書く」から「作品を監督する」へ。
文章・画像・音声・絵コンテを入力すると、AIが映像制作工程を統合して一本の作品へ仕上げる次世代映像制作プラットフォーム。
ターゲット
個人クリエイター、YouTuber、アニメ・ゲーム制作者、映像制作会社、広告制作、インディー映画制作者。
主要機能
- 長尺動画生成:数秒単位ではなく、シーンをつないで数分〜長編作品を制作
- キャラクター固定:顔・服・体格・声などをシーンをまたいで維持
- AI絵コンテ:文章からカット割りを自動作成
- カメラ演出:パン、ドリー、ズーム、手持ち、ドローンなどを指定
- セリフ・音声・効果音・BGMを映像と同時生成
- 動画の一部分だけ再生成
- キャラクター、背景、アイテムを個別アセットとして保存
- 「監督AI」が脚本から最適な演出を提案
最大の差別化
従来:プロンプト → 動画
Sora 3:脚本 → 絵コンテ → キャラクター → 撮影 → 音声 → 編集 → 完成映像
つまり、**動画生成AIではなく「AI映像制作環境」**として設計する。
利用例
「田舎町を舞台にした90年代風青春アニメ。主人公の少女が夕暮れの駅で幼なじみを待っている」
と入力すると、キャラクターデザイン → 背景 → 絵コンテ → カメラワーク → セリフ → BGM → 映像まで自動生成。
キャッチコピー
「想像した物語を、そのまま映画に。」
HTMLの基礎
HTMLってなに?
- Webページの骨組みを作る言語(見出し・段落・画像・リンクなどの構造)。
- 見た目はCSS、動きはJSが担当。HTMLは“意味と構造”。
まずは雛形(コピペOK)
<!doctype html> <html lang="ja"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>はじめてのHTML</title> <meta name="description" content="このページの説明文" /> </head> <body> <h1>こんにちは!</h1> <p>これは最小構成のHTML5ページです。</p> </body> </html>
よく使う要素(超基本)
- 見出し:
<h1>~<h6>(ページに基本はh1は1つ) - 段落:
<p> - リンク:
<a href="https://example.com">リンク</a> - 画像:
<img src="img.png" alt="画像の説明">(alt必須) - リスト:
<ul><li>…</li></ul>/<ol>…</ol> - 強調:
<strong>(重要) /<em>(強調) - 区切り:
<br>(改行は最小限)、<hr>(区切り線) - まとまり:
<div>(汎用ブロック)、<span>(汎用インライン)
セマンティック要素(構造をわかりやすく)
header(ヘッダー)nav(ナビ)main(主内容は1ページ1つ)section(章)article(単体で完結する記事)aside(補足)footer(フッター)
属性のキホン
id(一意な識別子)/class(グループ化)href(リンク先)/src(画像・スクリプト元)alt(画像代替文)/title(補足ヒント)target="_blank"はrel="noopener noreferrer"とセットで
フォーム最小例
<form action="/search" method="get"> <label for="q">検索:</label> <input id="q" name="q" type="search" required> <button type="submit">送信</button> </form>
テーブル最小例(表)
<table>
<thead><tr><th>商品</th><th>価格</th></tr></thead>
<tbody>
<tr><td>りんご</td><td>120</td></tr>
<tr><td>みかん</td><td>100</td></tr>
</tbody>
</table>
CSS / JS の読み込み
<link rel="stylesheet" href="styles.css"> <script src="app.js" defer></script>
deferはHTML解析後に実行(推奨)。
ちょっとだけ“実践的”なサンプル
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ミニサイト</title>
<style>
body { font-family: system-ui, sans-serif; line-height: 1.7; margin: 0; }
header, footer { padding: 16px; background: #f5f5f5; }
nav a { margin-right: 12px; }
main { max-width: 920px; margin: 24px auto; padding: 0 16px; }
img { max-width: 100%; height: auto; }
.card { border: 1px solid #ddd; border-radius: 8px; padding: 16px; }
</style>
</head>
<body>
<header>
<h1>ミニサイト</h1>
<nav>
<a href="#about">概要</a>
<a href="#gallery">ギャラリー</a>
<a href="#contact">お問い合わせ</a>
</nav>
</header>
<main id="content">
<section id="about">
<h2>概要</h2>
<p>これはHTMLの基本で作ったミニページです。</p>
</section>
<section id="gallery">
<h2>ギャラリー</h2>
<div class="card">
<img src="sample.jpg" alt="サンプル画像">
<p>レスポンシブに画像が縮みます。</p>
</div>
<ul>
<li>箇条書き1</li>
<li>箇条書き2</li>
</ul>
</section>
<section id="contact">
<h2>お問い合わせ</h2>
<form>
<label for="name">お名前</label><br>
<input id="name" name="name" required><br><br>
<label for="msg">メッセージ</label><br>
<textarea id="msg" name="msg" rows="4"></textarea><br><br>
<button type="submit">送信</button>
</form>
</section>
</main>
<footer>
<small>© 2025 MiniSite</small>
</footer>
<script>
// ごく簡単なJS:ナビをクリックしたらスムーズスクロール
document.querySelectorAll('nav a').forEach(a => {
a.addEventListener('click', e => {
const id = a.getAttribute('href');
if (id.startsWith('#')) {
e.preventDefault();
document.querySelector(id)?.scrollIntoView({ behavior: 'smooth' });
}
});
});
</script>
</body>
</html>
初心者がつまずきやすいポイント
- 文字化け→
<meta charset="utf-8">を必ず入れる。 - スマホで拡大縮小が変→
<meta name="viewport" …>を入れる。 - 画像が大きすぎる→CSSで
img { max-width: 100%; height: auto; } - 見出し乱用→
h1はページの主題に1回、階層は順序を守る。 - altなし→スクリーンリーダー/SEO的にマイナス。必ず書く。
もっと深掘り(フォームのバリデーション、SEO、アクセシビリティ、Flex/Gridレイアウト、コンポーネント化など)もまとめられます。どこから強化したい?(例:フォームをしっかり、レイアウトを学ぶ、CSS設計、JS連携 など)
X風サイト.html
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>X-like UI – Polished (Left-aligned)</title>
<style>
/* ===== Design Tokens ===== */
:root{
/* colors */
--bg: #0b0d10;
--surface: #0e1116;
--panel: rgba(16,18,24,.75);
--card: #0f1319;
--line: #1c2230; /* single-pixel separators */
--text: #e7ecf3;
--muted: #9aa7ba;
--accent: #1da1f2;
--accent-2: #7dd3fc;
--accent-3: #60a5fa;
/* radius & shadow */
--r: 14px;
--shadow-sm: 0 1px 0 rgba(255,255,255,.02) inset, 0 8px 24px rgba(0,0,0,.35);
--shadow-card: 0 10px 30px rgba(0,0,0,.25);
/* layout */
--col-left: 84px;
--col-center-min: 360px;
--col-center-max: 720px;
--col-right-min: 280px;
--col-right-max: 420px;
/* NEW: tighter left gutter for center column */
--gutter-x: 10px;
/* motion */
--e1: cubic-bezier(.2,.8,.2,1);
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
background:
radial-gradient(1200px 600px at 10% -10%, rgba(29,161,242,.15), transparent 40%),
radial-gradient(800px 500px at 110% -10%, rgba(96,165,250,.12), transparent 40%),
var(--bg);
color:var(--text);
font:14px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans JP", "Hiragino Kaku Gothic ProN", "Helvetica Neue", Arial;
}
/* focus ring */
:where(a,button,[role="button"],input,textarea,.sb-btn,.ic-btn,.plus,.btn,.btn-ghost,.btn-primary){outline:none}
:where(a,button,[role="button"],input,textarea,.sb-btn,.ic-btn,.plus,.btn,.btn-ghost,.btn-primary):focus-visible{
box-shadow:0 0 0 3px rgba(29,161,242,.35);
border-radius:10px;
}
/* ===== Layout ===== */
.app{
display:grid; gap:0;
grid-template-columns: var(--col-left) minmax(var(--col-center-min),var(--col-center-max)) minmax(var(--col-right-min),var(--col-right-max));
height:100%;
max-width:1280px;
margin:0 auto;
}
.sidebar{
position:sticky; top:0; height:100vh;
backdrop-filter: blur(10px);
background:linear-gradient(180deg, rgba(16,18,24,.7), rgba(16,18,24,.3));
/* NOTE: borders are drawn via ::after to avoid subpixel drift */
padding:10px 8px; display:flex; flex-direction:column; gap:6px;
}
.sb-btn{
border-radius:999px; padding:12px 14px; cursor:pointer;
display:flex; align-items:center; gap:14px; transition:background .2s var(--e1), transform .12s var(--e1);
}
.sb-btn:hover{background:rgba(255,255,255,.04)}
.sb-btn:active{transform:translateY(1px)}
.sb-icon{width:28px;height:28px;display:grid;place-items:center;border-radius:999px}
.sb-btn .label{font-weight:700;letter-spacing:.02em}
.compose-fab{
position:fixed; left:18px; bottom:18px; z-index:50; cursor:pointer;
border:none; color:#fff; font-weight:800; letter-spacing:.02em;
border-radius:999px; padding:12px 18px;
background:linear-gradient(135deg, var(--accent), var(--accent-3));
box-shadow:0 10px 30px rgba(29,161,242,.35);
transition: transform .12s var(--e1), filter .2s var(--e1);
}
.compose-fab:hover{filter:brightness(1.05)}
.compose-fab:active{transform:translateY(1px)}
.you-chip{
position:fixed; left:18px; bottom:84px; width:48px;height:48px;border-radius:999px;
display:grid;place-items:center;font-weight:900; background:conic-gradient(from 180deg at 50% 50%, #0f629e, #0c3c68 70%, #0f629e);
color:#fff; border:1px solid rgba(255,255,255,.08);
}
.main{
position:relative; /* for ::after separator */
min-height:100vh; background:rgba(0,0,0,.25);
backdrop-filter: blur(6px);
}
.rightcol{
padding:14px; position:sticky; top:0; height:100vh; overflow:auto;
}
.searchbar{
display:flex; align-items:center; gap:10px; padding:10px 14px;
background:rgba(255,255,255,.04); border:1px solid rgba(255,255,255,.06);
border-radius:999px;
}
/* ===== Single-pixel separators (no drift) ===== */
.sidebar::after,
.main::after{
content:"";
position:absolute; top:0; bottom:0; width:1px; background:var(--line);
pointer-events:none; z-index:10;
}
.sidebar::after{ right:0; } /* Sidebar ↔ Main */
.main::after{ right:0; } /* Main ↔ Right column */
/* ===== Header Tabs ===== */
.header-title{padding:10px var(--gutter-x); font-size:20px; font-weight:900; border-bottom:1px solid var(--line)}
.tabs{
position:sticky; top:0; z-index:6;
display:flex; gap:24px; padding:0 var(--gutter-x); /* <<< tightened left padding */
background:linear-gradient(180deg, rgba(14,17,22,.8), rgba(14,17,22,.55));
backdrop-filter: saturate(120%) blur(8px);
border-bottom:1px solid var(--line);
}
.tab{
padding:16px 6px; cursor:pointer; color:var(--muted); font-weight:800; border-bottom:3px solid transparent;
transition:color .2s var(--e1), border-color .2s var(--e1);
}
.tab.active{color:var(--text); border-color:var(--accent)}
/* ===== Composer ===== */
.composer{
padding:14px var(--gutter-x); /* <<< tightened */
border-bottom:1px solid var(--line); display:flex; gap:12px;
background:linear-gradient(180deg, rgba(17,22,29,.8), rgba(17,22,29,.4));
}
.avatar{
width:42px; height:42px; border-radius:999px; display:grid; place-items:center; font-weight:900; letter-spacing:.02em;
color:#fff; border:1px solid rgba(255,255,255,.08);
background:radial-gradient(120% 120% at 20% 15%, #1e81c5, #0f2a43 70%);
}
.composer-box{flex:1}
.composer textarea{
width:100%; min-height:76px; resize:vertical; background:transparent; border:none; color:var(--text);
outline:none; font-size:18px; caret-color:var(--accent);
}
.reply-scope{color:var(--accent);font-weight:700;font-size:13px}
.row{display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:6px}
.icons{display:flex; gap:8px}
.ic-btn{width:32px;height:32px;display:grid;place-items:center;border-radius:10px;cursor:pointer;transition:background .2s}
.ic-btn:hover{background:rgba(255,255,255,.06)}
.post-btn{
border:none; color:#0b1220; font-weight:900; letter-spacing:.02em;
padding:9px 18px; border-radius:999px; cursor:not-allowed;
background:linear-gradient(135deg, #6b7280 0%, #9aa7ba 100%);
filter:saturate(.7); opacity:.7; transition:filter .2s, transform .12s;
}
.post-btn.enabled{
cursor:pointer; opacity:1; color:#fff; filter:none;
background:linear-gradient(135deg, var(--accent), var(--accent-3));
box-shadow:0 12px 30px rgba(29,161,242,.3);
}
.post-btn.enabled:hover{filter:brightness(1.05)}
.post-btn.enabled:active{transform:translateY(1px)}
/* ===== Cards ===== */
.card{
border-bottom:1px solid var(--line); display:flex; gap:12px;
padding:14px var(--gutter-x); /* <<< tightened */
background:linear-gradient(180deg, rgba(16,20,27,.5), rgba(16,20,27,.25));
}
.meta{display:flex; gap:6px; color:var(--muted)}
.name{font-weight:900}
.handle{color:var(--muted)}
.hash a{color:var(--accent)}
.post-img{
border-radius:18px; border:1px solid rgba(255,255,255,.06); width:100%; margin-top:10px;
box-shadow:var(--shadow-card)
}
.actions{display:flex; gap:26px; margin-top:8px; color:var(--muted)}
.action{display:flex; gap:6px; align-items:center; cursor:pointer; transition:color .15s}
.action:hover{color:var(--accent)}
/* ===== Right column ===== */
.rightcol .rc-card{
background:linear-gradient(180deg, rgba(17,22,29,.65), rgba(17,22,29,.35));
border:1px solid rgba(255,255,255,.08); border-radius:18px; overflow:hidden; margin-top:12px; box-shadow:var(--shadow-sm)
}
.rc-title{font-weight:900; padding:12px 16px; border-bottom:1px solid rgba(255,255,255,.06)}
.rc-item{padding:12px 16px; border-top:1px solid rgba(255,255,255,.04)}
.chip{background:linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.02)); border:1px solid rgba(255,255,255,.08);
padding:4px 10px; border-radius:999px; color:var(--muted); font-size:12px}
/* ===== Subtabs / Explore ===== */
.subtabs{display:flex; gap:18px; padding:10px var(--gutter-x); border-bottom:1px solid var(--line); background:rgba(255,255,255,.02)} /* <<< tightened */
.subtab{padding:10px 2px; color:var(--muted); font-weight:800; cursor:pointer; border-bottom:3px solid transparent}
.subtab.active{color:var(--text); border-color:var(--accent)}
/* ===== Empties / Profile / Lists ===== */
.empty{display:grid; place-items:center; padding:72px var(--gutter-x); color:var(--muted); text-align:center}
.empty h2{margin:0 0 6px; color:var(--text)}
.cover{height:170px; background:
radial-gradient(70% 120% at 10% 0%, rgba(29,161,242,.25), transparent 40%),
radial-gradient(60% 120% at 100% 0%, rgba(96,165,250,.2), transparent 40%),
linear-gradient(180deg,#121722,#0b0f17)}
.prof-wrap{padding:0 var(--gutter-x) 16px}
.prof-row{display:flex; justify-content:space-between; align-items:end; margin-top:-36px}
.pfp{width:96px;height:96px;border-radius:999px;border:4px solid var(--surface);background:radial-gradient(120% 120% at 20% 15%, #1e81c5, #0f2a43 70%);display:grid;place-items:center;color:#fff;font-weight:900}
.btn{background:transparent; border:1px solid rgba(255,255,255,.18); color:#fff; border-radius:999px; padding:8px 14px; cursor:pointer; transition:background .2s}
.btn:hover{background:rgba(255,255,255,.05)}
.alert{background:rgba(18,42,24,.65); border:1px solid #165c36; color:#a9f2b7; border-radius:14px; padding:12px 14px; margin:12px var(--gutter-x); display:flex; gap:10px; align-items:center}
.check{width:18px;height:18px;border-radius:4px;border:2px solid #a9f2b7;display:grid;place-items:center}
.list-row{display:flex; align-items:center; justify-content:space-between; gap:12px; padding:12px 16px}
.list-pill{width:44px;height:44px;border-radius:12px; background:linear-gradient(135deg,#374151,#1f2937)}
.plus{width:28px;height:28px;border-radius:999px;border:1px solid rgba(255,255,255,.18);display:grid;place-items:center}
/* ===== Grok ===== */
.grok{display:grid; place-items:center; padding:44px var(--gutter-x)}
.grok .logo{font-size:28px; font-weight:1000; display:flex; align-items:center; gap:10px}
.grok .input{margin-top:18px; display:flex; gap:8px; width:min(680px,90vw)}
.grok .input input{flex:1; padding:14px; border-radius:12px; border:1px solid rgba(255,255,255,.18); background:#0c1118; color:#fff; transition:border .2s}
.grok .input input:focus{border-color:rgba(125,211,252,.6)}
.grok .input button{padding:12px 16px; border-radius:12px; border:1px solid rgba(255,255,255,.18); background:linear-gradient(135deg,#10151f,#0e131b); color:#fff}
.grok .tools{display:flex; gap:8px; margin-top:12px}
/* ===== E2EE Page ===== */
.e2ee{padding:56px var(--gutter-x); text-align:center}
.e2ee h1{font-size:26px; margin:0 0 12px}
.bullet{display:flex; gap:10px; align-items:flex-start; justify-content:center; color:#cfd9ea}
.e2ee .cta{display:flex; gap:12px; justify-content:center; margin-top:18px}
.btn-ghost{border:1px solid rgba(255,255,255,.18); background:transparent; color:#fff; padding:10px 14px; border-radius:999px}
.btn-primary{border:1px solid rgba(29,161,242,.55); background:linear-gradient(135deg, var(--accent), var(--accent-2)); color:#001e33; font-weight:900; padding:10px 16px; border-radius:999px}
/* ===== Modal ===== */
.modal-backdrop{
position:fixed; inset:0; background:rgba(4,8,12,.6); display:none; align-items:center; justify-content:center; z-index:100;
animation:fadeIn .2s var(--e1);
}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
.modal{
width:min(640px,92vw); background:linear-gradient(180deg, rgba(14,18,24,.95), rgba(14,18,24,.85));
border:1px solid rgba(255,255,255,.12); border-radius:20px; overflow:hidden; box-shadow:var(--shadow-card)
}
.modal .top{display:flex; align-items:center; justify-content:space-between; padding:10px 14px; border-bottom:1px solid rgba(255,255,255,.06)}
.close-x{width:32px;height:32px;border-radius:999px;display:grid;place-items:center;cursor:pointer;transition:background .2s}
.close-x:hover{background:rgba(255,255,255,.06)}
.small{font-size:12px;color:var(--muted)}
/* ===== Toast ===== */
.toast{
position:fixed; left:50%; transform:translateX(-50%) translateY(20px);
bottom:20px; background:rgba(18,22,28,.92); border:1px solid rgba(255,255,255,.12);
padding:10px 14px; border-radius:999px; display:none; gap:10px; align-items:center; z-index:120;
box-shadow:var(--shadow-card); animation:slideUp .25s var(--e1);
}
.toast .view{background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.12); padding:6px 10px; border-radius:999px}
@keyframes slideUp{from{opacity:0; transform:translateX(-50%) translateY(40px)} to{opacity:1; transform:translateX(-50%) translateY(0)}}
/* ===== Utilities ===== */
.hide{display:none !important}
/* ===== Responsive ===== */
@media (max-width:1100px){
.app{grid-template-columns: 72px 1fr}
.rightcol{display:none}
}
@media (max-width:520px){
.sb-btn .label{display:none}
.sidebar{align-items:center}
}
</style>
</head>
<body>
<div class="app">
<!-- Sidebar -->
<aside class="sidebar" aria-label="Sidebar">
<div class="sb-btn" data-nav="home"><div class="sb-icon">🏠</div><div class="label">Home</div></div>
<div class="sb-btn" data-nav="explore"><div class="sb-icon">🔎</div><div class="label">Explore</div></div>
<div class="sb-btn" data-nav="notifications"><div class="sb-icon">🔔</div><div class="label">Notifications</div></div>
<div class="sb-btn" data-nav="messages"><div class="sb-icon">✉️</div><div class="label">Messages</div></div>
<div class="sb-btn" data-nav="grok"><div class="sb-icon">⚡</div><div class="label">Grok</div></div>
<div class="sb-btn" data-nav="lists"><div class="sb-icon">🗂️</div><div class="label">Lists</div></div>
<div class="sb-btn" data-nav="profile"><div class="sb-icon">👤</div><div class="label">Profile</div></div>
<div class="sb-btn" data-nav="encrypted"><div class="sb-icon">🔒</div><div class="label">Chat</div></div>
</aside>
<!-- Main -->
<main class="main" id="main">
<!-- Home -->
<section id="view-home" role="region" aria-label="Home timeline">
<div class="tabs">
<div class="tab active" data-home-tab="foryou">For you</div>
<div class="tab" data-home-tab="following">Following</div>
</div>
<div class="composer">
<div class="avatar">裕平</div>
<div class="composer-box">
<div class="reply-scope">Everyone can reply</div>
<textarea id="compose-input" placeholder="What’s happening?"></textarea>
<div class="row">
<div class="icons" aria-label="Composer actions">
<div class="ic-btn" title="Media">🖼️</div>
<div class="ic-btn" title="GIF">🌀</div>
<div class="ic-btn" title="Poll">📊</div>
<div class="ic-btn" title="Emoji">😊</div>
<div class="ic-btn" title="Schedule">🗓️</div>
<div class="ic-btn" title="Location">📍</div>
</div>
<button id="post-btn" class="post-btn" disabled>Post</button>
</div>
</div>
</div>
<article class="card">
<div class="avatar">H</div>
<div style="flex:1">
<div class="meta"><span class="name">HANA</span><span class="handle">@HANA__BRAVE · 9h</span></div>
<div>🎂 <span class="hash"><a href="#">#HAPPYJISOODAY</a></span><br/>Happy Birthday JISOO 🤍<br/><span class="hash"><a href="#">#HANA</a> <a href="#">#JISOO</a></span></div>
<img class="post-img" alt="sample" src="https://picsum.photos/seed/jisoo/720/380" />
<div class="actions">
<div class="action">💬 <span>24</span></div>
<div class="action">🔁 <span>10</span></div>
<div class="action">❤️ <span>128</span></div>
<div class="action">↗️</div>
</div>
</div>
</article>
<article class="card">
<div class="avatar">X</div>
<div style="flex:1">
<div class="meta"><span class="name">MTV VMA · LIVE</span><span class="handle"> · now</span></div>
<div class="hash"><a href="#">#VMAs</a></div>
<img class="post-img" alt="vmas" src="https://picsum.photos/seed/vma/720/300" />
<div class="actions">
<div class="action">💬 <span>8</span></div>
<div class="action">🔁 <span>3</span></div>
<div class="action">❤️ <span>42</span></div>
<div class="action">↗️</div>
</div>
</div>
</article>
<div id="feed-anchor"></div>
</section>
<!-- Explore -->
<section id="view-explore" class="hide" role="region" aria-label="Explore">
<div class="header-title">
<div class="searchbar"><span>🔎</span><input style="flex:1;background:transparent;border:none;color:#fff;outline:none" placeholder="Search" /></div>
</div>
<div class="subtabs">
<div class="subtab active">For You</div>
<div class="subtab">Trending</div>
<div class="subtab">News</div>
<div class="subtab">Sports</div>
<div class="subtab">Entertainment</div>
</div>
<article class="card">
<div class="avatar">T</div>
<div style="flex:1">
<div class="meta"><span class="name">Tokyo 2025</span><span class="handle"> · promoted</span></div>
<img class="post-img" alt="tokyo" src="https://picsum.photos/seed/tokyo2025/720/260" />
</div>
</article>
</section>
<!-- Notifications -->
<section id="view-notifications" class="hide" role="region" aria-label="Notifications">
<div class="tabs">
<div class="tab active">All</div>
<div class="tab">Verified</div>
<div class="tab">Mentions</div>
</div>
<div class="empty">
<div>
<h2>Nothing to see here — yet</h2>
<div>From likes to reposts and a whole lot more, this is where all the action happens.</div>
</div>
</div>
</section>
<!-- Messages -->
<section id="view-messages" class="hide" role="region" aria-label="Messages">
<div class="inbox" style="min-height:60vh">
<div class="dm-left" style="border-right:1px solid var(--line)">
<div style="padding:16px">
<h3 style="margin:4px 0">Welcome to your inbox!</h3>
<div class="muted">Drop a line, share posts and more with private conversations between you and others on X.</div>
<div style="height:10px"></div>
<button class="btn">Write a message</button>
</div>
</div>
<div>
<div class="empty">
<div>
<h2>Select a message</h2>
<div class="muted">Choose from your existing conversations, start a new one, or just keep swimming.</div>
<div style="height:10px"></div>
<button class="btn">New message</button>
</div>
</div>
</div>
</div>
</section>
<!-- Lists -->
<section id="view-lists" class="hide" role="region" aria-label="Lists">
<div class="header-title">Lists</div>
<div style="padding:16px">
<div class="muted" style="padding:8px 16px">Discover new Lists</div>
<div class="rc-card" role="list">
<div class="list-row">
<div style="display:flex;gap:12px;align-items:center">
<div class="list-pill"></div>
<div>
<div>J.League · <span class="muted">60 members</span></div>
<div class="muted">2K followers including @sascha348</div>
</div>
</div>
<div class="plus">+</div>
</div>
<div class="list-row">
<div style="display:flex;gap:12px;align-items:center">
<div class="list-pill"></div>
<div>
<div>Official Accounts · <span class="muted">83 members</span></div>
<div class="muted">263 followers including @dencetuno</div>
</div>
</div>
<div class="plus">+</div>
</div>
<div class="list-row">
<div style="display:flex;gap:12px;align-items:center">
<div class="list-pill"></div>
<div>
<div>kitchen · <span class="muted">52 members</span></div>
<div class="muted">181 followers including @Carolina_3254</div>
</div>
</div>
<div class="plus">+</div>
</div>
</div>
<div class="muted" style="padding:22px 16px 8px">Your Lists</div>
<div class="empty" style="opacity:.75"><div>You haven't created or followed any Lists. When you do, they'll show up here.</div></div>
</div>
</section>
<!-- Profile -->
<section id="view-profile" class="hide" role="region" aria-label="Profile">
<div class="cover"></div>
<div class="prof-wrap">
<div class="prof-row">
<div class="pfp">裕平</div>
<button class="btn">Edit profile</button>
</div>
<h2 style="margin:10px 0 0">長留裕平</h2>
<div class="muted">@PingZhang89719 · Joined September 2025</div>
<div style="height:8px"></div>
<div class="muted">0 Following · 0 Followers</div>
</div>
<div class="alert">
<div class="check">✔</div>
<div>
<div class="name" style="font-weight:900">You aren’t verified yet</div>
<div class="muted">Get verified for boosted replies, analytics, ad-free browsing, and more.</div>
</div>
</div>
<article class="card">
<div class="avatar">裕平</div>
<div style="flex:1">
<div class="meta"><span class="name">長留裕平</span><span class="handle"> · 1m</span></div>
<div>はじめました。</div>
</div>
</article>
</section>
<!-- Grok -->
<section id="view-grok" class="hide" role="region" aria-label="Grok">
<div class="grok">
<div class="logo">⚡ <span>Grok</span></div>
<div class="muted">Ask anything</div>
<div class="input">
<input placeholder="Ask anything" />
<button>➤</button>
</div>
<div class="tools">
<button class="btn">Create Images</button>
<button class="btn">Edit Image</button>
</div>
<div class="muted" style="margin-top:16px">Fast ▾ · History</div>
</div>
</section>
<!-- Encrypted Chat -->
<section id="view-encrypted" class="hide" role="region" aria-label="Encrypted Chat">
<div class="e2ee">
<h1>Meet new Chat, now fully encrypted.</h1>
<div class="muted">X Chat are now protected with end-to-end encryption on all your devices.</div>
<div style="height:12px"></div>
<div class="bullet">🔒 <div><b>End-to-End Encryption</b><br/>Your messages are protected across devices.</div></div>
<div class="bullet">🛡️ <div><b>Uncompromising Privacy</b><br/>No one — not even X — can access or read your messages.</div></div>
<div class="cta">
<button class="btn-ghost">Maybe later</button>
<button class="btn-primary">Set up now</button>
</div>
</div>
</section>
</main>
<!-- Right -->
<aside class="rightcol" aria-label="Right column">
<div class="rc-card">
<div class="rc-title">What’s happening</div>
<div class="rc-item"><b>MTV Video Music Awards 2025</b><div class="muted">LIVE</div></div>
<div class="rc-item"><b>東京2025 世界陸上</b><div class="muted">Trending · 8,724 posts</div></div>
<div class="rc-item"><b>JISOO</b><div class="chip">K-POP · Trending</div></div>
</div>
<div class="rc-card">
<div class="rc-title">Who to follow</div>
<div class="rc-item">🅿️ <b>Product Dev</b> · <span class="muted">@buildhub</span> <button class="btn" style="float:right">Follow</button></div>
<div class="rc-item">🧠 <b>AI Lab</b> · <span class="muted">@ailab</span> <button class="btn" style="float:right">Follow</button></div>
</div>
</aside>
</div>
<!-- Floating -->
<button class="compose-fab" id="open-compose">Post</button>
<div class="you-chip">裕平</div>
<!-- Modal -->
<div class="modal-backdrop" id="composer-modal" aria-hidden="true">
<div class="modal" role="dialog" aria-modal="true" aria-label="New post">
<div class="top">
<div class="close-x" id="close-compose">✕</div>
<a class="small" href="#" id="drafts-link">Drafts</a>
</div>
<div class="composer" style="border:none">
<div class="avatar">裕平</div>
<div class="composer-box">
<div class="reply-scope">Everyone can reply</div>
<textarea id="modal-input" placeholder="What’s happening?"></textarea>
<div class="row">
<div class="icons">
<div class="ic-btn">🖼️</div><div class="ic-btn">🌀</div><div class="ic-btn">📊</div><div class="ic-btn">😊</div><div class="ic-btn">🗓️</div><div class="ic-btn">📍</div>
</div>
<button id="modal-post" class="post-btn" disabled>Post</button>
</div>
</div>
</div>
</div>
</div>
<!-- Toast -->
<div class="toast" id="toast">
<div>✅ Your post was sent.</div>
<a class="view" href="#feed-anchor">View</a>
</div>
<script>
/* ---------- Router ---------- */
const views = {
home: document.getElementById('view-home'),
explore: document.getElementById('view-explore'),
notifications: document.getElementById('view-notifications'),
messages: document.getElementById('view-messages'),
lists: document.getElementById('view-lists'),
profile: document.getElementById('view-profile'),
grok: document.getElementById('view-grok'),
encrypted: document.getElementById('view-encrypted'),
};
function show(view){
for(const k in views){ views[k].classList.add('hide'); }
(views[view]||views.home).classList.remove('hide');
window.location.hash = view;
}
document.querySelectorAll('.sb-btn').forEach(btn=>{
btn.addEventListener('click', ()=>show(btn.dataset.nav));
});
window.addEventListener('load', ()=>{
const v = location.hash.replace('#','');
if(v && views[v]) show(v);
});
/* ---------- Composer (inline) ---------- */
const composeInput = document.getElementById('compose-input');
const postBtn = document.getElementById('post-btn');
composeInput.addEventListener('input',()=>{
const on = composeInput.value.trim().length>0;
postBtn.disabled = !on; postBtn.classList.toggle('enabled', on);
});
postBtn.addEventListener('click', ()=>{
addPost(composeInput.value.trim());
composeInput.value=''; postBtn.disabled=true; postBtn.classList.remove('enabled');
showToast();
});
/* ---------- Composer (modal) ---------- */
const modal = document.getElementById('composer-modal');
const openCompose = document.getElementById('open-compose');
const closeCompose = document.getElementById('close-compose');
const modalInput = document.getElementById('modal-input');
const modalPost = document.getElementById('modal-post');
openCompose.addEventListener('click', ()=>{ modal.style.display='flex'; modalInput.focus(); });
closeCompose.addEventListener('click', ()=>{ modal.style.display='none'; });
modalInput.addEventListener('input', ()=>{
const on = modalInput.value.trim().length>0;
modalPost.disabled = !on; modalPost.classList.toggle('enabled', on);
});
modalPost.addEventListener('click', ()=>{
addPost(modalInput.value.trim());
modalInput.value=''; modalPost.disabled=true; modalPost.classList.remove('enabled');
modal.style.display='none'; showToast();
});
modal.addEventListener('click', (e)=>{ if(e.target===modal) modal.style.display='none'; });
/* ---------- Add Post ---------- */
function addPost(text){
if(!text) return;
const card = document.createElement('article');
card.className='card';
card.innerHTML = `
<div class="avatar">裕平</div>
<div style="flex:1">
<div class="meta"><span class="name">長留裕平</span><span class="handle"> · now</span></div>
<div>${escapeHTML(text)}</div>
<div class="actions">
<div class="action">💬 <span>0</span></div>
<div class="action">🔁 <span>0</span></div>
<div class="action">❤️ <span>0</span></div>
<div class="action">↗️</div>
</div>
</div>`;
const anchor = document.getElementById('feed-anchor');
anchor.parentNode.insertBefore(card, anchor);
}
function escapeHTML(s){return s.replaceAll('&','&').replaceAll('<','<').replaceAll('>','>')}
/* ---------- Toast ---------- */
const toast = document.getElementById('toast');
function showToast(){
toast.style.display='flex';
clearTimeout(showToast._t);
showToast._t = setTimeout(()=> toast.style.display='none', 2600);
}
/* ---------- Home tab visual only ---------- */
document.querySelectorAll('[data-home-tab]').forEach(t=>{
t.addEventListener('click', ()=>{
document.querySelectorAll('[data-home-tab]').forEach(x=>x.classList.remove('active'));
t.classList.add('active');
});
});
</script>
</body>
</html>
MailLite — シンプルWebメール
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MailLite — シンプルWebメール</title>
<style>
:root{
--bg:#f6f7fb;
--panel:#ffffff;
--text:#1f2937;
--muted:#6b7280;
--primary:#4f46e5;
--primary-weak:#eef2ff;
--border:#e5e7eb;
--danger:#ef4444;
--success:#10b981;
--warning:#f59e0b;
}
.dark{
--bg:#0b0e15;
--panel:#0f1623;
--text:#e5e7eb;
--muted:#9ca3af;
--primary:#8b5cf6;
--primary-weak:#221a36;
--border:#1f2937;
--danger:#f87171;
--success:#34d399;
--warning:#fbbf24;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;background:var(--bg);color:var(--text);
font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,"Noto Sans JP",sans-serif;
}
.app{
display:grid;grid-template-rows:56px 1fr;height:100%;
}
/* Topbar */
.topbar{
display:flex;align-items:center;gap:12px;
padding:0 16px;border-bottom:1px solid var(--border);background:var(--panel);
position:sticky;top:0;z-index:5;
}
.logo{
display:flex;align-items:center;gap:10px;font-weight:700;
letter-spacing:.2px;
}
.badge{font-size:10px;padding:2px 6px;border-radius:999px;background:var(--primary-weak);color:var(--primary)}
.search{
margin-left:auto;display:flex;align-items:center;gap:8px;background:var(--bg);
padding:6px 10px;border-radius:10px;border:1px solid var(--border);min-width:220px;max-width:460px;flex:1;
}
.search input{border:none;background:transparent;outline:none;color:var(--text);width:100%}
.icon{width:18px;height:18px;display:inline-block;flex:0 0 18px}
.btn{
display:inline-flex;align-items:center;gap:8px;padding:8px 12px;border-radius:10px;
border:1px solid var(--border);background:var(--panel);cursor:pointer;color:var(--text);
}
.btn.primary{background:var(--primary);border-color:var(--primary);color:#fff}
.btn.ghost{background:transparent}
.btn:disabled{opacity:.6;cursor:not-allowed}
/* Layout */
.layout{
display:grid;grid-template-columns:260px 360px 1fr;gap:12px;padding:12px;height:calc(100vh - 56px);
}
.panel{background:var(--panel);border:1px solid var(--border);border-radius:14px;overflow:hidden;display:flex;flex-direction:column;min-height:0}
.sidebar{padding:12px}
.compose-block{padding:12px}
.compose-button{width:100%;justify-content:center}
.nav-group{margin-top:8px}
.nav-item{
display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:10px;color:var(--text);text-decoration:none;cursor:pointer;
}
.nav-item:hover{background:var(--primary-weak)}
.nav-item.active{background:var(--primary);color:#fff}
.nav-item .count{margin-left:auto;opacity:.8}
/* List */
.list-toolbar{display:flex;align-items:center;gap:8px;padding:8px;border-bottom:1px solid var(--border)}
.list{overflow:auto}
.msg{
display:grid;grid-template-columns:24px 1fr auto;gap:10px;padding:12px;border-bottom:1px solid var(--border);cursor:pointer;
}
.msg:hover{background:var(--primary-weak)}
.msg.unread{background:linear-gradient(0deg,transparent,transparent), var(--panel);font-weight:600}
.msg .from{color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.msg .subject{color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.msg .snippet{color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.msg .meta{display:flex;flex-direction:column;align-items:end;gap:4px;color:var(--muted)}
.chip{display:inline-flex;align-items:center;gap:6px;padding:2px 8px;border-radius:999px;border:1px solid var(--border);font-size:11px}
.star{cursor:pointer;opacity:.7}
.star.active{opacity:1}
.avatar{
width:24px;height:24px;border-radius:50%;background:linear-gradient(135deg,var(--primary),#22c1c3);
display:grid;place-items:center;color:#fff;font-size:12px;font-weight:700;
}
/* Reader */
.reader-head{
padding:12px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;flex-wrap:wrap;
}
.reader-title{font-size:18px;font-weight:700}
.reader-meta{color:var(--muted);font-size:12px}
.reader-actions{margin-left:auto;display:flex;gap:8px}
.reader-body{padding:16px;overflow:auto}
.empty{display:grid;place-items:center;height:100%;color:var(--muted)}
/* Modal */
.modal{
position:fixed;inset:0;background:rgba(0,0,0,.4);display:none;align-items:center;justify-content:center;z-index:20;
}
.modal.open{display:flex}
.modal-card{
width:min(920px,94vw);max-height:88vh;overflow:auto;background:var(--panel);border:1px solid var(--border);
border-radius:16px;
}
.modal-head{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border)}
.modal-body{padding:16px;display:grid;gap:10px}
.field{display:grid;gap:6px}
.input, textarea{
width:100%;padding:10px 12px;border-radius:10px;border:1px solid var(--border);
background:transparent;color:var(--text);outline:none;
}
textarea{min-height:220px;resize:vertical}
.row{display:flex;gap:10px;flex-wrap:wrap}
.grow{flex:1}
/* Responsive */
@media (max-width: 1100px){
.layout{grid-template-columns:220px 1fr}
.reader{display:none}
.layout.show-reader .list{display:none}
.layout.show-reader .reader{display:flex}
}
@media (max-width: 640px){
.layout{grid-template-columns:1fr}
.sidebar{display:none}
}
</style>
</head>
<body>
<div class="app">
<!-- Topbar -->
<div class="topbar">
<div class="logo">
<svg class="icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M3 7.5 12 13l9-5.5v9A2.5 2.5 0 0 1 18.5 19h-13A2.5 2.5 0 0 1 3 16.5v-9Z" stroke="currentColor" stroke-width="1.5"/>
<path d="m3 7.5 9-5 9 5" stroke="currentColor" stroke-width="1.5"/>
</svg>
MailLite <span class="badge">beta</span>
</div>
<div class="search">
<svg class="icon" viewBox="0 0 24 24" fill="none"><path d="m21 21-4.2-4.2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="1.5"/></svg>
<input id="search" placeholder="メールを検索(差出人・件名・本文・ラベル)" />
<button class="btn ghost" id="clearSearch" title="検索クリア">クリア</button>
</div>
<button class="btn" id="toggleDark" title="ダークモード">
<svg class="icon" viewBox="0 0 24 24" fill="none"><path d="M12 3a9 9 0 1 0 9 9 7 7 0 0 1-9-9Z" stroke="currentColor" stroke-width="1.5"/></svg>
主题
</button>
<button class="btn primary" id="composeBtn">
<svg class="icon" viewBox="0 0 24 24" fill="none"><path d="M4 20h16M6 14l9.5-9.5a2.1 2.1 0 1 1 3 3L9 17l-5 1 2-4Z" stroke="#fff" stroke-width="1.5" stroke-linejoin="round"/></svg>
新規作成
</button>
</div>
<!-- Main layout -->
<div class="layout" id="layout">
<!-- Sidebar -->
<aside class="panel sidebar">
<div class="compose-block">
<button class="btn primary compose-button" id="composeBtn2">
<svg class="icon" viewBox="0 0 24 24" fill="none"><path d="M4 20h16M6 14l9.5-9.5a2.1 2.1 0 1 1 3 3L9 17l-5 1 2-4Z" stroke="#fff" stroke-width="1.5" stroke-linejoin="round"/></svg>
新規メール
</button>
</div>
<nav class="nav-group" id="folders"></nav>
</aside>
<!-- List -->
<section class="panel">
<div class="list-toolbar">
<button class="btn" id="markReadBtn" title="既読にする">既読</button>
<button class="btn" id="markUnreadBtn" title="未読にする">未読</button>
<button class="btn" id="archiveBtn" title="アーカイブ">アーカイブ</button>
<button class="btn" id="deleteBtn" title="削除">削除</button>
<div style="margin-left:auto;display:flex;align-items:center;gap:6px;">
<span class="chip"><span class="dot" style="width:8px;height:8px;border-radius:50%;background:var(--primary)"></span> ラベル</span>
</div>
</div>
<div class="list" id="list"></div>
</section>
<!-- Reader -->
<section class="panel reader" id="reader">
<div class="empty" id="emptyState">メールを選択してください</div>
<div style="display:none;flex-direction:column;height:100%;" id="readerWrap">
<div class="reader-head">
<div style="display:flex;align-items:center;gap:10px;min-width:0">
<div class="avatar" id="readerAvatar">Y</div>
<div style="min-width:0">
<div class="reader-title" id="readerSubject">件名</div>
<div class="reader-meta" id="readerMeta">From – To ・ 日付</div>
</div>
</div>
<div class="reader-actions">
<button class="btn" id="replyBtn" title="返信">返信</button>
<button class="btn" id="starBtn" title="スター">
<span id="starIcon">☆</span> スター
</button>
<button class="btn" id="archBtn" title="アーカイブ">アーカイブ</button>
<button class="btn" id="trashBtn" title="削除" style="color:var(--danger)">削除</button>
</div>
</div>
<div class="reader-body">
<div id="readerBody"></div>
</div>
</div>
</section>
</div>
</div>
<!-- Compose Modal -->
<div class="modal" id="composeModal" aria-hidden="true">
<div class="modal-card">
<div class="modal-head">
<strong>新規メッセージ</strong>
<div class="row">
<button class="btn" id="saveDraftBtn">下書き保存</button>
<button class="btn primary" id="sendBtn">送信</button>
<button class="btn" id="closeModalBtn">閉じる</button>
</div>
</div>
<div class="modal-body">
<div class="row">
<div class="field grow">
<label for="to">宛先(カンマ区切り)</label>
<input class="input" id="to" placeholder="example@example.com, someone@domain.jp" />
</div>
<div class="field" style="min-width:160px;">
<label for="label">ラベル</label>
<input class="input" id="label" placeholder="work, personal など" />
</div>
</div>
<div class="field">
<label for="subject">件名</label>
<input class="input" id="subject" placeholder="件名を入力" />
</div>
<div class="field">
<label for="body">本文</label>
<textarea id="body" placeholder="本文を入力"></textarea>
</div>
</div>
</div>
</div>
<script>
/** ======= Simple Mail App (no backend) ======= */
const DB_KEY = "maillite-db-v1";
const QS = sel => document.querySelector(sel);
const QSA = sel => [...document.querySelectorAll(sel)];
const state = {
currentFolder: "inbox",
query: "",
selectedIds: new Set(),
currentId: null,
db: { messages: [] }
};
const FOLDERS = [
{id:"inbox", name:"受信箱", icon:"📥"},
{id:"starred", name:"スター", icon:"⭐"},
{id:"sent", name:"送信済み", icon:"📤"},
{id:"drafts", name:"下書き", icon:"📝"},
{id:"archive", name:"アーカイブ",icon:"🗄️"},
{id:"spam", name:"迷惑", icon:"🚫"},
{id:"trash", name:"ゴミ箱", icon:"🗑️"},
];
function initDB(){
const saved = localStorage.getItem(DB_KEY);
if(saved){
state.db = JSON.parse(saved);
return;
}
// Seed sample messages
const now = Date.now();
const demo = [
mkMsg("suzuki@example.com","ようこそ MailLite へ","MailLite をお試しいただきありがとうございます!\n\nこのメールはデモです。",["welcome"], now-3600_000),
mkMsg("shop@ec.example.com","【お知らせ】サマーセール開催!","最大 50%OFF。今すぐチェック!",["promo"], now-7200_000),
mkMsg("boss@company.jp","明日の打合せ議題","・リリース計画\n・障害対応\n・コスト見直し",["work"], now-86400_000, true),
mkMsg("friend@chat.jp","週末の予定どう?","映画かカラオケ行かない?",["personal"], now-5400_000),
mkMsg("security@service.jp","ログイン通知","新しい端末からログインがありました。",["security"], now-9600_000),
];
demo[2].unread = false;
state.db.messages = demo;
persist();
}
function mkMsg(from, subject, body, labels=[], time=Date.now(), starred=false){
const id = crypto.randomUUID();
const initials = (from.split("@")[0][0]||"U").toUpperCase();
return {
id, box:"inbox", from, to:[], subject, body, labels, starred, unread:true,
date: time, initials
};
}
function persist(){ localStorage.setItem(DB_KEY, JSON.stringify(state.db)); }
function formatDate(ts){
const d = new Date(ts);
const pad = n => String(n).padStart(2,"0");
return `${d.getFullYear()}/${pad(d.getMonth()+1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
// Render folders
function renderFolders(){
const el = QS("#folders");
el.innerHTML = "";
for(const f of FOLDERS){
const count = countFolder(f.id);
const a = document.createElement("a");
a.className = "nav-item" + (state.currentFolder===f.id?" active":"");
a.dataset.id = f.id;
a.innerHTML = `<span>${f.icon}</span><span>${f.name}</span><span class="count">${count||""}</span>`;
a.addEventListener("click", ()=>{
state.currentFolder = f.id;
state.selectedIds.clear();
state.currentId = null;
render();
});
el.appendChild(a);
}
}
function countFolder(folder){
return filterByFolder(state.db.messages, folder).length;
}
function filterByFolder(list, folder){
switch(folder){
case "inbox": return list.filter(m=>m.box==="inbox");
case "starred": return list.filter(m=>m.starred && m.box!=="trash");
case "sent": return list.filter(m=>m.box==="sent");
case "drafts": return list.filter(m=>m.box==="drafts");
case "archive": return list.filter(m=>m.box==="archive");
case "spam": return list.filter(m=>m.box==="spam");
case "trash": return list.filter(m=>m.box==="trash");
default: return list;
}
}
// Search
function matchesQuery(m,q){
if(!q) return true;
const s = q.toLowerCase();
const hay = [
m.from, (m.to||[]).join(","), m.subject, m.body, (m.labels||[]).join(",")
].join("\n").toLowerCase();
return hay.includes(s);
}
// Render list
function renderList(){
const wrap = QS("#list");
const items = filterByFolder(state.db.messages, state.currentFolder)
.filter(m=>matchesQuery(m, state.query))
.sort((a,b)=>b.date-a.date);
wrap.innerHTML = "";
if(items.length===0){
wrap.innerHTML = `<div class="empty" style="height:100%;">${state.query? "検索結果がありません":"このフォルダは空です"}</div>`;
return;
}
for(const m of items){
const row = document.createElement("div");
row.className = "msg" + (m.unread?" unread":"");
row.dataset.id = m.id;
const starClass = m.starred? "active":"";
row.innerHTML = `
<div class="avatar" title="${m.from}">${m.initials}</div>
<div style="min-width:0">
<div class="from">${m.from}</div>
<div class="subject">${m.subject}</div>
<div class="snippet">${(m.labels?.length? m.labels.map(l=>"#"+l).join(" ")+" · ":"")}${m.body.replace(/\n/g," ").slice(0,120)}</div>
</div>
<div class="meta">
<div>${formatDate(m.date)}</div>
<div class="star ${starClass}" data-star-id="${m.id}" title="スター">${m.starred?"★":"☆"}</div>
</div>
`;
row.addEventListener("click", (e)=>{
// If star clicked, don't open
if(e.target && e.target.dataset.starId){ return; }
openMessage(m.id);
});
row.querySelector(".star").addEventListener("click",(e)=>{
e.stopPropagation();
toggleStar(m.id);
});
wrap.appendChild(row);
}
}
// Reader
function openMessage(id){
const m = state.db.messages.find(x=>x.id===id);
if(!m) return;
state.currentId = id;
m.unread = false;
persist();
QS("#emptyState").style.display = "none";
QS("#readerWrap").style.display = "flex";
QS("#readerSubject").textContent = m.subject || "(件名なし)";
QS("#readerMeta").textContent = `From: ${m.from} / To: ${(m.to||[]).join(", ")||"(なし)"} ・ ${formatDate(m.date)}`;
QS("#readerBody").innerHTML = safeHtml(m.body).replace(/\n/g,"<br>");
QS("#readerAvatar").textContent = m.initials || "U";
QS("#starIcon").textContent = m.starred ? "★" : "☆";
// Mobile: show reader
QS("#layout").classList.add("show-reader");
render();
}
function safeHtml(s=""){
return s.replace(/[&<>"']/g, ch => ({
"&":"&","<":"<",">":">",'"':""","'":"'"
}[ch]));
}
// Actions
function getSelectedOrCurrentIds(){
if(state.selectedIds.size>0) return [...state.selectedIds];
if(state.currentId) return [state.currentId];
return [];
}
function markRead(read=true){
for(const id of getSelectedOrCurrentIds()){
const m = state.db.messages.find(x=>x.id===id);
if(m) m.unread = !read;
}
persist(); render();
}
function moveTo(box){
for(const id of getSelectedOrCurrentIds()){
const m = state.db.messages.find(x=>x.id===id);
if(m) m.box = box;
}
// If current message was moved out of view, clear reader
if(state.currentId){
const cm = state.db.messages.find(x=>x.id===state.currentId);
const visible = filterByFolder([cm], state.currentFolder).length>0;
if(!visible){ state.currentId = null; QS("#readerWrap").style.display="none"; QS("#emptyState").style.display="grid"; }
}
persist(); render();
}
function toggleStar(id){
const m = state.db.messages.find(x=>x.id===id);
if(m){ m.starred = !m.starred; persist(); render(); if(state.currentId===id) QS("#starIcon").textContent=m.starred?"★":"☆"; }
}
// Compose
function openCompose(prefill={}){
QS("#composeModal").classList.add("open");
QS("#to").value = prefill.to?.join(", ") || "";
QS("#subject").value = prefill.subject || "";
QS("#body").value = prefill.body || "";
QS("#label").value = (prefill.labels||[]).join(", ");
}
function closeCompose(){ QS("#composeModal").classList.remove("open"); }
function saveDraft(){
const draft = collectForm();
draft.box = "drafts";
draft.unread = false;
draft.from = "me@local";
draft.id = crypto.randomUUID();
state.db.messages.push(draft);
persist(); closeCompose(); render();
alert("下書きを保存しました。");
}
function sendMail(){
const msg = collectForm();
if(!msg.to.length){ alert("宛先を入力してください"); return; }
msg.box = "sent";
msg.unread = false;
msg.from = "me@local";
msg.id = crypto.randomUUID();
state.db.messages.push(msg);
persist(); closeCompose(); render();
alert("送信しました(デモ動作:実送信なし)");
}
function collectForm(){
const to = QS("#to").value.split(",").map(s=>s.trim()).filter(Boolean);
const subject = QS("#subject").value.trim() || "(件名なし)";
const body = QS("#body").value;
const labels = QS("#label").value.split(",").map(s=>s.trim()).filter(Boolean);
return { to, subject, body, labels, date: Date.now(), starred:false, unread:true, initials:"M" };
}
// Selection (click+Ctrl/Shift optional simplified)
QS("#list").addEventListener("click",(e)=>{
const row = e.target.closest(".msg");
if(!row) return;
if(e.ctrlKey || e.metaKey){
const id = row.dataset.id;
if(state.selectedIds.has(id)) state.selectedIds.delete(id); else state.selectedIds.add(id);
row.classList.toggle("selected");
}
});
// Topbar controls
QS("#composeBtn").onclick = ()=>openCompose();
QS("#composeBtn2").onclick = ()=>openCompose();
QS("#closeModalBtn").onclick = closeCompose;
QS("#saveDraftBtn").onclick = saveDraft;
QS("#sendBtn").onclick = sendMail;
QS("#search").addEventListener("input",(e)=>{ state.query = e.target.value.trim(); renderList(); });
QS("#clearSearch").onclick = ()=>{ QS("#search").value=""; state.query=""; renderList(); };
QS("#toggleDark").onclick = ()=>{
document.body.classList.toggle("dark");
localStorage.setItem("maillite-theme", document.body.classList.contains("dark")? "dark":"light");
};
// List toolbar actions
QS("#markReadBtn").onclick = ()=>markRead(true);
QS("#markUnreadBtn").onclick = ()=>markRead(false);
QS("#archiveBtn").onclick = ()=>moveTo("archive");
QS("#deleteBtn").onclick = ()=>moveTo("trash");
// Reader actions
QS("#replyBtn").onclick = ()=>{
const m = state.db.messages.find(x=>x.id===state.currentId);
if(!m) return;
openCompose({ to:[m.from], subject:"Re: "+m.subject, body:`\n\n--- ${m.from} さんのメッセージ ---\n${m.body}`, labels:["reply"] });
};
QS("#starBtn").onclick = ()=>{ if(state.currentId) toggleStar(state.currentId); };
QS("#archBtn").onclick = ()=>moveTo("archive");
QS("#trashBtn").onclick = ()=>moveTo("trash");
// Clicking outside modal to close
QS("#composeModal").addEventListener("click",(e)=>{ if(e.target===QS("#composeModal")) closeCompose(); });
// Mobile back from reader by clicking empty area (or press Escape)
document.addEventListener("keydown",(e)=>{
if(e.key==="Escape"){
if(QS("#composeModal").classList.contains("open")) closeCompose();
else QS("#layout").classList.remove("show-reader");
}
});
// Initial render
function render(){
renderFolders();
renderList();
}
(function boot(){
initDB();
render();
// Theme
if(localStorage.getItem("maillite-theme")==="dark"){ document.body.classList.add("dark"); }
})();
</script>
</body>
</html>
ミニ百科.html
<!DOCTYPE html>
<html lang="ja" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ミニ百科 – シングルファイル版</title>
<meta name="description" content="検索・カテゴリ・タグ・ブックマーク対応のシングルファイル百科事典。" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" />
<!-- TailwindCSS (CDN) -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome (icons) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" integrity="sha512-5I0VnK5tQhJ0eZ5Ck1gC3b6h9fJ3k6l9FeI3K6J0q9JtO1Yw1l2Y7N5M6d2xQf8Q2F6mZ8l2s3A=" crossorigin="anonymous" referrerpolicy="no-referrer" />
<!-- Favicon (inline SVG) -->
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Cpath fill='%234f46e5' d='M32 56c0-13.3 10.7-24 24-24h144c13.3 0 24 10.7 24 24v144c0 13.3-10.7 24-24 24H56c-13.3 0-24-10.7-24-24z'/%3E%3Cpath fill='white' d='M72 80h112v16H72zM72 112h80v16H72zM72 144h112v16H72zM72 176h96v16H72z'/%3E%3C/svg%3E" />
<style>
/* 追加の細かなスタイル */
.line-clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.prose h2{scroll-margin-top:6rem}
.toc a{display:block;padding:.25rem .5rem;border-radius:.5rem}
.toc a.active{background:rgba(99,102,241,.12)}
</style>
<script>
// ダークモード初期化
(function(){
const theme=localStorage.getItem('theme');
if(theme==='dark'||(!theme&&window.matchMedia('(prefers-color-scheme: dark)').matches)){
document.documentElement.classList.add('dark');
}
})();
</script>
</head>
<body class="bg-slate-50 text-slate-800 dark:bg-slate-900 dark:text-slate-100 min-h-screen">
<!-- Skip link -->
<a href="#main" class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:bg-indigo-600 focus:text-white focus:px-3 focus:py-2 focus:rounded">本文へスキップ</a>
<!-- Header -->
<header class="sticky top-0 z-40 backdrop-blur border-b border-slate-200/60 dark:border-slate-700/60 bg-white/70 dark:bg-slate-900/70">
<div class="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8 py-3 flex items-center gap-3">
<button id="btnHome" class="shrink-0 px-2 py-1 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/40" title="ホーム">
<i class="fa-solid fa-book-open text-indigo-600"></i>
</button>
<h1 class="text-lg sm:text-2xl font-bold tracking-tight">ミニ百科 <span class="text-indigo-600">Mini Encyclopedia</span></h1>
<div class="ms-auto flex items-center gap-2">
<button id="btnRandom" class="px-3 py-2 rounded-xl bg-indigo-600 text-white text-sm hover:opacity-90"><i class="fa-solid fa-shuffle me-1"></i>ランダム</button>
<button id="btnBookmarks" class="px-3 py-2 rounded-xl bg-amber-500 text-white text-sm hover:opacity-90"><i class="fa-solid fa-star me-1"></i>ブックマーク</button>
<button id="btnDark" class="px-3 py-2 rounded-xl bg-slate-800 text-white text-sm dark:bg-slate-700 hover:opacity-90" title="ダーク/ライト切替"><i class="fa-solid fa-moon"></i></button>
</div>
</div>
</header>
<!-- Toolbar -->
<section class="border-b border-slate-200/60 dark:border-slate-700/60 bg-white/60 dark:bg-slate-900/60">
<div class="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8 py-4 grid gap-3 sm:grid-cols-12 items-end">
<div class="sm:col-span-6">
<label for="search" class="block text-sm text-slate-600 dark:text-slate-300 mb-1">記事検索</label>
<div class="relative">
<input id="search" type="search" placeholder="キーワード(例: 富士山 / 恐竜 / インターネット)" class="w-full rounded-2xl border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800/80 px-4 py-2 pe-10 outline-none focus:ring-2 focus:ring-indigo-500" />
<i class="fa-solid fa-magnifying-glass absolute right-3 top-1/2 -translate-y-1/2 text-slate-400"></i>
</div>
</div>
<div class="sm:col-span-3">
<label for="category" class="block text-sm text-slate-600 dark:text-slate-300 mb-1">カテゴリ</label>
<select id="category" class="w-full rounded-2xl border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800/80 px-4 py-2 outline-none focus:ring-2 focus:ring-indigo-500">
<option value="">すべて</option>
</select>
</div>
<div class="sm:col-span-3">
<label for="sort" class="block text-sm text-slate-600 dark:text-slate-300 mb-1">並び替え</label>
<select id="sort" class="w-full rounded-2xl border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800/80 px-4 py-2 outline-none focus:ring-2 focus:ring-indigo-500">
<option value="recent">更新が新しい順</option>
<option value="title">タイトル順</option>
</select>
</div>
<div class="sm:col-span-12" id="tagBar" aria-label="タグフィルタ" class="flex flex-wrap gap-2"></div>
</div>
</section>
<main id="main" class="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8 py-6">
<!-- Home / List View -->
<section id="view-home" class="grid gap-6">
<div class="flex items-center justify-between">
<h2 class="text-xl sm:text-2xl font-semibold">記事一覧</h2>
<div class="text-sm text-slate-500"><span id="resultCount">0</span> 件</div>
</div>
<div id="cards" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<!-- cards injected -->
</div>
<div class="flex items-center justify-center gap-2 pt-2" id="pager"></div>
</section>
<!-- Article View -->
<section id="view-article" class="hidden lg:grid lg:grid-cols-12 gap-8">
<aside class="lg:col-span-3 order-last lg:order-first">
<div class="sticky top-[6.5rem] border border-slate-200 dark:border-slate-700 rounded-2xl p-4">
<h3 class="font-semibold mb-2">目次</h3>
<nav id="toc" class="toc text-sm space-y-1"></nav>
</div>
</aside>
<article class="lg:col-span-9">
<nav class="text-sm text-slate-500 mb-3" id="breadcrumb"></nav>
<header class="mb-4">
<h1 id="articleTitle" class="text-2xl sm:text-3xl font-bold tracking-tight"></h1>
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm text-slate-500" id="articleMeta"></div>
<div class="mt-3 flex items-center gap-2">
<button id="btnCopyLink" class="px-3 py-2 rounded-xl border border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-800"><i class="fa-solid fa-link me-1"></i>リンクをコピー</button>
<button id="btnToggleBookmark" class="px-3 py-2 rounded-xl border border-amber-400 text-amber-600 hover:bg-amber-50"><i class="fa-regular fa-star me-1"></i>ブックマーク</button>
</div>
</header>
<div id="articleContent" class="prose prose-slate dark:prose-invert max-w-none"></div>
<section class="mt-8">
<h3 class="font-semibold mb-2">関連タグ</h3>
<div id="articleTags" class="flex flex-wrap gap-2"></div>
</section>
</article>
</section>
</main>
<footer class="border-t border-slate-200 dark:border-slate-700 py-8">
<div class="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8 text-sm text-slate-500 flex flex-wrap items-center gap-2">
<span>© <span id="year"></span> ミニ百科</span>
<span class="mx-1">•</span>
<button id="btnExport" class="underline underline-offset-4">データを書き出す(JSON)</button>
<span class="mx-1">•</span>
<label class="cursor-pointer">JSON読み込み <input id="fileImport" type="file" accept="application/json" class="hidden" /></label>
</div>
</footer>
<!-- Structured data placeholder (updated on article view) -->
<script id="ldjson" type="application/ld+json">{}</script>
<script>
// ======================
// サンプル記事データ
// ======================
/**
* 記事スキーマ:
* id, slug, title, category, tags[], summary, updated(ISO), author, content(HTML)
*/
const ARTICLES = [
{
id: 1,
slug: 'fuji-san',
title: '富士山',
category: '地理',
tags: ['日本','山','世界文化遺産'],
summary: '日本の象徴ともいわれる成層火山。標高3,776mで日本最高峰。',
updated: '2025-08-15',
author: 'ミニ百科編集部',
content: `
<p>富士山は本州中部に位置する<span>成層火山</span>で、標高は3,776m。2013年に世界文化遺産に登録されました。古来より信仰の対象であり、芸術や文学にも多く登場します。</p>
<h2 id="geo">地形と地質</h2>
<p>富士山は何度もの噴火活動を経て現在の美しい円錐形を形成しました。火口は山頂部にあり、外輪としてお鉢巡りが知られています。</p>
<h2 id="climb">登山と保全</h2>
<p>一般的な登山シーズンは夏。登山道の混雑やゴミ問題、低温・高山病などのリスク対策が重要です。</p>
<h2 id="culture">文化的意義</h2>
<p>葛飾北斎の『富嶽三十六景』をはじめ、絵画や和歌に頻繁に詠まれ、日本の象徴として国際的にも広く知られています。</p>
`
},
{
id: 2,
slug: 'internet-basics',
title: 'インターネットの基礎',
category: 'テクノロジー',
tags: ['ネットワーク','Web','通信'],
summary: '世界中のコンピュータを相互接続する情報ネットワークの総称。',
updated: '2025-07-01',
author: 'ミニ百科編集部',
content: `
<p>インターネットは標準化された<span>TCP/IP</span>により機器同士が通信する巨大なネットワークです。Web、メール、動画配信など多様なサービスの土台になっています。</p>
<h2 id="protocols">主要プロトコル</h2>
<p>HTTP/HTTPS、DNS、SMTP、FTPなどが代表的。セキュリティ確保には暗号化や認証が重要です。</p>
<h2 id="web">Webの仕組み</h2>
<p>ブラウザがURLを解決し、サーバからHTML/CSS/JS等のリソースを取得・表示します。</p>
<h2 id="safety">安全な利用</h2>
<p>二要素認証、ソフトウェア更新、フィッシング対策、強力なパスワード管理が基本です。</p>
`
},
{
id: 3,
slug: 'dinosaurs',
title: '恐竜',
category: '生物',
tags: ['古生物学','白亜紀','化石'],
summary: '中生代に栄えた爬虫類のグループ。鳥類は恐竜の系統に含まれると考えられている。',
updated: '2025-05-28',
author: 'ミニ百科編集部',
content: `
<p>恐竜は約2億3000万年前に出現し、中生代に多様化しました。<span>鳥類</span>は恐竜の一系統とみなされます。</p>
<h2 id="era">時代区分</h2>
<p>三畳紀・ジュラ紀・白亜紀に区分され、各時代で特徴的な種が繁栄しました。</p>
<h2 id="extinction">大量絶滅</h2>
<p>約6600万年前の大量絶滅で多くが消滅。隕石衝突や火山活動などが要因と考えられています。</p>
`
},
{
id: 4,
slug: 'ww2-overview',
title: '第二次世界大戦(概説)',
category: '歴史',
tags: ['20世紀','戦争','国際関係'],
summary: '1939年から1945年にかけて行われた世界規模の戦争。',
updated: '2025-03-10',
author: 'ミニ百科編集部',
content: `
<p>第二次世界大戦は多数の国が参戦した世界規模の戦争で、政治・経済・科学技術・社会に長期の影響を与えました。</p>
<h2 id="fronts">主要戦線</h2>
<p>ヨーロッパ、太平洋、北アフリカ、東部戦線など多くの戦域に分かれました。</p>
<h2 id="aftermath">戦後の世界</h2>
<p>国際連合の設立、冷戦構造の形成、国際秩序の再編などにつながりました。</p>
`
},
{
id: 5,
slug: 'ai-basics',
title: '人工知能の基礎',
category: 'テクノロジー',
tags: ['AI','機械学習','深層学習'],
summary: '知的な処理をコンピュータで実現する研究分野と技術群。',
updated: '2025-06-12',
author: 'ミニ百科編集部',
content: `
<p>人工知能は探索・推論から機械学習・深層学習まで多様な手法を含みます。現代では大量データと計算資源により実世界応用が拡大。</p>
<h2 id="ml">機械学習</h2>
<p>教師あり・教師なし・強化学習などの枠組みがあり、予測や分類に用いられます。</p>
<h2 id="dl">深層学習</h2>
<p>多層ニューラルネットワークにより画像・音声・自然言語処理で高精度を実現。</p>
`
},
{
id: 6,
slug: 'sakura',
title: 'サクラ(桜)',
category: '文化',
tags: ['日本文化','植物','季節'],
summary: '日本の春を象徴する花。花見は古くからの季節行事。',
updated: '2025-04-02',
author: 'ミニ百科編集部',
content: `
<p>桜はバラ科サクラ属の総称。品種が多く、花期は短いものの観賞価値が高いことで知られます。</p>
<h2 id="hanami">花見の歴史</h2>
<p>貴族文化から庶民に広がり、現在では地域の祭りや観光資源にもなっています。</p>
`
},
{
id: 7,
slug: 'japan-history-outline',
title: '日本史(概説)',
category: '歴史',
tags: ['古代','中世','近代'],
summary: '古代から現代までの日本の歴史を大まかに概観する。',
updated: '2025-01-20',
author: 'ミニ百科編集部',
content: `
<p>日本史は縄文・弥生・古墳などの古代から、中世・近世、明治以降の近代・現代に至るまで連続する多様な変化の歴史です。</p>
<h2 id="ancient">古代</h2>
<p>稲作の普及、古代国家の形成、律令制の確立など。</p>
<h2 id="modern">近代・現代</h2>
<p>近代化、戦後復興、高度経済成長、少子高齢化と新たな課題。</p>
`
},
{
id: 8,
slug: 'programming-intro',
title: 'プログラミング入門',
category: 'テクノロジー',
tags: ['コード','アルゴリズム','学習'],
summary: 'コンピュータに手順を伝えるための技術と考え方の総称。',
updated: '2025-07-22',
author: 'ミニ百科編集部',
content: `
<p>プログラミングは問題を分解し、再利用可能な手順として表現する作業です。変数、条件分岐、反復、関数などの基本を学ぶと応用が広がります。</p>
<h2 id="lang">主な言語</h2>
<p>Python、JavaScript、C#、C++ など用途に応じて選択されます。</p>
`
},
{
id: 9,
slug: 'tea-ceremony',
title: '茶道',
category: '文化',
tags: ['日本文化','礼法','芸道'],
summary: '湯を沸かし茶を点て、客をもてなす総合芸術。',
updated: '2025-05-03',
author: 'ミニ百科編集部',
content: `
<p>茶道は道具、作法、空間、季節感などが一体となる総合芸術です。<span>和敬清寂</span>の精神が重視されます。</p>
<h2 id="tools">道具</h2>
<p>茶碗、茶筅、茶杓、釜、柄杓など。取り扱いには所作と配慮が求められます。</p>
`
},
{
id: 10,
slug: 'solar-system',
title: '太陽系',
category: '天文学',
tags: ['惑星','衛星','宇宙'],
summary: '太陽とその周囲を公転する天体の集まり。',
updated: '2025-06-30',
author: 'ミニ百科編集部',
content: `
<p>太陽系は太陽を中心に、8つの惑星、準惑星、小惑星、彗星、塵やガスが重力で結びつくシステムです。</p>
<h2 id="planets">惑星</h2>
<p>水星・金星・地球・火星・木星・土星・天王星・海王星。各惑星は固有の特徴を持ちます。</p>
`
}
];
// ================
// ユーティリティ
// ================
const $ = (sel, root=document) => root.querySelector(sel);
const $$ = (sel, root=document) => Array.from(root.querySelectorAll(sel));
const fmtDate = iso => new Date(iso).toLocaleDateString('ja-JP', {year:'numeric', month:'short', day:'numeric'});
const unique = arr => [...new Set(arr)];
const slugToArticle = slug => ARTICLES.find(a=>a.slug===slug);
const STORAGE = {
bookmarks: 'mini_ency_bookmarks',
history: 'mini_ency_history'
};
function getBookmarks(){
try{return JSON.parse(localStorage.getItem(STORAGE.bookmarks)||'[]');}catch{ return []; }
}
function setBookmarks(list){ localStorage.setItem(STORAGE.bookmarks, JSON.stringify(unique(list))); }
function isBookmarked(slug){ return getBookmarks().includes(slug); }
function toggleBookmark(slug){
const list=getBookmarks();
if(list.includes(slug)) setBookmarks(list.filter(s=>s!==slug));
else setBookmarks([...list, slug]);
}
function pushHistory(slug){
try{
const now = Date.now();
const hist = JSON.parse(localStorage.getItem(STORAGE.history)||'[]');
const filtered = hist.filter(h=>h.slug!==slug);
filtered.unshift({slug, t: now});
localStorage.setItem(STORAGE.history, JSON.stringify(filtered.slice(0,50)));
}catch{}
}
// ================
// 検索・フィルタ
// ================
let state = {
q: '',
category: '',
tag: '',
sort: 'recent',
page: 1,
perPage: 9
};
function normalize(str){ return (str||'').toString().toLowerCase(); }
function filterArticles(){
let list = ARTICLES.slice();
if(state.q){
const q = normalize(state.q);
list = list.filter(a => normalize(a.title+" "+a.summary+" "+a.tags.join(' ')+" "+a.content.replace(/<[^>]+>/g,'')).includes(q));
}
if(state.category){ list = list.filter(a => a.category===state.category); }
if(state.tag){ list = list.filter(a => a.tags.includes(state.tag)); }
if(state.sort==='recent'){ list.sort((a,b)=> new Date(b.updated)-new Date(a.updated)); }
if(state.sort==='title'){ list.sort((a,b)=> a.title.localeCompare(b.title,'ja')); }
return list;
}
// =============
// 一覧描画
// =============
function renderCategories(){
const select = $('#category');
const cats = unique(ARTICLES.map(a=>a.category)).sort((a,b)=>a.localeCompare(b,'ja'));
select.innerHTML = '<option value="">すべて</option>' + cats.map(c=>`<option value="${c}">${c}</option>`).join('');
}
function renderTagsBar(){
const bar = $('#tagBar');
const tags = unique(ARTICLES.flatMap(a=>a.tags)).sort((a,b)=>a.localeCompare(b,'ja'));
bar.innerHTML = '<div class="flex flex-wrap gap-2">' + tags.map(t=>
`<button data-tag="${t}" class="tag-btn px-3 py-1 rounded-full border border-slate-300 dark:border-slate-700 text-sm hover:bg-slate-100 dark:hover:bg-slate-800 ${state.tag===t?'bg-indigo-600 text-white border-indigo-600':''}">#${t}</button>`
).join('') + '</div>';
$$('.tag-btn').forEach(b=> b.addEventListener('click',()=>{ state.tag = (state.tag===b.dataset.tag? '' : b.dataset.tag); state.page=1; syncList(); }));
}
function createCard(a){
const bookmarked = isBookmarked(a.slug);
return `
<article class="border border-slate-200 dark:border-slate-700 rounded-2xl p-4 bg-white/70 dark:bg-slate-800/70 hover:shadow transition">
<header class="flex items-start justify-between gap-3">
<h3 class="text-lg font-semibold leading-tight">${a.title}</h3>
<button class="bookmark inline-flex items-center justify-center w-9 h-9 rounded-full ${bookmarked?'text-amber-500':'text-slate-400'}" title="ブックマーク" data-slug="${a.slug}">
<i class="${bookmarked?'fa-solid':'fa-regular'} fa-star"></i>
</button>
</header>
<div class="mt-1 text-sm text-slate-500">${a.category}・更新 ${fmtDate(a.updated)}</div>
<p class="mt-2 text-sm line-clamp-3">${a.summary}</p>
<div class="mt-3 flex flex-wrap gap-2 text-xs">${a.tags.map(t=>`<span class='px-2 py-1 rounded-full bg-slate-100 dark:bg-slate-700'>#${t}</span>`).join('')}</div>
<div class="mt-4 flex gap-2">
<a href="#/a/${a.slug}" class="inline-flex items-center gap-2 px-3 py-2 rounded-xl bg-indigo-600 text-white text-sm hover:opacity-90"><i class="fa-solid fa-circle-info"></i> 詳細</a>
<button class="copy-link inline-flex items-center gap-2 px-3 py-2 rounded-xl border border-slate-300 dark:border-slate-700 text-sm hover:bg-slate-100 dark:hover:bg-slate-800" data-link="${location.origin+location.pathname}#/a/${a.slug}"><i class="fa-solid fa-link"></i>リンク</button>
</div>
</article>
`;
}
function renderPager(total){
const pager = $('#pager');
const pages = Math.max(1, Math.ceil(total/state.perPage));
state.page = Math.min(state.page, pages);
let html='';
for(let i=1;i<=pages;i++){
html += `<button class="px-3 py-1 rounded-lg border ${i===state.page?'bg-indigo-600 text-white border-indigo-600':'border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-800'}" data-page="${i}">${i}</button>`;
}
pager.innerHTML = html;
$$('#pager button').forEach(b=> b.addEventListener('click',()=>{ state.page=Number(b.dataset.page); syncList(false); }));
}
function syncList(scrollTop=true){
const list = filterArticles();
$('#resultCount').textContent = list.length;
renderPager(list.length);
const start=(state.page-1)*state.perPage;
const pageItems=list.slice(start, start+state.perPage);
$('#cards').innerHTML = pageItems.map(createCard).join('');
// events
$$('.bookmark').forEach(b=> b.addEventListener('click',()=>{ toggleBookmark(b.dataset.slug); syncList(false); }));
$$('.copy-link').forEach(b=> b.addEventListener('click',()=> copyText(b.dataset.link)));
if(scrollTop) window.scrollTo({top:0, behavior:'smooth'});
}
// =================
// 記事ページ描画
// =================
function renderArticle(slug){
const a = slugToArticle(slug);
if(!a){ location.hash = ''; return; }
// breadcrumb
$('#breadcrumb').innerHTML = `<a class="underline" href="#">ホーム</a> / <span class="text-slate-600">${a.category}</span>`;
// title & meta
$('#articleTitle').textContent = a.title;
$('#articleMeta').innerHTML = `
<span><i class="fa-regular fa-calendar"></i> 更新 ${fmtDate(a.updated)}</span>
<span class="mx-1">•</span>
<span><i class="fa-regular fa-user"></i> ${a.author}</span>
<span class="mx-1">•</span>
<span><i class="fa-solid fa-folder"></i> ${a.category}</span>
`;
// content
const container = $('#articleContent');
container.innerHTML = a.content;
// tags
$('#articleTags').innerHTML = a.tags.map(t=>`<a href="#" data-tag="${t}" class="px-3 py-1 rounded-full border border-slate-300 dark:border-slate-700 text-sm hover:bg-slate-100 dark:hover:bg-slate-800">#${t}</a>`).join('');
$$('#articleTags a').forEach(el=> el.addEventListener('click',(e)=>{ e.preventDefault(); state.tag=el.dataset.tag; location.hash=''; }));
// bookmark button
const btnBM = $('#btnToggleBookmark');
const setBM = ()=>{
const marked = isBookmarked(a.slug);
btnBM.innerHTML = `<i class="${marked?'fa-solid':'fa-regular'} fa-star me-1"></i>${marked?'保存済み':'ブックマーク'}`;
};
btnBM.onclick = ()=>{ toggleBookmark(a.slug); setBM(); };
setBM();
// copy link
$('#btnCopyLink').onclick = ()=> copyText(location.href);
// TOC
buildTOC();
// JSON-LD
updateLDJSON(a);
// history
pushHistory(a.slug);
}
function buildTOC(){
const toc = $('#toc');
const headings = $$('#articleContent h2, #articleContent h3');
if(headings.length===0){ toc.innerHTML = '<div class="text-slate-500 text-sm">見出しがありません</div>'; return; }
let html='';
headings.forEach(h=>{
if(!h.id) h.id = h.textContent.trim().toLowerCase().replace(/[^a-z0-9一-龥ぁ-んァ-ヶー]+/g,'-');
const indent = h.tagName==='H3' ? 'ms-4' : '';
html += `<a href="#${h.id}" class="${indent} hover:text-indigo-600">${h.textContent}</a>`;
});
toc.innerHTML = html;
const observer = new IntersectionObserver((entries)=>{
entries.forEach(e=>{
if(e.isIntersecting){
$$('#toc a').forEach(a=>a.classList.remove('active'));
const a = $(`#toc a[href="#${e.target.id}"]`);
if(a) a.classList.add('active');
}
});
}, {rootMargin: '0px 0px -70% 0px'});
headings.forEach(h=> observer.observe(h));
}
function updateLDJSON(a){
const obj = {
'@context':'https://schema.org',
'@type':'Article',
headline: a.title,
dateModified: a.updated,
author: { '@type':'Organization', name: a.author },
keywords: a.tags.join(','),
articleSection: a.category,
url: location.href
};
$('#ldjson').textContent = JSON.stringify(obj);
}
// ============
// ルーター
// ============
function route(){
const hash = location.hash.slice(1);
if(hash.startsWith('/a/')){
const slug = hash.split('/')[2];
$('#view-home').classList.add('hidden');
$('#view-article').classList.remove('hidden');
renderArticle(slug);
window.scrollTo({top:0, behavior:'instant'});
}else{
$('#view-article').classList.add('hidden');
$('#view-home').classList.remove('hidden');
syncList();
}
}
window.addEventListener('hashchange', route);
// ============
// 便利機能
// ============
function copyText(text){
navigator.clipboard.writeText(text).then(()=>{
toast('リンクをコピーしました');
}, ()=>{
prompt('コピーできない場合は手動で選択してコピーしてください:', text);
});
}
function toast(msg){
const t = document.createElement('div');
t.textContent = msg;
t.className = 'fixed left-1/2 -translate-x-1/2 bottom-6 z-50 bg-black/80 text-white px-4 py-2 rounded-xl text-sm';
document.body.appendChild(t);
setTimeout(()=>{ t.remove(); }, 1600);
}
function randomArticle(){
const a = ARTICLES[Math.floor(Math.random()*ARTICLES.length)];
location.hash = `#/a/${a.slug}`;
}
// ==============
// I/O (JSON)
// ==============
function exportJSON(){
const blob = new Blob([JSON.stringify(ARTICLES, null, 2)], {type:'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'mini-encyclopedia.json'; a.click();
URL.revokeObjectURL(url);
}
function importJSON(file){
const reader = new FileReader();
reader.onload = (e)=>{
try{
const data = JSON.parse(e.target.result);
if(Array.isArray(data)){
// 形式が正しければ差し替え
if(data.every(x=>x.slug && x.title && x.content)){
ARTICLES.length = 0; // 破壊的更新
data.forEach(x=> ARTICLES.push(x));
init();
toast('JSONを読み込みました');
}else{
alert('スキーマが不正です。slug/title/content は必須です。');
}
}else{
alert('配列形式のJSONが必要です');
}
}catch(err){
alert('JSONの解析に失敗しました: '+err.message);
}
};
reader.readAsText(file);
}
// ============
// 初期化
// ============
function init(){
// 年
$('#year').textContent = new Date().getFullYear();
// カテゴリ・タグバー
renderCategories();
renderTagsBar();
// イベント
$('#search').addEventListener('input', (e)=>{ state.q = e.target.value.trim(); state.page=1; syncList(); });
$('#category').addEventListener('change', (e)=>{ state.category = e.target.value; state.page=1; syncList(); });
$('#sort').addEventListener('change', (e)=>{ state.sort = e.target.value; state.page=1; syncList(); });
$('#btnRandom').addEventListener('click', randomArticle);
$('#btnBookmarks').addEventListener('click', ()=>{
const bms = getBookmarks();
if(bms.length===0){ toast('ブックマークはまだありません'); return; }
const first = slugToArticle(bms[0]);
if(first) location.hash = `#/a/${first.slug}`;
});
$('#btnHome').addEventListener('click', ()=>{ location.hash=''; });
// ダークモード切替
$('#btnDark').addEventListener('click', ()=>{
const root = document.documentElement;
const isDark = root.classList.toggle('dark');
localStorage.setItem('theme', isDark? 'dark':'light');
});
// JSON I/O
$('#btnExport').addEventListener('click', exportJSON);
$('#fileImport').addEventListener('change', (e)=>{ const f=e.target.files?.[0]; if(f) importJSON(f); e.target.value=''; });
// 初回描画
route();
}
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>
XLogpro.html(ツイートまとめサイト)
<!DOCTYPE html>
<html lang="ja" data-theme="light" style="--cols:3; --card-h:640px; --accent:#2563eb">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Xlog Pro — HTMLだけで動く自動ツイートまとめ</title>
<link rel="preconnect" href="https://platform.twitter.com" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"/>
<meta name="description" content="HTMLだけ/APIキー不要のX(Twitter)まとめボード。プロフィール・ハッシュタグ・検索・リストを好きな列で配置し、JSON/HTML書き出しや手動ランキング、ボード切替に対応。">
<style>
:root{
--bg: #0b0e14; --panel:#111827; --muted:#9aa4b2; --text:#e5e7eb; --border:#1f2937; --chip:#141a23; --card:#0f172a; --btn:#1f2937; --btn-text:#e5e7eb; --link:#60a5fa;
}
[data-theme="light"]{ --bg:#f8fafc; --panel:#ffffff; --muted:#64748b; --text:#0f172a; --border:#e2e8f0; --chip:#f1f5f9; --card:#ffffff; --btn:#0f172a; --btn-text:#ffffff; --link:#1d9bf0; }
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,"Apple Color Emoji","Segoe UI Emoji"}
header{position:sticky;top:0;z-index:10;background:var(--panel);border-bottom:1px solid var(--border)}
.wrap{max-width:1280px;margin:0 auto;padding:12px 16px}
.row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
.brand{display:flex;gap:10px;align-items:center;font-weight:800}
.brand i{color:var(--accent)}
.muted{color:var(--muted)}
.pill{display:inline-flex;gap:8px;align-items:center;background:var(--chip);border:1px solid var(--border);border-radius:999px;padding:6px 10px}
.input, select, textarea{background:transparent;border:1px solid var(--border);border-radius:10px;padding:8px 10px;color:var(--text)}
textarea{min-height:88px;width:100%;}
input[type="text"].input{min-width:220px}
button{cursor:pointer;border:none}
.btn{background:var(--btn);color:var(--btn-text);padding:9px 12px;border-radius:12px}
.btn.secondary{background:transparent;color:var(--text);border:1px solid var(--border)}
.btn.ghost{background:transparent;color:var(--text)}
.btn.badge{padding:6px 10px;border-radius:999px}
.grid{display:grid;grid-template-columns:320px 1fr;gap:16px}
@media (max-width:1080px){.grid{grid-template-columns:1fr}}
aside{background:var(--panel);border:1px solid var(--border);border-radius:16px;padding:14px;position:sticky;top:72px;height:max-content}
h2{margin:6px 0 12px 0;font-size:18px}
.list{display:flex;flex-direction:column;gap:12px}
.card{background:var(--card);border:1px solid var(--border);border-radius:16px;overflow:hidden}
.card .head{display:flex;justify-content:space-between;align-items:center;padding:12px 14px;border-bottom:1px solid var(--border)}
.card .head .title{display:flex;gap:8px;align-items:center;font-weight:700}
.card .body{padding:0;min-height:var(--card-h)}
.sources{display:flex;flex-wrap:wrap;gap:8px}
.chip{background:var(--chip);border:1px solid var(--border);border-radius:999px;padding:6px 10px;display:flex;gap:8px;align-items:center}
.chip b{color:var(--accent)}
.columns{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:16px}
@media (max-width:1200px){:root{--cols:2}}
@media (max-width:860px){:root{--cols:1}}
.drag{cursor:grab}
.toolbar{display:flex;gap:8px;flex-wrap:wrap}
.footer{padding:24px 16px;color:var(--muted);text-align:center}
.kbd{font-family:ui-monospace, Menlo, Monaco, Consolas; background:var(--chip); border:1px solid var(--border); padding:2px 6px; border-radius:6px}
.danger{color:#ef4444}
.accent{color:var(--accent)}
.section{background:var(--panel);border:1px solid var(--border);border-radius:16px;padding:14px}
.help{font-size:13px;color:var(--muted)}
.label{font-size:12px;color:var(--muted)}
.tiny{font-size:12px}
.row-wrap{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.w-100{width:100%}
.space{height:8px}
</style>
</head>
<body>
<header>
<div class="wrap row">
<div class="brand"><i class="fa-solid fa-wave-square"></i> Xlog <span class="muted">Pro</span></div>
<div class="pill">
<i class="fa-solid fa-diagram-project"></i>
<select id="boardSelect" title="ボード切替"></select>
<button id="boardNew" class="btn badge secondary" title="新規ボード"><i class="fa-solid fa-plus"></i></button>
<button id="boardRename" class="btn badge secondary" title="名前変更"><i class="fa-solid fa-pen"></i></button>
<button id="boardDelete" class="btn badge secondary danger" title="削除"><i class="fa-regular fa-trash-can"></i></button>
</div>
<div class="pill" title="テーマ切替"><i class="fa-solid fa-circle-half-stroke"></i>
<label class="row-wrap"><input id="themeToggle" type="checkbox" /> ダーク</label>
</div>
<div class="pill" title="アクセントカラー">
<i class="fa-solid fa-palette"></i>
<input id="accentPicker" type="color" value="#2563eb" />
</div>
<div class="pill" title="列数"><i class="fa-solid fa-table-columns"></i>
<input id="colsRange" type="range" min="1" max="4" step="1" value="3"/>
<span id="colsVal" class="tiny"></span>
</div>
<div class="pill" title="カード高さ"><i class="fa-solid fa-up-down"></i>
<input id="heightRange" type="range" min="360" max="1200" step="40" value="640"/>
<span id="heightVal" class="tiny"></span>
</div>
<div class="pill" title="自動再読み込み">
<label class="row-wrap"><input id="autoRefreshToggle" type="checkbox"/> 自動</label>
<select id="refreshMinutes">
<option value="3">3分</option>
<option value="5" selected>5分</option>
<option value="10">10分</option>
<option value="30">30分</option>
</select>
</div>
<div class="pill help tiny">ショートカット: <span class="kbd">N</span> 追加 / <span class="kbd">R</span> 再描画 / <span class="kbd">G</span> グリッド- / <span class="kbd">H</span> 高さ-</div>
</div>
</header>
<main class="wrap grid">
<aside>
<div class="list">
<div class="section">
<h2>ソースを追加</h2>
<div class="toolbar row-wrap">
<select id="sourceType">
<option value="profile">プロフィール</option>
<option value="hashtag">ハッシュタグ</option>
<option value="search">検索キーワード</option>
<option value="list">リストURL</option>
</select>
<input id="sourceValue" class="input" type="text" placeholder="@username / #tag / キーワード / リストURL" />
<input id="sourceLabel" class="input" type="text" placeholder="表示名(任意)" />
<button id="addBtn" class="btn"><i class="fa-solid fa-plus"></i> 追加</button>
</div>
<div class="space"></div>
<label class="label">まとめて追加(改行/カンマ区切りOK)</label>
<textarea id="bulkArea" placeholder="@OpenAI, #UnrealEngine, Unity URP, https://twitter.com/i/lists/123...\n@EpicGames"></textarea>
<div class="row-wrap">
<button id="bulkAdd" class="btn secondary"><i class="fa-solid fa-download"></i> 取り込み</button>
<button id="bulkClear" class="btn ghost"><i class="fa-solid fa-eraser"></i> クリア</button>
</div>
<p class="help" style="margin-top:8px">形式は自動判定:<span class="kbd">@id</span> → プロフィール、<span class="kbd">#tag</span> → ハッシュタグ、<span class="kbd">twitter.com/i/lists</span> → リスト、それ以外は検索。</p>
<div class="space"></div>
<div class="row-wrap help tiny">クイック追加:</div>
<div class="row-wrap">
<button class="btn badge secondary quick" data-type="hashtag" data-val="#UnrealEngine">#UnrealEngine</button>
<button class="btn badge secondary quick" data-type="hashtag" data-val="#Unity3D">#Unity3D</button>
<button class="btn badge secondary quick" data-type="search" data-val="VRM OR \"Meta Quest\"">VR/Quest</button>
<button class="btn badge secondary quick" data-type="profile" data-val="@OpenAI">@OpenAI</button>
</div>
</div>
<div class="section">
<h2>保存・書き出し</h2>
<div class="toolbar row-wrap">
<button id="exportBtn" class="btn secondary"><i class="fa-solid fa-file-export"></i> JSON</button>
<label class="btn secondary" for="importFile"><i class="fa-solid fa-file-import"></i> JSON読込</label>
<input id="importFile" type="file" accept="application/json" hidden />
<button id="exportHtmlBtn" class="btn"><i class="fa-regular fa-file-code"></i> 単一HTML</button>
<button id="clearBtn" class="btn ghost danger"><i class="fa-regular fa-trash-can"></i> すべて削除</button>
</div>
<p class="help">単一HTML: いまのレイアウトと設定を埋め込んだ自立HTMLを生成します。</p>
</div>
<div class="section">
<h2>手動ランキング</h2>
<div class="toolbar row-wrap">
<input id="tweetUrl" class="input" type="text" placeholder="ツイートURLを貼り付け" />
<button id="addTweetBtn" class="btn"><i class="fa-brands fa-x-twitter"></i> 追加</button>
</div>
<label class="label">メモ(任意・次回以降も保持)</label>
<textarea id="tweetNote" placeholder="このツイートの要点やタグ(例: #UE5 #VRM)"></textarea>
<p class="help">※HTMLのみの制約で自動集計は不可。URLをカード化して手動で順序を決められます。</p>
</div>
<div class="section">
<h2>RSS生成(ランキング→RSS)</h2>
<div class="toolbar row-wrap">
<input id="rssTitle" class="input" type="text" placeholder="RSSタイトル(例: Xlogランキング)"/>
<button id="rssExport" class="btn secondary"><i class="fa-solid fa-rss"></i> RSSを書き出し</button>
</div>
<p class="help">ランキングに登録したツイートURLから簡易RSS(XML)を生成し、ファイルとして保存します。</p>
</div>
<div class="section">
<h2>ヘルプ</h2>
<div class="help">
・列の並べ替えはカードの <span class="kbd">⋯</span> アイコンをドラッグ。<br>
・<span class="kbd">R</span> で全カラムを再描画。<br>
・URLハッシュ <span class="kbd">#data=</span> に設定をBase64で埋め込んで共有可能(メニューから自動生成予定)。
</div>
</div>
</div>
</aside>
<section>
<div class="card" style="margin-bottom:16px">
<div class="head">
<div class="title"><i class="fa-solid fa-layer-group drag"></i> マイまとめ <span class="muted tiny" id="boardInfo"></span></div>
<div class="sources" id="activeChips"></div>
</div>
<div class="body" style="padding:14px">
<div id="columns" class="columns"></div>
</div>
</div>
<div class="card">
<div class="head">
<div class="title"><i class="fa-regular fa-star"></i> 手動ランキング</div>
<div class="help">ドラッグで順序変更/🗑で削除/✎でメモ編集</div>
</div>
<div class="body" style="padding:14px">
<div id="ranking" class="columns"></div>
</div>
</div>
<div class="footer">Xlog Pro v2 — HTML Only / Embedded Timelines. No API keys. <span class="muted">Made for you.</span></div>
</section>
</main>
<script async src="https://platform.twitter.com/widgets.js"></script>
<script>
// ========== 基本ユーティリティ ==========
const $ = (s, d=document)=>d.querySelector(s);
const $$ = (s, d=document)=>Array.from(d.querySelectorAll(s));
const defaultBoard = ()=>({sources:[], tweets:[]});
const defaultState = ()=>({
version:2,
dark:false,
accent:'#2563eb',
autoRefresh:false,
minutes:5,
columns:3,
cardHeight:640,
boards:{'Default': defaultBoard()},
activeBoard:'Default'
});
const store = {
key: 'xlog-pro-v2',
load(){
try{ return JSON.parse(localStorage.getItem(this.key)) || defaultState(); }
catch(e){ return defaultState(); }
},
save(v){ localStorage.setItem(this.key, JSON.stringify(v)); }
};
function migrate(s){
const base = defaultState();
if (!s || typeof s !== 'object') return base;
// v1互換(sources/tweets直下 → boards.Default)
if (s.sources || s.tweets){
base.boards.Default.sources = s.sources||[];
base.boards.Default.tweets = s.tweets||[];
}
// 既存キー上書き
for (const k of ['dark','accent','autoRefresh','minutes','columns','cardHeight','boards','activeBoard']){
if (k in s) base[k]=s[k];
}
return base;
}
// ハッシュ (#data=BASE64) から読み込み
function loadFromHash(){
const h = location.hash || '';
if (!h.startsWith('#data=')) return null;
try{
const b64 = decodeURIComponent(h.slice(6));
const json = atob(b64);
return JSON.parse(json);
}catch(e){ return null; }
}
let embedded = (typeof window.__XLOG_INITIAL_STATE__!== 'undefined') ? window.__XLOG_INITIAL_STATE__ : null;
if (!embedded){
const el = document.getElementById('xlog-init');
if (el) { try{ embedded = JSON.parse(el.textContent); }catch(_e){} }
}
let state = migrate( embedded || loadFromHash() || store.load() );
// ========== テーマ/アクセント/レイアウト適用 ==========
function applySkin(){
document.documentElement.setAttribute('data-theme', state.dark ? 'dark' : 'light');
document.documentElement.style.setProperty('--cols', state.columns);
document.documentElement.style.setProperty('--card-h', state.cardHeight+'px');
document.documentElement.style.setProperty('--accent', state.accent || '#2563eb');
$('#themeToggle').checked = !!state.dark;
$('#colsRange').value = String(state.columns);
$('#colsVal').textContent = state.columns+'列';
$('#heightRange').value = String(state.cardHeight);
$('#heightVal').textContent = state.cardHeight+'px';
$('#accentPicker').value = state.accent || '#2563eb';
}
// ========== X埋め込み ==========
function waitTwttr(){
return new Promise(res=>{
if (window.twttr && twttr.widgets) return res();
const timer = setInterval(()=>{ if(window.twttr && twttr.widgets){ clearInterval(timer); res(); } }, 200);
});
}
function timelineOptions(){
return {
height: state.cardHeight,
theme: state.dark ? 'dark' : 'light',
chrome: 'nofooter noborders transparent',
linkColor: getComputedStyle(document.documentElement).getPropertyValue('--link').trim() || '#1d9bf0'
};
}
async function createTimeline(el, src){
await waitTwttr();
const opts = timelineOptions();
const t = (src.type||'profile');
if (t==='profile'){
const screenName = src.value.replace(/^@/,'');
return twttr.widgets.createTimeline({ sourceType:'profile', screenName }, el, opts);
}
if (t==='list'){
return twttr.widgets.createTimeline({ sourceType:'url', url: src.value }, el, opts);
}
if (t==='hashtag'){
const tag = src.value.replace(/^#/,'');
const url = `https://twitter.com/hashtag/${encodeURIComponent(tag)}?f=live`;
return twttr.widgets.createTimeline({ sourceType:'url', url }, el, opts);
}
if (t==='search'){
const url = `https://twitter.com/search?q=${encodeURIComponent(src.value)}&f=live`;
return twttr.widgets.createTimeline({ sourceType:'url', url }, el, opts);
}
}
// ========== 現在ボードの参照 ==========
function board(){ return state.boards[state.activeBoard] || (state.boards[state.activeBoard]=defaultBoard()); }
// ========== 描画 ==========
function chipNode(src, idx){
const chip = document.createElement('span');
chip.className='chip';
const kind = {profile:'@',hashtag:'#',search:'検索:',list:'リスト'}[src.type] || '';
chip.innerHTML = `<b>${kind}</b> ${src.label || src.value} <a class="muted" href="${openUrl(src)}" target="_blank" title="Xで開く"><i class="fa-solid fa-arrow-up-right-from-square"></i></a> <button title="削除" data-del="${idx}" class="muted"><i class="fa-solid fa-xmark"></i></button>`;
chip.querySelector('button').onclick = ()=>{ board().sources.splice(idx,1); store.save(state); renderAll(); };
return chip;
}
function openUrl(src){
if (src.type==='profile') return `https://twitter.com/${src.value.replace(/^@/,'')}`;
if (src.type==='hashtag') return `https://twitter.com/hashtag/${src.value.replace(/^#/,'')}`;
if (src.type==='list') return src.value;
return `https://twitter.com/search?q=${encodeURIComponent(src.value)}&f=live`;
}
function columnCard(src, idx){
const card = document.createElement('div');
card.className='card';
card.draggable=true; card.dataset.idx=idx;
card.innerHTML = `
<div class="head">
<div class="title"><i class="fa-solid fa-grip-vertical drag"></i> ${src.label || prettyLabel(src)}</div>
<div class="toolbar">
<a class="btn ghost" href="${openUrl(src)}" target="_blank" title="Xで開く"><i class="fa-solid fa-arrow-up-right-from-square"></i></a>
<button class="btn ghost" title="再読み込み" data-refresh="${idx}"><i class="fa-solid fa-rotate"></i></button>
<button class="btn ghost danger" title="削除" data-remove="${idx}"><i class="fa-regular fa-trash-can"></i></button>
</div>
</div>
<div class="body"><div class="embed" style="min-height:120px"></div></div>`;
// DnD 並べ替え
card.addEventListener('dragstart', e=>{ e.dataTransfer.setData('text/plain', idx); card.style.opacity='0.6'; });
card.addEventListener('dragend', ()=>{ card.style.opacity='1'; });
card.addEventListener('dragover', e=>{ e.preventDefault(); card.style.outline='2px dashed var(--accent)'; });
card.addEventListener('dragleave', ()=>{ card.style.outline='none'; });
card.addEventListener('drop', e=>{
e.preventDefault(); card.style.outline='none';
const from = +e.dataTransfer.getData('text/plain');
const to = +card.dataset.idx;
if (from===to) return;
const arr = board().sources;
const [moved] = arr.splice(from,1);
arr.splice(to,0,moved);
store.save(state); renderAll();
});
// 操作
card.querySelector('[data-remove]')?.addEventListener('click', ()=>{ board().sources.splice(idx,1); store.save(state); renderAll(); });
card.querySelector('[data-refresh]')?.addEventListener('click', ()=>{ mountTimeline(card, src); });
// 初回描画
mountTimeline(card, src);
return card;
}
function mountTimeline(card, src){
const holder = card.querySelector('.embed');
holder.innerHTML = '<div style="padding:14px" class="muted">読み込み中…</div>';
createTimeline(holder, src).catch(()=>{
holder.innerHTML = '<div style="padding:14px" class="danger">読み込みに失敗しました。値を確認してください。</div>';
});
}
function prettyLabel(src){
if (src.type==='profile') return '@'+src.value.replace(/^@/,'');
if (src.type==='hashtag') return '#'+src.value.replace(/^#/,'');
if (src.type==='search') return '検索: '+src.value;
if (src.type==='list') return 'リスト';
return src.value;
}
function renderAll(){
// ボード情報
const info = $('#boardInfo');
const b = board();
info.textContent = `(${state.activeBoard}|${b.sources.length}列 / ${b.tweets.length}件)`;
// チップ
const chips = $('#activeChips'); chips.innerHTML='';
b.sources.forEach((s,i)=> chips.appendChild(chipNode(s,i)) );
// カラム
const col = $('#columns'); col.innerHTML='';
b.sources.forEach((s,i)=> col.appendChild(columnCard(s,i)) );
// ランキング
renderRanking();
}
// ========== ランキング ==========
function parseTweetId(url){ const m = (url||'').match(/status\/(\d{5,})/); return m? m[1] : null; }
function tweetUrlFromId(id){ return `https://twitter.com/i/web/status/${id}`; }
async function addTweet(url, note){
const id = parseTweetId(url);
if (!id) return alert('ツイートURLが正しくありません');
board().tweets.push({id, note: (note||'')});
store.save(state); renderRanking();
}
async function renderRanking(){
await waitTwttr();
const root = $('#ranking'); root.innerHTML='';
const arr = board().tweets;
arr.forEach((t, idx)=>{
const card = document.createElement('div'); card.className='card'; card.draggable=true; card.dataset.idx=idx;
card.innerHTML = `
<div class="head">
<div class="title"><i class="fa-solid fa-grip-vertical drag"></i> エントリ #${idx+1}</div>
<div class="toolbar">
<button class="btn ghost" data-edit="${idx}" title="メモ編集"><i class="fa-regular fa-pen-to-square"></i></button>
<a class="btn ghost" href="${tweetUrlFromId(t.id)}" target="_blank" title="Xで開く"><i class="fa-solid fa-arrow-up-right-from-square"></i></a>
<button class="btn ghost danger" title="削除" data-del-rank="${idx}"><i class="fa-regular fa-trash-can"></i></button>
</div>
</div>
<div class="body">
<div class="embed"></div>
<div style="padding:10px 14px;border-top:1px solid var(--border)" class="tiny"><span class="muted">メモ:</span> <span class="note">${escapeHtml(t.note||'')}</span></div>
</div>`;
// イベント
card.querySelector('[data-del-rank]')?.addEventListener('click', ()=>{ arr.splice(idx,1); store.save(state); renderRanking(); });
card.querySelector('[data-edit]')?.addEventListener('click', ()=>{
const newNote = prompt('メモを編集', t.note||'');
if (newNote!==null){ t.note = newNote; store.save(state); renderRanking(); }
});
// DnD 並べ替え
card.addEventListener('dragstart', e=>{ e.dataTransfer.setData('text/plain', 'rank:'+idx); card.style.opacity='0.6'; });
card.addEventListener('dragend', ()=>{ card.style.opacity='1'; });
card.addEventListener('dragover', e=>{ e.preventDefault(); card.style.outline='2px dashed var(--accent)'; });
card.addEventListener('dragleave', ()=>{ card.style.outline='none'; });
card.addEventListener('drop', e=>{
e.preventDefault(); card.style.outline='none';
const data = e.dataTransfer.getData('text/plain'); if (!data.startsWith('rank:')) return;
const from = +data.split(':')[1]; const to = +card.dataset.idx;
const [moved] = arr.splice(from,1); arr.splice(to,0,moved);
store.save(state); renderRanking();
});
const holder = card.querySelector('.embed');
twttr.widgets.createTweet(t.id, holder, { theme: state.dark ? 'dark' : 'light' });
root.appendChild(card);
});
}
function escapeHtml(s){ return (s||'').replace(/[&<>"']/g, m=> ({'&':'&','<':'<','>':'>','"':'"','\'':'''}[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=> ({'<':'<','>':'>','&':'&','\"':'"','\'':'''}[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>
BranchBoard.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>GBranchBoard</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Bootstrap & FontAwesome -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" rel="stylesheet">
<style>
body {
background: #f6f8fa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.repo-header { background: #fff; padding: 20px; border: 1px solid #ddd; margin-bottom: 10px; }
.nav-tabs .nav-link.active { border-color: #ddd #ddd #fff; background: #fff; }
.file-list li { border-bottom: 1px solid #eee; padding: 8px 0; display: flex; align-items: center; }
.file-list i { margin-right: 10px; }
.readme-box, .issues-box, .commits-box { background: #fff; padding: 20px; border: 1px solid #ddd; margin-top: 10px; }
#editor { height: 400px; width: 100%; border: 1px solid #ccc; }
</style>
</head>
<body>
<!-- ヘッダー -->
<nav class="navbar navbar-dark bg-dark px-3">
<span class="text-white"><i class="fas fa-user-circle"></i> owner</span>
</nav>
<div class="container mt-3">
<!-- リポジトリヘッダー -->
<div class="repo-header">
<h3><i class="fas fa-book"></i> owner / <strong>SampleRepo</strong></h3>
<p class="text-muted">最終更新: 2025年5月26日</p>
<button class="btn btn-sm btn-outline-secondary"><i class="fas fa-star"></i> Star</button>
<button class="btn btn-sm btn-outline-secondary"><i class="fas fa-code-branch"></i> Fork</button>
</div>
<!-- タブ -->
<ul class="nav nav-tabs" id="repoTabs">
<li class="nav-item"><a class="nav-link active" href="#" onclick="switchTab('code')"><i class="fas fa-code"></i> Code</a></li>
<li class="nav-item"><a class="nav-link" href="#" onclick="switchTab('issues')"><i class="fas fa-exclamation-circle"></i> Issues</a></li>
<li class="nav-item"><a class="nav-link" href="#" onclick="switchTab('commits')"><i class="fas fa-history"></i> Commits</a></li>
</ul>
<!-- Codeタブ -->
<div id="tab-code" class="tab-content">
<ul class="file-list list-unstyled bg-white p-3 border">
<li><i class="fas fa-folder"></i> src/</li>
<li><i class="fas fa-file-code"></i> index.js</li>
<li><i class="fas fa-file-alt"></i> README.md</li>
<li><i class="fas fa-file-alt"></i> LICENSE</li>
</ul>
<div class="readme-box">
<h4><i class="fas fa-book-open"></i> README.md</h4>
<hr>
<div id="readme-content"></div>
</div>
<div class="mt-4">
<h5><i class="fas fa-code"></i> コード編集 (Monaco Editor)</h5>
<div id="editor"></div>
<button id="saveCode" class="btn btn-success mt-2"><i class="fas fa-save"></i> 保存</button>
<button id="themeToggle" class="btn btn-secondary mt-2"><i class="fas fa-adjust"></i> テーマ切替</button>
</div>
</div>
<!-- Issuesタブ -->
<div id="tab-issues" class="tab-content" style="display:none;">
<div class="issues-box">
<h4><i class="fas fa-exclamation-circle"></i> Open Issues</h4>
<ul class="list-group">
<li class="list-group-item">
<strong>#1</strong> READMEの翻訳が必要です<br>
<small class="text-muted">posted by owner - 1日前</small>
</li>
<li class="list-group-item">
<strong>#2</strong> index.jsにテストコードを追加してください<br>
<small class="text-muted">posted by owner - 2日前</small>
</li>
</ul>
</div>
</div>
<!-- Commitsタブ -->
<div id="tab-commits" class="tab-content" style="display:none;">
<div class="commits-box">
<h4><i class="fas fa-history"></i> Commits</h4>
<ul class="list-group">
<li class="list-group-item">
<strong>Initial commit</strong> - 2025-05-25<br>
<small class="text-muted">by owner</small>
</li>
<li class="list-group-item">
<strong>README updated</strong> - 2025-05-26<br>
<small class="text-muted">by owner</small>
</li>
</ul>
</div>
</div>
</div>
<!-- ライブラリ -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs/loader.min.js"></script>
<!-- タブ切替・Markdown表示・Monaco起動 -->
<script>
const markdown = `
# SampleRepo
## 特徴
- Monaco Editorでコード編集
- Markdown表示(README)
- ダークモード対応
- Issue・コミットのUI
## 技術
- HTML/CSS/JS
- Bootstrap5
- Monaco Editor
- Marked.js
`;
document.getElementById('readme-content').innerHTML = marked.parse(markdown);
function switchTab(tab) {
['code', 'issues', 'commits'].forEach(id => {
document.getElementById('tab-' + id).style.display = (id === tab) ? 'block' : 'none';
});
document.querySelectorAll('#repoTabs .nav-link').forEach(link => link.classList.remove('active'));
document.querySelector(`#repoTabs .nav-link[onclick*="${tab}"]`).classList.add('active');
}
// Monaco起動
require.config({ paths: { 'vs': 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs' }});
require(['vs/editor/editor.main'], function () {
window.editor = monaco.editor.create(document.getElementById('editor'), {
value: localStorage.getItem('savedCode') || `function hello() {\n console.log("Hello from Monaco Editor!");\n}`,
language: 'javascript',
theme: 'vs-light',
automaticLayout: true
});
document.getElementById('saveCode').onclick = function () {
const code = editor.getValue();
localStorage.setItem('savedCode', code);
alert('保存しました!');
};
document.getElementById('themeToggle').onclick = function () {
const theme = monaco.editor.getTheme() === 'vs-dark' ? 'vs-light' : 'vs-dark';
monaco.editor.setTheme(theme);
};
});
</script>
</body>
</html>
GPT-5
GPT‑5(正式には次世代ChatGPTとしてリリース予定)は、無料プランでも利用可能になる予定で、ユーザーはいわゆる標準的なインテリジェンス設定で無制限にチャット利用できるとOpenAI CEOのサム・アルトマン氏が公式に発表しています。 Plus や Pro の有料プランでは、さらに高度な知能レベルや追加機能にアクセスできるようになります note(ノート)+7note(ノート)+7MiraLab.inc+7。
🎯 どこで無料で使えるか?
- ChatGPT(OpenAI公式ウェブ版/アプリ)
無料アカウントでも GPT‑5(標準モード)を利用可能。乱用防止のための使用制限(例:1日の回数上限など)は設けられる可能性があるが、無制限チャットが基本です Reddit。 - Microsoft Copilot
Windows PCに統合された Copilot は GPT‑5 に対応する「Smart モード」を導入予定で、プラットフォームそのもので GPT‑5 を無料で利用できるようになると見られます(ただし、精度やレスポンスの仕様は流動的)note(ノート)+4note(ノート)+4accelc.co.jp+4。
📋 まとめ
| サービス | GPT‑5 利用状況(無料プラン) |
|---|---|
| ChatGPT(OpenAI公式) | ✅ 無料で標準インテリジェンス設定において無制限利用可能(ただし乱用対策あり) |
| Microsoft Copilot(Windows統合) | ✅ Smart モードで GPT‑5 を採用、無料での使用が期待される |
| その他のAIサービス | 現時点では GPT‑5 の対応情報は限定的 |
ℹ️ 注意点と補足
- GPT‑5 は現在、2025年8月ごろの正式リリースが予定されていますThe Verge。したがって、ChatGPT の無料版でも GPT‑5 を使い始めるのはリリース後になります。
- アルトマン氏は「地球上のすべての人へのGPT‑5の無料提供」を理想として語っていますが、これはまだ公式に実現しているわけではなく、長期的な構想です chatgpt5free.com+15note(ノート)+15shift-ai.co.jp+15。
- 無料プランでは乱用防止や公平性維持のために、トークン制限・アクセス制限などの利用制約が設けられる可能性があります。また、Plus/Pro プランでは優先アクセスや高度な機能が得られます MiraLab.inc+1note(ノート)+1。
✅ ご参考まで
- 使い始めるには?
OpenAI公式サイトまたはモバイルアプリから ChatGPT にサインアップ・ログインすれば、無料プランで GPT‑5(標準モード)が利用可能になります(リリース後)。 - Microsoft Copilot を使うなら?
Windows PC に標準搭載されている Copilot を通じて、GPT‑5 Smart モードで利用できるようになる予定です。
まとめると、GPT‑5 はリリース後に ChatGPT の無料プランで使えます(標準モードで無制限利用、ただし制限あり)。さらに、Microsoft Copilot ユーザーも同時に利用できる可能性があるとの見通しです。
推しログ – 推し活情報共有サービス
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>推しログ - 推し活情報共有サービス</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body class="bg-gradient-to-r from-purple-200 to-indigo-200">
<!-- ヘッダー -->
<header class="bg-purple-600 text-white p-4 shadow-lg">
<div class="container mx-auto flex justify-between items-center">
<h1 class="text-3xl font-bold"><i class="fa-solid fa-heart mr-2"></i>推しログ</h1>
<nav class="space-x-4">
<a href="#" class="hover:text-gray-200">ホーム</a>
<a href="#" class="hover:text-gray-200">スケジュール</a>
<a href="#" class="hover:text-gray-200">ニュース</a>
<a href="#" class="hover:text-gray-200">ログイン</a>
</nav>
</div>
</header>
<!-- メインコンテンツ -->
<main class="container mx-auto my-8 px-4">
<!-- 推しスケジュール -->
<section class="bg-white p-8 rounded-xl shadow-xl mb-8">
<h2 class="text-2xl font-bold border-b pb-2 mb-4"><i class="fa-regular fa-calendar mr-2"></i>今月の推しスケジュール</h2>
<ul class="space-y-3">
<li class="flex justify-between items-center p-4 bg-gray-50 rounded-lg shadow">
<span class="font-semibold">ライブ配信「推しの部屋」</span>
<span class="text-gray-500">8/10(木) 20:00〜</span>
</li>
<li class="flex justify-between items-center p-4 bg-gray-50 rounded-lg shadow">
<span class="font-semibold">ニューシングル発売日!</span>
<span class="text-gray-500">8/15(火)</span>
</li>
<li class="flex justify-between items-center p-4 bg-gray-50 rounded-lg shadow">
<span class="font-semibold">ファンクラブ限定イベント</span>
<span class="text-gray-500">8/25(金) 18:30〜</span>
</li>
</ul>
<button class="mt-4 bg-purple-600 hover:bg-purple-700 text-white py-2 px-4 rounded">もっと見る</button>
</section>
<!-- 最新推しニュース -->
<section class="bg-white p-8 rounded-xl shadow-xl mb-8">
<h2 class="text-2xl font-bold border-b pb-2 mb-4"><i class="fa-solid fa-newspaper mr-2"></i>最新推しニュース</h2>
<article class="mb-6 border-b pb-4">
<h3 class="font-semibold text-purple-700 mb-1">推しの新曲MVが公開!</h3>
<p class="text-gray-600">待望の新曲MVが公式YouTubeチャンネルにて公開されました!視聴数も急上昇中!</p>
<a href="#" class="text-blue-500 hover:underline">詳しく見る <i class="fa-solid fa-arrow-right ml-1"></i></a>
</article>
<article>
<h3 class="font-semibold text-purple-700 mb-1">推し、テレビ番組に出演決定!</h3>
<p class="text-gray-600">8/12(土)の「音楽バズ」にゲスト出演予定です。特別トークもお楽しみに!</p>
<a href="#" class="text-blue-500 hover:underline">詳しく見る <i class="fa-solid fa-arrow-right ml-1"></i></a>
</article>
<button class="mt-4 bg-purple-600 hover:bg-purple-700 text-white py-2 px-4 rounded">もっと見る</button>
</section>
<!-- 推しメンバー紹介 -->
<section class="bg-white p-8 rounded-xl shadow-xl">
<h2 class="text-2xl font-bold border-b pb-2 mb-4"><i class="fa-solid fa-user-group mr-2"></i>推しメンバー紹介</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="text-center">
<img src="https://via.placeholder.com/150" class="rounded-full mx-auto mb-2" alt="推し1">
<h3 class="font-semibold">推しメン1</h3>
</div>
<div class="text-center">
<img src="https://via.placeholder.com/150" class="rounded-full mx-auto mb-2" alt="推し2">
<h3 class="font-semibold">推しメン2</h3>
</div>
<div class="text-center">
<img src="https://via.placeholder.com/150" class="rounded-full mx-auto mb-2" alt="推し3">
<h3 class="font-semibold">推しメン3</h3>
</div>
<div class="text-center">
<img src="https://via.placeholder.com/150" class="rounded-full mx-auto mb-2" alt="推し4">
<h3 class="font-semibold">推しメン4</h3>
</div>
</div>
</section>
</main>
<!-- フッター -->
<footer class="bg-gray-900 text-white text-center py-4 mt-8">
<p class="text-sm">© 2024 推しログ. All rights reserved.</p>
</footer>
</body>
</html>
