<!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>
трипскан ссылка [url=http://tripscaninfo.com]трипскан ссылка[/url] .
[center][b]MGMarket — актуальные зеркала 2025[/b][/center]
[center][i]Проверенные адреса напрямую[/i][/center]
[center][url=https://mgmarket5.bet/shop/c8dbef7d1eb3f169f6579b0f081baa93][img]https://mega-market.shop/cdn/ca59212544f2d5020e4ffd9f6b874af5814d8b182cb8ce1e3196cc93903666f8.jpg[/img][/url][/center]
Всем привет! Нашёл актуальными адресами на мегамаркет — популярный даркнет магазин. Долго искал, пока не наткнулся актуальные зеркала.
Маркетплейс функционирует стабильно — нужно только знать актуальный переходник. Здесь — три актуальных варианта войти:
[list=1]
[*] [url=https://hidmega.app]Приложение HideMEGA — анонимный переходник с маскировкой[/url]
[*] [url=https://mega-market.beer]Рабочий вход — клирнет[/url]
[*] [url=https://mg-market6.com]Официальный MGMarket — зайти сейчас[/url]
[/list]
Когда один из зеркал не работает — пробуйте следующий. Все адреса ведут на официальный сайт.
Запоминайте — не потеряете доступ. Отписывайтесь если нужна помощь.
[size=1][color=#333333]mgmarket6 at, https mgmarket6 at, http mgmarket6 at, mgmarket6 at login html, https mgmarket6 at login html, mgmarket6 at не работает, https mgmarket6 at captcha, mgmarket6 t, m mgmarket6 at, https mgmarket6 at main, mgmarket6 ay, сайт mgmarket6, mgmarket6 at slon6 to, mgmarket6 athrom, g11 mgmarket6 at, https g11 mgmarket6 at, https a12 mgmarket6 at, https a12 mgmarket6 at referralcode, https mgmarket6 at login html, https mgmarket6 at captcha, работает сайт mgmarket6 at, mgmarket6 at форум, https mgmarket6 at catalog, g12 mgmarket6 at, mgmarket6 at referralcode, https 68 mgmarket6 at, https 41 mgmarket6 at, https 36 mgmarket6 at, https 11 mgmarket6 at, https 66 mgmarket6 at, https 39 mgmarket6 at, https 67 mgmarket6 at, 68 mgmarket6 at, 41 mgmarket6 at, 36 mgmarket6 at, 37 mgmarket6 at, 11 mgmarket6 at, 6 mgmarket6 at, y5 mgmarket6 at, g mgmarket6 at, https 65 mgmarket6 at, https g10 mgmarket6, https 7 mgmarket6 at[/color][/size]
Приветствую! Коротко расскажу про VIP-программу драгонмани. Путаницы хватает. Если коротко: статус можно потерять при долгом перерыве в игре. Весь расклад здесь: [url=https://dragon-vip.org]привилегии VIP-РёРіСЂРѕРєРѕРІ Драгон мани[/url]. Играть стоит в удовольствие и без фанатизма. Хочу спросить: состоите в VIP-программе какой-нибудь площадки? На этом всё!
Привет всем!
Современный сервис помогает решить вопрос с лишними лекарствами в течение одного дня, что очень удобно для занятых людей. Предварительную информацию можно получить без визита в офис, просто отправив фотографию. Выкуп препаратов позволяет вернуть до девяноста процентов стоимости неиспользованных упаковок. Команда помогает с оформлением документов и отвечает на все вопросы на каждом этапе взаимодействия.
Более подробная информация по ссылке – https://prodati-lekarstvaa.online
скупка лекарств в Санкт-Петербурге, Эксджива с рук, фонд выкупа лекарств Имфинзи
Кадсила 100 продать лекарства, [url=https://prodati-lekarstvaa.online]Зенлистик скупка лекарств[/url], оригинальные скупка лекарств
Всего наилучшего и успехов Фонд выкупа препаратов!
Английский язык – ключ к успешному обучению
Kazuhito tadano porn videos watch online or download
https://lesbian-i-make-a-video-with.lesbianxxxfilms.com/?tyra-destiny
youtube of porn gay popular porn tubes hot mom tori porn nina hartly porn star hot business office porn
Хирургические наконечники
Добрый день!
Перед покупкой новых медикаментов стоит разобраться, как правильно распорядиться старыми упаковками и не потерять вложенные средства. Взаимодействие строится на основе официального договора, что защищает интересы обеих сторон. Фонд выкупа препаратов принимает дорогие и онкологические медикаменты на максимально выгодных условиях. Перед тем как принимать решение, клиент получает полную информацию о порядке и условиях передачи.
Более подробная информация по ссылке – https://скупкалекарств.рф
выкуп препаратов Эриведж, Халавен с рук, выкуп препаратов Инлита
оригинальные продать лекарства, [url=https://скупкалекарств.рф]продать Эпклюза[/url], Имфинзи с рук
Всего наилучшего и успехов Фонд выкупа препаратов!
Добрый день!
Дорогие препараты, приобретённые впрок, часто так и остаются лежать в аптечке, хотя могли бы вернуть часть потраченных средств. В работе участвуют только специалисты, поэтому процесс организован корректно и безопасно. Выкуп лекарств в данном случае проходит официально, с заключением договора и подтверждением оплаты. Вы всегда можете задать уточняющие вопросы и получить развёрнутый ответ от персонального специалиста.
Более подробная информация по ссылке – https://выкуплекарств.рф
скупка Опсамит, выкуп Инлита, выкуп Абраксан
условия скупка лекарств, [url=https://выкуплекарств.рф]выкуп препаратов Полайви[/url], выкуп препаратов Энбрел 25
Всего наилучшего и успехов Фонд выкупа препаратов!
Продаю PlayStation последнего поколения вместе с кучей игр — переезжаю и, к сожалению, взять всё это с собой не получится. Отдам по сниженной цене, чтобы успеть продать до отъезда. Предпочтительно всё одним комплектом: заниматься продажей каждой игры отдельно сейчас некогда. Фотографии, список игр и цену отправлю в личку, при встрече можно будет всё посмотреть и проверить. Кто как раз присматривает себе приставку и готов рассмотреть такой вариант? Мой телефон для связи + 7 906 378 00 21
[url=https://wheelouttogame.online]кухня б у купить на авито
[/url]
Трипскан [url=tripscanvhod.com]tripscanvhod.com[/url] .
Сейчас подбираю текстиль для дома и думаю, как лучше оформить окна, чтобы интерьер выглядел гармонично. Интересные идеи по сочетанию тканей и оттенков можно посмотреть здесь: https://dodiplom.ru/interesting/iskusstvo-sozdaniya-tekstilnyh-reshenij-dlya-okon/
перейдите на этот сайт [url=https://t.me/s/darknet_links_ru/]Сайты Даркнет[/url]
pinco koefisentlər [url=www.pinco94171.shop]www.pinco94171.shop[/url]
https://belygorod.ru/vote/pgs/?1xbet-promokod.html
Следующая страница [url=https://krab45.at]kraken darknet[/url]
Хороший интерьер складывается из деталей, и оконный текстиль — одна из них. С помощью штор на заказ можно подчеркнуть стиль помещения и подобрать подходящий вариант для конкретного пространства. Полезный материал для ознакомления: https://airparkingkzn.ru/stati/iskusstvo-preobrazhenija-prostranstva-pochemu-poshiv-shtor-na-zakaz-stanovitsja-vyborom-sovremennyh-intererov/
Наша компания – это ключ к вашему идеальному жилищу! Пусть каждый уголок вашего дома станет для вас и вашей семьи источником радости и уюта.
Доверьте строительство своего дома профессионалам и получите результат, о котором мечтали – [url=https://telegra.ph/Stroitelstvo-doma-pod-klyuch-08-30]строительство дома под ключ[/url]
Основатель Rick Owens — это один из самых узнаваемых создателей одежды нового поколения, который разработал собственный эстетический язык в духе готической эстетики и протеста.
Его почерк отличается по драпировкам, нестандартным силуэтам и интересу к работе с грубым материалам в сочетании с сдержанностью и глубокими тонами.
rick-owens-boutique.ru
Оуэнс приобрёл известность талантом соединять люкс и бунтарство, создавая вещи для ценит уникальность и готов выделяться нестандартно.
В наши дни Рик Оуэнс — это уже не только модельер, а узнаваемое культурное явление, охватывающее мебельный дизайн, обувь и философию продуманного бунтарства.
Привет! Пара слов про правила драгон мани. Путаницы хватает. Самое важное: в справке разобраны и бонусы, и выплаты, и спорные ситуации. Подробно разобрано здесь: [url=https://casino-dragonmoney-terms.org]драгонмани лицензия казино[/url]. В деталях разобраться не лишнее — речь про деньги. Интересно ваше мнение: заглядывали хоть раз в справочный раздел? Берегите депозит!
Reading this in a quiet hour and finding it suited the quiet, and a stop at shadowglowcorner extended the quiet reading mood, content that matches its own optimal reading conditions rather than fighting them is content that has been thoughtfully calibrated and this site reads as having a particular reading mood in mind throughout.
Easily one of the better explanations I have read on the topic, and a stop at fastgoodsarena pushed it even higher in my mental ranking of useful resources, the kind of site that beats the average not by trying harder but by simply caring more about what it puts out daily which always shows.
Bookmark added without hesitation after finishing, and a look at echopetalbazaar confirmed I should bookmark the homepage too rather than just this page, the rare site that earns category level trust rather than just single article approval is the kind I want to rely on across many different topics over time.
Bookmark added without hesitation after finishing, and a look at echopetalbazaar confirmed I should bookmark the homepage too rather than just this page, the rare site that earns category level trust rather than just single article approval is the kind I want to rely on across many different topics over time.
This filled in a gap in my understanding that I had not even noticed was there, and a stop at frostharvestgoods did the same, the kind of post that gives you more than you expected when you first clicked through from somewhere else, a real find for anyone curious about the area covered here.
Reading this on a slow Sunday and finding it perfectly suited to a slow Sunday read, and a quick stop at premiumpickzone kept the same gentle pace, content that fits the mood of the moment is something I notice and remember and this site has the kind of pace that suits relaxed reading sessions especially well.
Found the rhythm of the prose particularly enjoyable on this read through, and a look at copperwindessentials kept that musical quality going across the related pages, sentence rhythm is something most blog writers ignore but it makes a real difference in how content lands with the careful reader who cares.
Skipped the comments to avoid spoilers and came back later to find them genuinely worth reading, and a stop at velvetharborgoods extended that surprised respect, when the discussion below a post matches the quality of the post itself you have found something special and this site appears to attract that kind of audience.
In the middle of an otherwise scattered day this post landed as a moment of focus, and a stop at glowforgeessentials extended that focused feeling across more pages, content that anchors a fragmented day rather than contributing to the fragmentation is content with real centring effect and this site is providing that anchoring function for me.
Reading this with a notebook open turned out to be the right move, and a stop at silversproutstore added more material to the notes, content that justifies active note taking from a passive reader is content with real informational density and this site is producing notes worthy material at a high rate consistently.