<!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>
カテゴリー: programming
Sora 3 企画書(たたき台)
Sora 3 企画書(たたき台)
企画名:Sora 3 — AI Movie Studio
コンセプト
「プロンプトを書く」から「作品を監督する」へ。
文章・画像・音声・絵コンテを入力すると、AIが映像制作工程を統合して一本の作品へ仕上げる次世代映像制作プラットフォーム。
ターゲット
個人クリエイター、YouTuber、アニメ・ゲーム制作者、映像制作会社、広告制作、インディー映画制作者。
主要機能
- 長尺動画生成:数秒単位ではなく、シーンをつないで数分〜長編作品を制作
- キャラクター固定:顔・服・体格・声などをシーンをまたいで維持
- AI絵コンテ:文章からカット割りを自動作成
- カメラ演出:パン、ドリー、ズーム、手持ち、ドローンなどを指定
- セリフ・音声・効果音・BGMを映像と同時生成
- 動画の一部分だけ再生成
- キャラクター、背景、アイテムを個別アセットとして保存
- 「監督AI」が脚本から最適な演出を提案
最大の差別化
従来:プロンプト → 動画
Sora 3:脚本 → 絵コンテ → キャラクター → 撮影 → 音声 → 編集 → 完成映像
つまり、**動画生成AIではなく「AI映像制作環境」**として設計する。
利用例
「田舎町を舞台にした90年代風青春アニメ。主人公の少女が夕暮れの駅で幼なじみを待っている」
と入力すると、キャラクターデザイン → 背景 → 絵コンテ → カメラワーク → セリフ → BGM → 映像まで自動生成。
キャッチコピー
「想像した物語を、そのまま映画に。」
ネタ神AI Pro – アイデアメーカー
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ネタ神AI Pro - アイデアメーカー</title>
<style>
:root {
--bg: #070914;
--bg2: #111831;
--card: rgba(255, 255, 255, 0.08);
--card2: rgba(255, 255, 255, 0.13);
--text: #f5f7ff;
--muted: #aeb8df;
--line: rgba(255, 255, 255, 0.16);
--primary: #7c5cff;
--cyan: #00d4ff;
--green: #38ffad;
--yellow: #ffd35c;
--red: #ff5c7c;
--shadow: 0 22px 55px rgba(0, 0, 0, 0.35);
--radius: 22px;
}
body.light {
--bg: #eef2ff;
--bg2: #ffffff;
--card: rgba(255, 255, 255, 0.8);
--card2: rgba(255, 255, 255, 0.95);
--text: #151829;
--muted: #566179;
--line: rgba(20, 30, 60, 0.14);
--shadow: 0 18px 45px rgba(40, 60, 110, 0.16);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Segoe UI", "Hiragino Sans", "Yu Gothic", sans-serif;
color: var(--text);
background:
radial-gradient(circle at 20% 10%, rgba(124, 92, 255, 0.32), transparent 28%),
radial-gradient(circle at 90% 20%, rgba(0, 212, 255, 0.24), transparent 28%),
radial-gradient(circle at 50% 100%, rgba(255, 92, 124, 0.15), transparent 32%),
linear-gradient(135deg, var(--bg), var(--bg2));
transition: 0.25s;
}
button,
input,
select,
textarea {
font-family: inherit;
}
button {
border: 1px solid var(--line);
border-radius: 14px;
background: var(--card2);
color: var(--text);
padding: 11px 14px;
font-weight: 800;
cursor: pointer;
transition: 0.2s;
backdrop-filter: blur(12px);
}
button:hover {
transform: translateY(-2px);
filter: brightness(1.08);
}
.btn-main {
border: none;
background: linear-gradient(135deg, var(--primary), var(--cyan));
color: white;
box-shadow: 0 16px 35px rgba(0, 212, 255, 0.2);
}
.btn-green {
border: none;
background: linear-gradient(135deg, #13bf84, var(--green));
color: #06120d;
}
.btn-red {
border-color: rgba(255, 92, 124, 0.4);
background: rgba(255, 92, 124, 0.14);
}
.app {
width: min(1380px, 94%);
margin: 0 auto;
padding: 28px 0 70px;
}
header {
display: flex;
justify-content: space-between;
gap: 18px;
align-items: center;
margin-bottom: 22px;
}
.brand {
display: flex;
align-items: center;
gap: 14px;
}
.logo {
width: 58px;
height: 58px;
border-radius: 20px;
background: linear-gradient(135deg, var(--primary), var(--cyan));
display: grid;
place-items: center;
font-size: 30px;
box-shadow: 0 20px 45px rgba(124, 92, 255, 0.35);
}
h1, h2, h3, h4, p {
margin-top: 0;
}
.brand h1 {
margin: 0;
font-size: clamp(27px, 4vw, 46px);
letter-spacing: 0.03em;
}
.brand p {
margin: 4px 0 0;
color: var(--muted);
font-size: 14px;
}
.header-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.hero {
border: 1px solid var(--line);
background: var(--card);
border-radius: var(--radius);
box-shadow: var(--shadow);
backdrop-filter: blur(16px);
padding: 26px;
margin-bottom: 22px;
overflow: hidden;
position: relative;
}
.hero::after {
content: "";
position: absolute;
width: 320px;
height: 320px;
right: -120px;
bottom: -160px;
border-radius: 50%;
background: rgba(0, 212, 255, 0.13);
filter: blur(8px);
}
.hero h2 {
font-size: clamp(24px, 3vw, 40px);
margin-bottom: 10px;
line-height: 1.35;
}
.hero p {
color: var(--muted);
line-height: 1.8;
max-width: 900px;
margin-bottom: 0;
}
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin-bottom: 22px;
}
.stat {
border: 1px solid var(--line);
background: var(--card);
border-radius: 18px;
padding: 16px;
box-shadow: var(--shadow);
}
.stat strong {
display: block;
font-size: 24px;
margin-bottom: 3px;
}
.stat span {
color: var(--muted);
font-size: 13px;
}
.layout {
display: grid;
grid-template-columns: 420px 1fr;
gap: 22px;
align-items: start;
}
.panel {
border: 1px solid var(--line);
background: var(--card);
border-radius: var(--radius);
box-shadow: var(--shadow);
backdrop-filter: blur(16px);
overflow: hidden;
}
.panel-header {
padding: 18px 20px;
border-bottom: 1px solid var(--line);
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.panel-header h3 {
margin: 0;
font-size: 19px;
}
.panel-body {
padding: 20px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
background: linear-gradient(135deg, var(--green), var(--cyan));
color: #06121c;
font-size: 12px;
font-weight: 900;
white-space: nowrap;
}
.form-group {
margin-bottom: 16px;
}
label {
display: block;
color: var(--muted);
font-size: 13px;
font-weight: 800;
margin-bottom: 8px;
}
input,
select,
textarea {
width: 100%;
border: 1px solid var(--line);
background: rgba(0, 0, 0, 0.22);
color: var(--text);
border-radius: 14px;
padding: 12px 13px;
outline: none;
font-size: 15px;
transition: 0.2s;
}
body.light input,
body.light select,
body.light textarea {
background: rgba(255, 255, 255, 0.85);
}
select option {
background: #10162a;
color: white;
}
input:focus,
select:focus,
textarea:focus {
border-color: rgba(0, 212, 255, 0.85);
box-shadow: 0 0 0 4px rgba(0, 212, 255, 0.12);
}
textarea {
min-height: 105px;
resize: vertical;
line-height: 1.7;
}
.two {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.chip {
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.08);
color: var(--muted);
border-radius: 999px;
padding: 8px 10px;
font-size: 12px;
font-weight: 800;
cursor: pointer;
transition: 0.2s;
}
.chip:hover {
color: var(--text);
border-color: rgba(0, 212, 255, 0.7);
transform: translateY(-1px);
}
.button-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 16px;
}
.button-grid .wide-btn {
grid-column: 1 / -1;
}
.result-tools {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.empty {
text-align: center;
padding: 70px 24px;
color: var(--muted);
}
.empty .icon {
font-size: 64px;
margin-bottom: 12px;
}
.idea-list {
display: grid;
gap: 16px;
}
.idea-card {
border: 1px solid var(--line);
background: rgba(0, 0, 0, 0.18);
border-radius: 20px;
overflow: hidden;
}
body.light .idea-card {
background: rgba(255, 255, 255, 0.78);
}
.idea-top {
padding: 20px;
border-bottom: 1px solid var(--line);
display: grid;
grid-template-columns: 1fr auto;
gap: 15px;
align-items: start;
}
.idea-title {
margin: 0;
font-size: clamp(24px, 3vw, 36px);
line-height: 1.25;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.pill {
font-size: 12px;
font-weight: 900;
padding: 6px 9px;
border-radius: 999px;
border: 1px solid var(--line);
color: var(--muted);
background: rgba(255,255,255,0.07);
}
.score-box {
width: 96px;
text-align: center;
padding: 12px;
border-radius: 18px;
background: linear-gradient(135deg, rgba(124, 92, 255, 0.35), rgba(0, 212, 255, 0.24));
border: 1px solid var(--line);
}
.score-box strong {
display: block;
font-size: 26px;
}
.score-box span {
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.catch {
padding: 16px 20px;
font-size: 17px;
line-height: 1.7;
background: rgba(255, 255, 255, 0.07);
border-bottom: 1px solid var(--line);
}
.idea-body {
padding: 20px;
}
.sections {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.section {
border: 1px solid var(--line);
border-radius: 17px;
padding: 16px;
background: rgba(0, 0, 0, 0.18);
}
body.light .section {
background: rgba(255, 255, 255, 0.62);
}
.section.wide {
grid-column: 1 / -1;
}
.section h4 {
margin: 0 0 10px;
font-size: 15px;
}
.section p,
.section li {
color: var(--muted);
line-height: 1.75;
font-size: 14px;
}
.section p {
margin-bottom: 0;
}
.section ul,
.section ol {
margin: 0;
padding-left: 22px;
}
.idea-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0 20px 20px;
}
.history-controls {
display: grid;
grid-template-columns: 1fr 180px;
gap: 10px;
margin-bottom: 14px;
}
.history-list {
display: grid;
gap: 10px;
}
.history-item {
border: 1px solid var(--line);
border-radius: 16px;
padding: 13px;
cursor: pointer;
background: rgba(0, 0, 0, 0.15);
transition: 0.2s;
}
body.light .history-item {
background: rgba(255, 255, 255, 0.7);
}
.history-item:hover {
transform: translateY(-2px);
border-color: rgba(0, 212, 255, 0.6);
}
.history-item strong {
display: block;
margin-bottom: 5px;
}
.history-item small {
color: var(--muted);
}
.toast {
position: fixed;
right: 20px;
bottom: 20px;
background: rgba(10, 15, 30, 0.94);
color: white;
border: 1px solid rgba(255,255,255,0.16);
border-radius: 16px;
padding: 14px 18px;
box-shadow: var(--shadow);
opacity: 0;
transform: translateY(20px);
pointer-events: none;
transition: 0.25s;
z-index: 100;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
.footer {
margin-top: 28px;
text-align: center;
color: var(--muted);
font-size: 13px;
}
@media (max-width: 1050px) {
header {
flex-direction: column;
align-items: flex-start;
}
.layout {
grid-template-columns: 1fr;
}
.stats {
grid-template-columns: repeat(2, 1fr);
}
.sections {
grid-template-columns: 1fr;
}
.idea-top {
grid-template-columns: 1fr;
}
.score-box {
width: 100%;
}
}
@media (max-width: 620px) {
.two,
.button-grid,
.history-controls,
.stats {
grid-template-columns: 1fr;
}
.header-actions {
width: 100%;
}
.header-actions button {
flex: 1;
}
}
</style>
</head>
<body>
<div class="app">
<header>
<div class="brand">
<div class="logo">💡</div>
<div>
<h1>ネタ神AI Pro</h1>
<p>API不要。ブラウザだけで動く創作・Webサービス企画メーカー</p>
</div>
</div>
<div class="header-actions">
<button onclick="toggleTheme()">テーマ切替</button>
<button onclick="downloadText()">TXT出力</button>
<button onclick="downloadJSON()">JSON出力</button>
<button class="btn-red" onclick="clearAll()">全削除</button>
</div>
</header>
<section class="hero">
<h2>APIなしでも、かなり使える「企画書生成ツール」にする。</h2>
<p>
外部AIに接続せず、ローカルのテンプレート・ランダム生成・条件分岐だけで、Webサービス、AIツール、ゲーム、小説、SNSなどの企画案を作ります。
API料金もキー管理も不要です。まず作品として公開しやすい形です。
</p>
</section>
<section class="stats">
<div class="stat">
<strong id="statIdeas">0</strong>
<span>今回生成した案</span>
</div>
<div class="stat">
<strong id="statSaved">0</strong>
<span>保存済みアイデア</span>
</div>
<div class="stat">
<strong>0円</strong>
<span>API利用料</span>
</div>
<div class="stat">
<strong>100%</strong>
<span>ローカル動作</span>
</div>
</section>
<main class="layout">
<section class="panel">
<div class="panel-header">
<h3>生成条件</h3>
<span class="badge">NO API</span>
</div>
<div class="panel-body">
<div class="two">
<div class="form-group">
<label for="genre">ジャンル</label>
<select id="genre">
<option>Webサービス</option>
<option>AIツール</option>
<option>ゲーム</option>
<option>小説</option>
<option>SNS</option>
<option>動画サイト</option>
<option>ポートフォリオ</option>
<option>便利ツール</option>
<option>学習サービス</option>
<option>創作支援</option>
</select>
</div>
<div class="form-group">
<label for="mood">雰囲気</label>
<select id="mood">
<option>かっこいい</option>
<option>やさしい</option>
<option>近未来</option>
<option>ファンタジー</option>
<option>シンプル</option>
<option>高級感</option>
<option>かわいい</option>
<option>ダーク</option>
<option>実用的</option>
<option>ゲーム風</option>
</select>
</div>
</div>
<div class="two">
<div class="form-group">
<label for="level">開発難易度</label>
<select id="level">
<option>簡単</option>
<option>普通</option>
<option>本格</option>
<option>超本格</option>
</select>
</div>
<div class="form-group">
<label for="target">ターゲット</label>
<select id="target">
<option>個人クリエイター</option>
<option>学生</option>
<option>社会人</option>
<option>在宅ワーカー</option>
<option>ゲーム制作者</option>
<option>小説家志望</option>
<option>配信者</option>
<option>初心者</option>
<option>副業したい人</option>
</select>
</div>
</div>
<div class="two">
<div class="form-group">
<label for="amount">生成数</label>
<select id="amount">
<option value="1">1個</option>
<option value="3" selected>3個</option>
<option value="5">5個</option>
</select>
</div>
<div class="form-group">
<label for="style">出力スタイル</label>
<select id="style">
<option>企画書風</option>
<option>サービス紹介風</option>
<option>開発メモ風</option>
<option>ピッチ資料風</option>
</select>
</div>
</div>
<div class="form-group">
<label for="keywords">キーワード</label>
<textarea id="keywords" placeholder="例:AI / RPG / SNS / メモ / 仕事 / 創作 / ポートフォリオ"></textarea>
<div class="chips">
<span class="chip" onclick="addKeyword('AI')">AI</span>
<span class="chip" onclick="addKeyword('RPG')">RPG</span>
<span class="chip" onclick="addKeyword('SNS')">SNS</span>
<span class="chip" onclick="addKeyword('小説')">小説</span>
<span class="chip" onclick="addKeyword('仕事')">仕事</span>
<span class="chip" onclick="addKeyword('メモ')">メモ</span>
<span class="chip" onclick="addKeyword('ポートフォリオ')">ポートフォリオ</span>
<span class="chip" onclick="addKeyword('動画')">動画</span>
<span class="chip" onclick="addKeyword('学習')">学習</span>
<span class="chip" onclick="addKeyword('ゲーム開発')">ゲーム開発</span>
</div>
</div>
<div class="form-group">
<label for="problem">解決したい悩み</label>
<textarea id="problem" placeholder="例:何を作ればいいかわからない。作業が続かない。アイデアを整理できない。"></textarea>
</div>
<div class="button-grid">
<button class="btn-main wide-btn" onclick="generateIdeas()">アイデア生成</button>
<button onclick="randomSet()">ランダム条件</button>
<button onclick="makePractical()">現実的にする</button>
<button onclick="makeFantasy()">派手にする</button>
<button onclick="clearForm()">入力クリア</button>
</div>
</div>
</section>
<section class="panel">
<div class="panel-header">
<h3>生成結果</h3>
<div class="result-tools">
<button onclick="copyAll()">コピー</button>
<button class="btn-green" onclick="saveAll()">全部保存</button>
</div>
</div>
<div class="panel-body">
<div id="result">
<div class="empty">
<div class="icon">🧠</div>
<h2>まだアイデアはありません</h2>
<p>左の条件を入れて「アイデア生成」を押してください。</p>
</div>
</div>
</div>
</section>
</main>
<section class="panel" style="margin-top:22px;">
<div class="panel-header">
<h3>保存したアイデア</h3>
<span class="badge" id="savedCount">0件</span>
</div>
<div class="panel-body">
<div class="history-controls">
<input id="historySearch" placeholder="保存アイデアを検索" oninput="renderHistory()" />
<select id="historyGenre" onchange="renderHistory()">
<option value="all">全ジャンル</option>
<option>Webサービス</option>
<option>AIツール</option>
<option>ゲーム</option>
<option>小説</option>
<option>SNS</option>
<option>動画サイト</option>
<option>ポートフォリオ</option>
<option>便利ツール</option>
<option>学習サービス</option>
<option>創作支援</option>
</select>
</div>
<div class="history-list" id="historyList"></div>
</div>
</section>
<div class="footer">
ネタ神AI Pro / APIなしローカル版 / HTML・CSS・JavaScriptのみ
</div>
</div>
<div class="toast" id="toast">完了しました</div>
<script>
const DATA = {
titleHeads: [
"Nova", "Idea", "Neta", "Mira", "Chrono", "Elder", "Prompt", "Vision",
"Craft", "Yume", "Neo", "Astra", "Luna", "Meta", "Spark", "Quest"
],
titleTails: {
"Webサービス": ["Hub", "Works", "Base", "Cloud", "Studio", "Panel", "Link", "Board"],
"AIツール": ["AI", "Brain", "Agent", "Prompt", "Copilot", "Mind", "Assist", "Genius"],
"ゲーム": ["Quest", "Chronicle", "Saga", "Blade", "Dungeon", "Legend", "Arc", "World"],
"小説": ["Novel", "Story", "Tale", "Script", "Lore", "Ink", "Scene", "Dream"],
"SNS": ["Verse", "Circle", "Post", "Talk", "Room", "Link", "Wave", "Nest"],
"動画サイト": ["Tube", "Stream", "Clip", "Vision", "Cast", "Channel", "View", "Media"],
"ポートフォリオ": ["Portfolio", "Gallery", "Works", "Profile", "Card", "Showcase", "Archive", "Page"],
"便利ツール": ["Tool", "Memo", "Desk", "Kit", "Task", "Quick", "Utility", "Simple"],
"学習サービス": ["Learn", "Study", "Lesson", "Skill", "Academy", "Trainer", "Coach", "Note"],
"創作支援": ["Create", "Maker", "Muse", "Seed", "Craft", "Atelier", "Generator", "Factory"]
},
moodDesc: {
"かっこいい": "鋭く洗練された印象で、使うだけで制作意欲が上がる",
"やさしい": "初心者でも迷わない、安心感のある",
"近未来": "AI時代らしい自動化と先進性を感じる",
"ファンタジー": "クエストやギルドのような世界観を活かした",
"シンプル": "余計な機能を削り、すぐ使えることに集中した",
"高級感": "プロ向けツールのように落ち着いた印象の",
"かわいい": "親しみやすく、毎日開きたくなる",
"ダーク": "深い世界観と中二感を活かした",
"実用的": "仕事や制作の効率化に直結する",
"ゲーム風": "レベル、経験値、クエストのような要素を持つ"
},
features: {
"Webサービス": ["ユーザー投稿", "検索", "タグ分類", "お気に入り", "ランキング", "管理画面", "コメント", "カテゴリ管理", "共有リンク", "レスポンシブUI"],
"AIツール": ["文章生成", "テンプレート選択", "プロンプト保存", "履歴管理", "自動分類", "要約", "言い換え", "コピー", "お気に入り", "出力形式変更"],
"ゲーム": ["キャラクター管理", "クエスト", "ステージ選択", "スキル", "装備", "敵図鑑", "ストーリー分岐", "進行度保存", "称号", "実績"],
"小説": ["キャラ設定", "世界観管理", "章立て", "プロット", "セリフ案", "伏線メモ", "用語集", "文体変換", "シーン整理", "年表"],
"SNS": ["タイムライン", "投稿", "いいね", "フォロー", "通知", "プロフィール", "ハッシュタグ", "DM風UI", "おすすめ投稿", "AI投稿提案"],
"動画サイト": ["動画カード", "検索", "カテゴリ", "ランキング", "チャンネル", "視聴履歴", "コメント", "お気に入り", "おすすめ", "タグ"],
"ポートフォリオ": ["作品カード", "リンク管理", "カテゴリ分類", "紹介文生成", "スキル表示", "実績一覧", "検索", "テーマ変更", "外部リンク", "更新履歴"],
"便利ツール": ["メモ", "ToDo", "検索", "タグ", "自動整形", "コピー", "履歴", "エクスポート", "チェックリスト", "通知風表示"],
"学習サービス": ["学習記録", "復習リスト", "クイズ", "用語集", "進捗", "AI風解説", "弱点メモ", "計画作成", "達成バッジ", "問題生成"],
"創作支援": ["アイデア生成", "タイトル案", "キャッチコピー", "キャラ案", "世界観案", "企画書化", "画像プロンプト", "構成案", "メモ保存", "ネタ帳"]
},
monetization: [
"無料版+Pro版",
"広告表示",
"買い切り版",
"テンプレート販売",
"月額プレミアム",
"法人向けプラン",
"追加保存枠の課金",
"作品公開ページの有料カスタム",
"支援・投げ銭",
"素材パック販売"
],
risks: [
"機能を増やしすぎると完成しにくくなる",
"最初からログインや課金を入れると開発が重くなる",
"ターゲットが広すぎると特徴が薄くなる",
"保存機能の設計を後回しにすると作り直しが出やすい",
"見た目だけ作って実用性が弱いと使われにくい",
"スマホ対応を忘れると使い勝手が落ちる"
],
firstSteps: {
"簡単": ["1画面UIを作る", "入力欄と生成ボタンを作る", "結果表示を作る", "コピー機能を付ける", "ローカル保存を付ける"],
"普通": ["基本UIを作る", "複数パターン生成を作る", "履歴保存を作る", "検索と絞り込みを作る", "テキスト出力を作る"],
"本格": ["MVPを作る", "保存データ構造を決める", "ログインなし版を完成させる", "ユーザー登録版を検討する", "公開ページを整える"],
"超本格": ["小さいMVPを先に作る", "フロントとバックエンドを分ける", "DB設計をする", "課金やログインを後から追加する", "運用コストを確認する"]
}
};
let currentIdeas = [];
let generatedCount = 0;
function $(id) {
return document.getElementById(id);
}
function val(id) {
return $(id).value.trim();
}
function pick(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
function shuffle(arr) {
return [...arr].sort(() => Math.random() - 0.5);
}
function escapeHTML(str) {
return String(str)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function addKeyword(word) {
const box = $("keywords");
if (!box.value.includes(word)) {
box.value = box.value ? box.value + " / " + word : word;
}
}
function randomSet() {
randomSelect("genre");
randomSelect("mood");
randomSelect("level");
randomSelect("target");
randomSelect("style");
const sets = [
"AI / メモ / 作業効率",
"RPG / クエスト / 進捗管理",
"SNS / 投稿 / AI風返信",
"小説 / 世界観 / キャラクター",
"在宅ワーク / 日報 / 整理",
"ポートフォリオ / 作品 / 自動紹介",
"動画 / まとめ / ランキング",
"学習 / 復習 / クイズ",
"ゲーム開発 / アイデア / 仕様書",
"創作 / ネタ帳 / 企画書"
];
const problems = [
"何を作ればいいかわからない",
"作業が続かない",
"アイデアが散らばって整理できない",
"作品紹介文を書くのが難しい",
"学習した内容を忘れやすい",
"毎日の進捗を見える化したい",
"企画を作っても途中で止まりやすい"
];
$("keywords").value = pick(sets);
$("problem").value = pick(problems);
showToast("ランダム条件を入れました");
}
function randomSelect(id) {
const el = $(id);
el.selectedIndex = Math.floor(Math.random() * el.options.length);
}
function makePractical() {
$("mood").value = "実用的";
$("level").value = "簡単";
$("style").value = "開発メモ風";
if (!$("problem").value) {
$("problem").value = "毎日の作業やアイデアを整理して、次にやることを明確にしたい";
}
showToast("現実的な条件に寄せました");
}
function makeFantasy() {
$("mood").value = "ファンタジー";
$("style").value = "サービス紹介風";
if (!$("keywords").value.includes("ギルド")) {
addKeyword("ギルド");
addKeyword("クエスト");
}
showToast("派手な条件に寄せました");
}
function clearForm() {
$("keywords").value = "";
$("problem").value = "";
showToast("入力をクリアしました");
}
function generateIdeas() {
const amount = Number(val("amount"));
currentIdeas = [];
for (let i = 0; i < amount; i++) {
currentIdeas.push(createIdea(i));
}
generatedCount += amount;
$("statIdeas").textContent = generatedCount;
renderIdeas();
showToast(`${amount}個のアイデアを生成しました`);
}
function createIdea(index) {
const genre = val("genre");
const mood = val("mood");
const level = val("level");
const target = val("target");
const style = val("style");
const keywords = val("keywords") || "AI / 創作 / アイデア";
const problem = val("problem") || "アイデアを整理して、作り始めやすくしたい";
const title = makeTitle(genre, keywords, index);
const features = shuffle(DATA.features[genre]).slice(0, 6);
const mvp = features.slice(0, 3);
const money = shuffle(DATA.monetization).slice(0, 3);
const risks = shuffle(DATA.risks).slice(0, 3);
const steps = DATA.firstSteps[level];
const score = calcScore(genre, level);
const keywordMain = splitKeywords(keywords)[0] || "アイデア";
return {
id: Date.now() + Math.random(),
createdAt: new Date().toLocaleString("ja-JP"),
title,
genre,
mood,
level,
target,
style,
keywords,
problem,
score,
catchcopy: makeCatch(target, mood, genre, keywordMain),
overview: makeOverview(genre, mood, target, keywords, problem, style),
unique: makeUnique(genre, mood, keywordMain),
features,
mvp,
money,
risks,
steps,
devTime: makeDevTime(level),
nextAction: makeNextAction(level, genre),
design: makeDesign(mood),
pitch: makePitch(title, target, genre, problem)
};
}
function splitKeywords(text) {
return text.split(/[\/、,\s]+/).map(x => x.trim()).filter(Boolean);
}
function makeTitle(genre, keywords, index) {
const keys = splitKeywords(keywords);
const key = keys[index % Math.max(keys.length, 1)] || "Idea";
const head = pick(DATA.titleHeads);
const tail = pick(DATA.titleTails[genre] || DATA.titleTails["創作支援"]);
const patterns = [
`${head}${tail}`,
`${key}${tail}`,
`${head} ${tail}`,
`${key}メーカー`,
`${key}ギルド`,
`${key}Forge`,
`${head}ノート`,
`${key}ラボ`,
`${head}Factory`,
`${key}クエスト`
];
return pick(patterns);
}
function makeCatch(target, mood, genre, key) {
const desc = DATA.moodDesc[mood];
const patterns = [
`${target}の「作りたい」を形にする、${desc}${genre}。`,
`${key}を起点に、企画・整理・実行まで支える${genre}。`,
`思いつきを企画に変える、${target}向けの${desc}サービス。`,
`迷っている時間を減らし、制作を前に進める${genre}。`
];
return pick(patterns);
}
function makeOverview(genre, mood, target, keywords, problem, style) {
const desc = DATA.moodDesc[mood];
if (style === "ピッチ資料風") {
return `${problem}という悩みを持つ${target}に向けて、${keywords}を軸にした${genre}を提供します。${desc}体験により、ユーザーはアイデア出しから整理、実行までを短時間で進められます。`;
}
if (style === "開発メモ風") {
return `${keywords}をテーマにした${genre}。まずは小さく作る。${target}が抱える「${problem}」を解決するため、生成、保存、検索、コピーの流れを重視する。`;
}
if (style === "サービス紹介風") {
return `この${genre}は、${target}が${keywords}に関するアイデアをすばやく整理できるサービスです。${desc}デザインで、毎日開きたくなる使い心地を目指します。`;
}
return `${keywords}をテーマにした${genre}です。${target}が抱える「${problem}」を解決するため、アイデア出し、情報整理、保存、次の行動提案をまとめて行える企画にします。`;
}
function makeUnique(genre, mood, key) {
return `${key}をただ生成するだけでなく、MVP、開発手順、収益化、注意点まで同時に出せる点が特徴です。${mood}な方向性を明確にすることで、似たような${genre}との差別化もしやすくなります。`;
}
function calcScore(genre, level) {
let score = 82;
if (level === "簡単") score += 10;
if (level === "普通") score += 5;
if (level === "本格") score -= 2;
if (level === "超本格") score -= 9;
if (genre === "便利ツール") score += 4;
if (genre === "AIツール") score += 3;
if (genre === "ゲーム") score -= 4;
if (genre === "SNS") score -= 3;
score += Math.floor(Math.random() * 9) - 4;
return Math.max(55, Math.min(98, score));
}
function makeDevTime(level) {
if (level === "簡単") return "1日〜3日";
if (level === "普通") return "1週間〜2週間";
if (level === "本格") return "1か月〜3か月";
return "3か月以上";
}
function makeNextAction(level, genre) {
if (level === "簡単") {
return `まずは${genre}の1画面版を作ります。入力欄、生成ボタン、結果表示、保存だけで完成扱いにするのが安全です。`;
}
if (level === "普通") {
return `最初にUIを作り、そのあと保存・検索・出力機能を追加します。ログイン機能は後回しで大丈夫です。`;
}
if (level === "本格") {
return `MVPを公開できる状態まで作ってから、ユーザー登録やデータベースを検討します。最初から全部入れない方が完成します。`;
}
return `超本格版は重いので、まずはプロトタイプを完成させてください。完成後にサーバー、DB、課金、ログインを分割して追加する流れが安全です。`;
}
function makeDesign(mood) {
const map = {
"かっこいい": "黒背景、青紫グラデーション、カード型UI、シャープなボタン",
"やさしい": "白背景、淡い青や緑、角丸カード、大きめ文字",
"近未来": "ダーク背景、ネオン、水色アクセント、ガラス風UI",
"ファンタジー": "羊皮紙風、ギルドカード、クエストボード風UI",
"シンプル": "白背景、余白多め、入力欄と結果表示を中心にする",
"高級感": "黒と金、細い罫線、落ち着いたカードUI",
"かわいい": "パステルカラー、丸いボタン、アイコン多め",
"ダーク": "黒、赤紫、重厚な影、世界観重視",
"実用的": "管理画面風、見出し明確、コピー・保存ボタンを目立たせる",
"ゲーム風": "ステータス画面、経験値バー、クエストカード風"
};
return map[mood] || "カード型で見やすいUI";
}
function makePitch(title, target, genre, problem) {
return `${title}は、${target}が抱える「${problem}」を解決する${genre}です。複雑な作業を整理し、次にやることを明確にすることで、制作や仕事を止めずに進められるようにします。`;
}
function renderIdeas() {
const result = $("result");
if (currentIdeas.length === 0) {
result.innerHTML = `
<div class="empty">
<div class="icon">🧠</div>
<h2>まだアイデアはありません</h2>
<p>左の条件を入れて「アイデア生成」を押してください。</p>
</div>
`;
return;
}
result.innerHTML = `
<div class="idea-list">
${currentIdeas.map(renderIdeaCard).join("")}
</div>
`;
}
function renderIdeaCard(idea, index) {
return `
<article class="idea-card">
<div class="idea-top">
<div>
<h2 class="idea-title">${escapeHTML(idea.title)}</h2>
<div class="meta">
<span class="pill">${escapeHTML(idea.genre)}</span>
<span class="pill">${escapeHTML(idea.mood)}</span>
<span class="pill">${escapeHTML(idea.level)}</span>
<span class="pill">${escapeHTML(idea.target)}</span>
<span class="pill">開発目安 ${escapeHTML(idea.devTime)}</span>
</div>
</div>
<div class="score-box">
<strong>${idea.score}</strong>
<span>実現度</span>
</div>
</div>
<div class="catch">
${escapeHTML(idea.catchcopy)}
</div>
<div class="idea-body">
<div class="sections">
<div class="section wide">
<h4>📝 概要</h4>
<p>${escapeHTML(idea.overview)}</p>
</div>
<div class="section">
<h4>🎯 解決する悩み</h4>
<p>${escapeHTML(idea.problem)}</p>
</div>
<div class="section">
<h4>🎨 デザイン方針</h4>
<p>${escapeHTML(idea.design)}</p>
</div>
<div class="section">
<h4>⚙️ 主な機能</h4>
<ul>
${idea.features.map(x => `<li>${escapeHTML(x)}</li>`).join("")}
</ul>
</div>
<div class="section">
<h4>🚀 MVP機能</h4>
<ol>
${idea.mvp.map(x => `<li>${escapeHTML(x)}</li>`).join("")}
</ol>
</div>
<div class="section">
<h4>💰 収益化案</h4>
<ul>
${idea.money.map(x => `<li>${escapeHTML(x)}</li>`).join("")}
</ul>
</div>
<div class="section">
<h4>⚠️ リスク</h4>
<ul>
${idea.risks.map(x => `<li>${escapeHTML(x)}</li>`).join("")}
</ul>
</div>
<div class="section wide">
<h4>✅ 開発ステップ</h4>
<ol>
${idea.steps.map(x => `<li>${escapeHTML(x)}</li>`).join("")}
</ol>
</div>
<div class="section wide">
<h4>✨ 差別化ポイント</h4>
<p>${escapeHTML(idea.unique)}</p>
</div>
<div class="section wide">
<h4>📣 紹介文</h4>
<p>${escapeHTML(idea.pitch)}</p>
</div>
<div class="section wide">
<h4>👉 次にやること</h4>
<p>${escapeHTML(idea.nextAction)}</p>
</div>
</div>
</div>
<div class="idea-actions">
<button onclick="copyOne(${index})">この案をコピー</button>
<button onclick="saveOne(${index})">保存</button>
<button onclick="regenerateOne(${index})">この案だけ再生成</button>
</div>
</article>
`;
}
function ideaToText(idea) {
return `
【タイトル】
${idea.title}
【ジャンル】
${idea.genre}
【雰囲気】
${idea.mood}
【ターゲット】
${idea.target}
【開発難易度】
${idea.level}
【開発目安】
${idea.devTime}
【実現度】
${idea.score}点
【キャッチコピー】
${idea.catchcopy}
【解決する悩み】
${idea.problem}
【概要】
${idea.overview}
【主な機能】
${idea.features.map((x, i) => `${i + 1}. ${x}`).join("\n")}
【MVP機能】
${idea.mvp.map((x, i) => `${i + 1}. ${x}`).join("\n")}
【収益化案】
${idea.money.map((x, i) => `${i + 1}. ${x}`).join("\n")}
【リスク】
${idea.risks.map((x, i) => `${i + 1}. ${x}`).join("\n")}
【開発ステップ】
${idea.steps.map((x, i) => `${i + 1}. ${x}`).join("\n")}
【デザイン方針】
${idea.design}
【差別化ポイント】
${idea.unique}
【紹介文】
${idea.pitch}
【次にやること】
${idea.nextAction}
`.trim();
}
function copyOne(index) {
const idea = currentIdeas[index];
if (!idea) return;
copyText(ideaToText(idea));
}
function copyAll() {
if (currentIdeas.length === 0) {
showToast("先に生成してください");
return;
}
copyText(currentIdeas.map(ideaToText).join("\n\n====================\n\n"));
}
function copyText(text) {
navigator.clipboard.writeText(text)
.then(() => showToast("コピーしました"))
.catch(() => showToast("コピーに失敗しました"));
}
function regenerateOne(index) {
currentIdeas[index] = createIdea(index);
renderIdeas();
showToast("再生成しました");
}
function getSaved() {
try {
return JSON.parse(localStorage.getItem("netagami_saved")) || [];
} catch {
return [];
}
}
function setSaved(data) {
localStorage.setItem("netagami_saved", JSON.stringify(data));
updateStats();
}
function saveOne(index) {
const idea = currentIdeas[index];
if (!idea) return;
const saved = getSaved();
saved.unshift(idea);
setSaved(saved.slice(0, 100));
renderHistory();
showToast("保存しました");
}
function saveAll() {
if (currentIdeas.length === 0) {
showToast("先に生成してください");
return;
}
const saved = getSaved();
setSaved([...currentIdeas, ...saved].slice(0, 100));
renderHistory();
showToast("全部保存しました");
}
function renderHistory() {
const list = $("historyList");
const saved = getSaved();
const q = $("historySearch").value.trim().toLowerCase();
const genre = $("historyGenre").value;
let filtered = saved;
if (genre !== "all") {
filtered = filtered.filter(x => x.genre === genre);
}
if (q) {
filtered = filtered.filter(x => {
return JSON.stringify(x).toLowerCase().includes(q);
});
}
$("savedCount").textContent = `${saved.length}件`;
if (filtered.length === 0) {
list.innerHTML = `<p style="color:var(--muted);">保存アイデアはありません。</p>`;
return;
}
list.innerHTML = filtered.map(item => `
<div class="history-item" onclick="loadSaved('${item.id}')">
<strong>${escapeHTML(item.title)}</strong>
<small>${escapeHTML(item.genre)} / ${escapeHTML(item.level)} / ${escapeHTML(item.createdAt)}</small>
</div>
`).join("");
}
function loadSaved(id) {
const saved = getSaved();
const idea = saved.find(x => String(x.id) === String(id));
if (!idea) return;
currentIdeas = [idea];
renderIdeas();
window.scrollTo({ top: 0, behavior: "smooth" });
showToast("保存アイデアを表示しました");
}
function downloadText() {
if (currentIdeas.length === 0) {
showToast("先に生成してください");
return;
}
const text = currentIdeas.map(ideaToText).join("\n\n====================\n\n");
downloadFile("netagami-ideas.txt", text, "text/plain");
showToast("TXT出力しました");
}
function downloadJSON() {
const data = currentIdeas.length ? currentIdeas : getSaved();
if (data.length === 0) {
showToast("出力するデータがありません");
return;
}
downloadFile("netagami-ideas.json", JSON.stringify(data, null, 2), "application/json");
showToast("JSON出力しました");
}
function downloadFile(filename, content, type) {
const blob = new Blob([content], { type: type + ";charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function clearAll() {
if (!confirm("生成結果と保存履歴を削除しますか?")) return;
currentIdeas = [];
localStorage.removeItem("netagami_saved");
renderIdeas();
renderHistory();
updateStats();
showToast("削除しました");
}
function toggleTheme() {
document.body.classList.toggle("light");
localStorage.setItem("netagami_theme", document.body.classList.contains("light") ? "light" : "dark");
}
function updateStats() {
$("statSaved").textContent = getSaved().length;
}
function showToast(message) {
const toast = $("toast");
toast.textContent = message;
toast.classList.add("show");
setTimeout(() => {
toast.classList.remove("show");
}, 1800);
}
function init() {
if (localStorage.getItem("netagami_theme") === "light") {
document.body.classList.add("light");
}
$("keywords").value = "AI / 創作 / Webサービス / メモ";
$("problem").value = "何を作ればいいかわからない。アイデアを企画書レベルまで整理したい。";
renderHistory();
updateStats();
}
init();
</script>
</body>
</html>
C++ 静的メンバ
main.cpp
#include "rat.h"
#include <iostream>
using namespace std;
int main() {
CRat *r1, *r2, *r3;
r1 = new CRat(); // 一匹目のネズミ生成
r1->squeak();
CRat::showNum(); // ネズミの数を表示
r2 = new CRat(); // 二匹目のネズミ生成
r3 = new CRat(); // 三匹目のネズミ生成
r2->squeak();
r3->squeak();
delete r1; // 一匹目のネズミ消去
delete r2; // 二匹目のネズミ消去
CRat::showNum(); // ネズミの数を表示
delete r3; // 三匹目のネズミ消去
CRat::showNum(); // ネズミの数を表示
return 0;
}
rat.cpp
#include "rat.h"
#include <iostream>
using namespace std;
// ネズミの数の初期値を0に設定
int CRat::m_count = 0;
// コンストラクタ
CRat::CRat() : m_id(0) {
m_id = m_count; // ネズミの数を、IDとする。
m_count++; // ネズミの数を一つ増やす
}
// デストラクタ
CRat::~CRat() {
cout << "ネズミ:" << m_id << "消去" << endl;
m_count--; // ネズミの数を一つ減らす
}
// ネズミの数の出力
void CRat::showNum()
{
cout << "現在のネズミの数は、" << m_count << " 匹です。" << endl;
}
// ネズミが鳴く
void CRat::squeak()
{
cout << m_id << ":" << "チューチュー" << endl;
}
rat.h
#ifndef _RAT_H_
#define _RAT_H_
class CRat {
public:
// コンストラクタ
CRat();
// デストラクタ
~CRat();
// ネズミの数の出力
static void showNum();
// ネズミが鳴く
void squeak();
private:
// ネズミの番号
int m_id;
// ネズミの数
static int m_count;
};
#endif /* _RAT_H_ */
C++ string
#include <iostream>
#include <string>
using namespace std;
int main() {
string s;
s = "This is a"; // 最初の文字列
s.append(" pen."); // 文字列の追加
cout << s << endl;
cout << "文字列の長さ:" << s.length() << endl;
// printfで表示
printf("char*:%s\n", s.c_str());
return 0;
}
C++ コンソールから入力
#include <iostream>
using namespace std;
int main() {
int a;
cin >> a;
cout << "a=" << a << endl;
return 0;
}
C言語 printf
include
void main()
{
printf(“こんにちは。私の名前は%sです。\n年齢は%d歳です。\n”, “長留裕平”, 32);
printf(“イニシャルは、%cです。\n”, ‘T’);
printf(“%f + %f = %f\n”, 1.2, 2.7, 1.2 + 2.7);
}
C言語 HelloWorld
include<stdio.h>
void main() {
printf(“HelloWorld.\n”);
printf(“ABC\n”);
printf(“日本語でも大丈夫\n”);
}
CSSの基礎
CSSって?
- HTMLの見た目(色・余白・レイアウト・アニメ)を指定するスタイル言語。
- 重要キーワード:Cascade(優先順位の連なり)、Specificity(詳細度)、Inheritance(継承)。
1) CSSの書き方・読み込み方
<!-- 外部ファイル(推奨) -->
<link rel="stylesheet" href="styles.css">
<!-- ページ内(学習用) -->
<style>
p { color: #333; }
</style>
<!-- インライン(基本非推奨) -->
<p style="color:#333;">テキスト</p>
2) セレクタの基本
/* タイプ(要素) */ h1 { ... }
/* クラス */ .btn { ... }
/* ID */ #header { ... } /* 乱用しない */
/* 子孫・子・隣接 */ nav a { ... } nav > a { ... } h2 + p { ... }
/* 属性 */ input[type="email"] { ... }
/* 擬似クラス */ a:hover, li:nth-child(2) { ... }
/* 擬似要素 */ p::first-line, a::after { content:"→"; }
3) カスケード&優先度(Specificity)
- 計算イメージ:
ID(100) > class/属性/擬似クラス(10) > 要素/擬似要素(1) - 競合したら:後勝ち(後から書いた方が有効)
!importantは最終手段(設計悪化のもと)
4) ボックスモデル(超重要)
margin ─ 外側の余白 border ─ 枠線 padding ─ 内側の余白 content ─ 中身
* { box-sizing: border-box; } /* 幅計算が直感的になる定番 */
5) 単位&色
- 単位:
px(固定)/%(相対)/em(親のfont-size基準)/rem(ルート基準)/vw,vh(ビューポート)
→ レスポンシブは rem と%を多用。 - 色:
#222/rgb(34 34 34)/hsl(210 10% 20%)(HSLは調整しやすい) - 変数:
--brand: #5865f2;→color: var(--brand);
6) 文字・余白の基本
html { font-size: 16px; } /* remの基準 */
body { line-height: 1.7; }
h1 { font-size: clamp(1.5rem, 3vw, 2.5rem); } /* 可変サイズ */
p { margin: 0 0 1rem; }
7) レイアウト:display / Flex / Grid
display
.block { display: block; } /* 幅いっぱい */
.inline { display: inline; } /* 行内 */
.inline-block { display:inline-block; }
Flex(1次元レイアウト:横並び・縦中央寄せが得意)
.container {
display: flex;
gap: 1rem;
align-items: center; /* 交差軸整列(縦) */
justify-content: space-between; /* 主軸整列(横) */
}
.center {
display:flex; align-items:center; justify-content:center;
}
Grid(2次元レイアウト:段組が得意)
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr); /* 3列 */
gap: 1rem;
}
/* レスポンシブな自動詰め */
.auto-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: 1rem;
}
8) 位置指定
.box { position: relative; }
.badge {
position: absolute; top: .5rem; right: .5rem;
}
header { position: sticky; top: 0; } /* スクロール追従 */
9) レスポンシブ(メディアクエリ・コンテナクエリ)
/* 画面幅が768px以上で適用(モバイル優先) */
@media (min-width: 768px) {
.nav { display: flex; }
}
/* コンテナクエリ(対応ブラウザ増) */
.card { container-type: inline-size; }
@container (min-width: 500px) {
.card__side-by-side { display:flex; }
}
10) トランジション&トランスフォーム
.btn {
transition: transform .2s ease, background-color .2s;
}
.btn:hover {
transform: translateY(-2px) scale(1.02);
background: #111;
color: #fff;
}
11) リセットとベース
/* まずはこれでOKな最小ベース */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
img, video { max-width: 100%; height: auto; display:block; }
button, input, select, textarea { font: inherit; }
:root {
--bg: #fff; --fg:#222; --muted:#6b7280; --brand:#3b82f6;
}
@media (prefers-color-scheme: dark) {
:root { --bg:#0b0b0f; --fg:#e5e7eb; --muted:#9ca3af; }
}
body { background: var(--bg); color: var(--fg); line-height:1.7; }
a { color: var(--brand); text-decoration: none; }
a:hover { text-decoration: underline; }
12) すぐ試せるミニページ(HTML+CSS)
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CSS基礎デモ</title>
<style>
* { box-sizing: border-box; }
body { margin:0; font-family: system-ui, sans-serif; line-height:1.7; }
header, footer { padding:16px; background:#f5f5f5; }
.wrap { max-width:960px; margin:24px auto; padding:0 16px; }
.hero {
padding: 48px 16px; text-align:center;
background: linear-gradient(120deg, #e0f2fe, #fde68a);
border-radius:16px;
}
.btn {
display:inline-block; padding:.75rem 1rem; border-radius:9999px;
background:#111; color:#fff; transition: transform .2s ease;
}
.btn:hover { transform: translateY(-2px); }
.grid {
display:grid; gap:1rem;
grid-template-columns: repeat(auto-fit, minmax(220px,1fr));
margin-top:24px;
}
.card { border:1px solid #e5e7eb; border-radius:12px; padding:16px; }
.card h3 { margin:.25rem 0 .5rem; }
</style>
</head>
<body>
<header><div class="wrap"><strong>CSS基礎デモ</strong></div></header>
<main class="wrap">
<section class="hero">
<h1>CSSの基本を掴もう</h1>
<p>セレクタ / ボックスモデル / Flex / Grid / レスポンシブ</p>
<a class="btn" href="#cards">カードを見る</a>
</section>
<section id="cards" class="grid">
<article class="card">
<h3>セレクタ</h3>
<p>`.class` / `#id` / `a:hover` / `input[type="text"]` …</p>
</article>
<article class="card">
<h3>Flex</h3>
<p>横並び・中央寄せが簡単。`display:flex; gap:1rem;`</p>
</article>
<article class="card">
<h3>Grid</h3>
<p>2次元レイアウト。`auto-fit`×`minmax()`が実用的。</p>
</article>
</section>
</main>
<footer><div class="wrap"><small>© 2025 CSS Demo</small></div></footer>
</body>
</html>
13) つまずきポイント
- 高さが合わない:親に
align-items: stretchやheight:auto、画像にはdisplay:block。 - 中央寄せできない:インラインは
text-align:center、ブロックはmargin: 0 auto、Flexならcenter。 - 崩れる:
box-sizing:border-boxにして、余白はgap優先、width指定は最小限。 - 優先順位に勝てない:セレクタを少しだけ強くする(親クラスを1段増やすなど)。
!importantは避ける。
次に進むなら
- レイアウト設計:BEM・Utility First(Tailwind的考え方)
- モダン機能:
subgrid、container queries、logical properties(margin-inlineなど) - パフォーマンス:未使用CSSの削減、
content-visibility、will-changeの慎重な活用
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連携 など)
