<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>GameWorks — ゲーム作品投稿</title>
<meta name="description" content="ゲーム作品を投稿・管理できるローカル保存型ポートフォリオサイト" />
<style>
:root{
--bg:#0b0d12; --panel:#111520; --panel2:#0f1320; --card:#0f1524;
--text:#e9edf7; --muted:#aab3c7; --line:#1f2a44;
--accent:#7c5cff; --accent2:#22c55e; --warn:#f59e0b; --danger:#ef4444;
--shadow: 0 12px 30px rgba(0,0,0,.35);
--r: 18px;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
background:
radial-gradient(900px 600px at 20% -10%, rgba(124,92,255,.25), transparent 60%),
radial-gradient(900px 600px at 85% 0%, rgba(34,197,94,.18), transparent 60%),
var(--bg);
color:var(--text);
font-family:var(--sans);
letter-spacing:.2px;
}
a{color:inherit}
.app{
min-height:100%;
display:grid;
grid-template-columns: 360px 1fr;
gap:16px;
padding:16px;
max-width:1400px;
margin:0 auto;
}
@media (max-width: 980px){
.app{grid-template-columns: 1fr; }
}
header{
grid-column: 1 / -1;
display:flex;
align-items:center;
justify-content:space-between;
gap:12px;
padding:14px 16px;
border:1px solid var(--line);
border-radius: var(--r);
background: linear-gradient(180deg, rgba(17,21,32,.9), rgba(15,19,32,.75));
box-shadow: var(--shadow);
position:sticky;
top:16px;
z-index:10;
backdrop-filter: blur(10px);
}
.brand{
display:flex; align-items:center; gap:12px;
}
.logo{
width:38px; height:38px; border-radius:14px;
background: conic-gradient(from 180deg, var(--accent), #3b82f6, var(--accent2), var(--accent));
box-shadow: 0 10px 20px rgba(124,92,255,.25);
}
.brand h1{font-size:16px; margin:0}
.brand p{margin:2px 0 0 0; color:var(--muted); font-size:12px}
.headerActions{display:flex; align-items:center; gap:10px; flex-wrap:wrap; justify-content:flex-end}
.chip{
display:inline-flex; align-items:center; gap:8px;
padding:8px 10px;
border:1px solid var(--line);
border-radius: 999px;
color:var(--muted);
background: rgba(10,12,18,.35);
font-size:12px;
user-select:none;
}
.btn{
appearance:none; border:1px solid var(--line);
background: rgba(15,19,32,.85);
color:var(--text);
padding:10px 12px;
border-radius: 12px;
cursor:pointer;
font-weight:600;
transition: transform .08s ease, border-color .2s ease, background .2s ease, opacity .2s ease;
}
.btn:hover{border-color: rgba(124,92,255,.55)}
.btn:active{transform: translateY(1px)}
.btn.primary{
border-color: rgba(124,92,255,.6);
background: linear-gradient(180deg, rgba(124,92,255,.35), rgba(124,92,255,.15));
}
.btn.good{
border-color: rgba(34,197,94,.55);
background: linear-gradient(180deg, rgba(34,197,94,.25), rgba(34,197,94,.10));
}
.btn.danger{
border-color: rgba(239,68,68,.6);
background: linear-gradient(180deg, rgba(239,68,68,.25), rgba(239,68,68,.10));
}
.btn.ghost{background: transparent}
.btn.small{padding:8px 10px; border-radius: 10px; font-size:12px}
.btn:disabled{opacity:.55; cursor:not-allowed}
.panel{
border:1px solid var(--line);
border-radius: var(--r);
background: linear-gradient(180deg, rgba(17,21,32,.92), rgba(15,19,32,.78));
box-shadow: var(--shadow);
overflow:hidden;
}
.panelHeader{
padding:14px 16px;
border-bottom:1px solid var(--line);
display:flex; align-items:center; justify-content:space-between; gap:10px;
}
.panelHeader h2{font-size:14px; margin:0}
.panelHeader .hint{font-size:12px; color:var(--muted)}
.panelBody{padding:14px 16px}
.field{display:flex; flex-direction:column; gap:6px; margin-bottom:12px}
.field label{font-size:12px; color:var(--muted)}
.row{display:grid; grid-template-columns: 1fr 1fr; gap:10px}
@media (max-width: 980px){ .row{grid-template-columns: 1fr} }
input, select, textarea{
width:100%;
padding:10px 12px;
border-radius: 12px;
border:1px solid var(--line);
background: rgba(10,12,18,.35);
color:var(--text);
outline:none;
}
textarea{min-height:110px; resize:vertical; line-height:1.5}
input:focus, select:focus, textarea:focus{border-color: rgba(124,92,255,.55)}
.help{font-size:11px; color:var(--muted); line-height:1.5}
.divider{height:1px; background: var(--line); margin:14px 0}
.toolbar{
display:flex; gap:10px; align-items:center; flex-wrap:wrap;
padding:12px 16px;
border-bottom:1px solid var(--line);
background: rgba(10,12,18,.18);
}
.toolbar input, .toolbar select{
padding:10px 12px; border-radius: 999px;
min-width: 200px;
}
.toolbar .grow{flex:1}
.stats{
display:flex; gap:8px; flex-wrap:wrap;
padding:0 16px 14px 16px;
color:var(--muted);
font-size:12px;
}
.grid{
padding:16px;
display:grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap:14px;
}
@media (max-width: 1200px){ .grid{grid-template-columns: repeat(2, minmax(0, 1fr));} }
@media (max-width: 680px){ .grid{grid-template-columns: 1fr;} }
.card{
border:1px solid var(--line);
border-radius: 18px;
overflow:hidden;
background: linear-gradient(180deg, rgba(15,21,36,.95), rgba(12,16,28,.88));
box-shadow: 0 10px 25px rgba(0,0,0,.22);
display:flex; flex-direction:column;
min-height: 260px;
}
.thumb{
height:160px;
background: radial-gradient(1200px 260px at 10% 0%, rgba(124,92,255,.18), transparent 60%),
radial-gradient(1200px 260px at 80% 0%, rgba(34,197,94,.12), transparent 60%),
rgba(10,12,18,.25);
border-bottom:1px solid var(--line);
display:flex; align-items:center; justify-content:center;
position:relative;
overflow:hidden;
}
.thumb img{
width:100%; height:100%;
object-fit:cover;
display:block;
filter:saturate(1.02) contrast(1.02);
}
.badgeRow{
position:absolute; left:10px; top:10px;
display:flex; gap:8px; flex-wrap:wrap;
}
.badge{
font-size:11px; color:var(--text);
padding:6px 9px;
border-radius: 999px;
border:1px solid rgba(255,255,255,.12);
background: rgba(0,0,0,.35);
backdrop-filter: blur(8px);
}
.badge.good{border-color: rgba(34,197,94,.35)}
.badge.warn{border-color: rgba(245,158,11,.35)}
.badge.muted{color:var(--muted)}
.cardBody{padding:12px 12px 10px 12px; display:flex; flex-direction:column; gap:8px; flex:1}
.titleRow{display:flex; align-items:flex-start; justify-content:space-between; gap:10px}
.card h3{margin:0; font-size:15px; line-height:1.25}
.tagline{margin:0; color:var(--muted); font-size:12px; line-height:1.45}
.meta{
display:flex; gap:8px; flex-wrap:wrap;
color:var(--muted); font-size:12px;
}
.pill{
border:1px solid var(--line);
background: rgba(10,12,18,.25);
padding:6px 9px; border-radius:999px;
font-size:12px;
}
.cardFooter{
padding:10px 12px 12px 12px;
display:flex; align-items:center; justify-content:space-between; gap:10px;
border-top:1px solid var(--line);
background: rgba(10,12,18,.18);
}
.actions{display:flex; gap:8px; flex-wrap:wrap}
.muted{color:var(--muted)}
.mono{font-family:var(--mono)}
.empty{
padding:28px 16px 40px 16px;
text-align:center;
color:var(--muted);
}
dialog{
border:none;
border-radius: 18px;
padding:0;
width:min(920px, calc(100vw - 24px));
background: rgba(15,19,32,.96);
color:var(--text);
box-shadow: 0 30px 80px rgba(0,0,0,.55);
}
dialog::backdrop{background: rgba(0,0,0,.6)}
.modalHeader{
padding:14px 16px;
border-bottom:1px solid var(--line);
display:flex; align-items:center; justify-content:space-between; gap:10px;
}
.modalHeader h3{margin:0; font-size:14px}
.modalBody{padding:14px 16px}
.modalGrid{
display:grid; grid-template-columns: 1.2fr .8fr; gap:14px;
}
@media (max-width: 900px){ .modalGrid{grid-template-columns: 1fr} }
.gallery{
display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap:10px;
}
@media (max-width: 680px){ .gallery{grid-template-columns: repeat(2, minmax(0, 1fr));} }
.gimg{
border:1px solid var(--line);
border-radius: 14px;
overflow:hidden;
background: rgba(10,12,18,.25);
aspect-ratio: 16 / 10;
display:flex; align-items:center; justify-content:center;
}
.gimg img{width:100%; height:100%; object-fit:cover}
.kvs{display:grid; grid-template-columns: 110px 1fr; gap:8px; align-items:start; font-size:12px; color:var(--muted)}
.kvs b{color:var(--text); font-weight:700}
.note{
padding:10px 12px;
border:1px solid var(--line);
border-radius: 14px;
background: rgba(10,12,18,.25);
color:var(--muted);
font-size:12px;
line-height:1.55;
}
.toast{
position: fixed;
left:50%;
bottom:16px;
transform: translateX(-50%);
background: rgba(15,19,32,.95);
border: 1px solid var(--line);
border-radius: 999px;
padding: 10px 14px;
color: var(--text);
box-shadow: var(--shadow);
opacity:0;
pointer-events:none;
transition: opacity .2s ease, transform .2s ease;
display:flex; gap:8px; align-items:center;
z-index:100;
max-width: calc(100vw - 24px);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.toast.show{opacity:1; transform: translateX(-50%) translateY(-2px)}
.smallLink{
color: var(--muted);
font-size:12px;
text-decoration:none;
border-bottom:1px dashed rgba(170,179,199,.35);
}
.smallLink:hover{color: var(--text); border-bottom-color: rgba(233,237,247,.55)}
.dangerText{color: #ffb4b4}
.goodText{color: #b5ffd1}
.warnText{color: #ffe2b0}
</style>
</head>
<body>
<div class="app">
<header>
<div class="brand">
<div class="logo" aria-hidden="true"></div>
<div>
<h1>GameWorks <span class="muted">— ゲーム作品投稿</span></h1>
<p>サーバー不要 / ブラウザに保存 / 作品ポートフォリオを即作る</p>
</div>
</div>
<div class="headerActions">
<span class="chip" id="chipCount">作品: 0</span>
<button class="btn small" id="btnSeed" type="button">サンプル追加</button>
<button class="btn small" id="btnExport" type="button">エクスポート</button>
<button class="btn small" id="btnImport" type="button">インポート</button>
<button class="btn small danger" id="btnWipe" type="button">全削除</button>
</div>
</header>
<!-- Left: 投稿フォーム -->
<section class="panel" aria-label="投稿フォーム">
<div class="panelHeader">
<div>
<h2 id="formTitle">新規投稿</h2>
<div class="hint" id="formHint">作品情報を入力して保存</div>
</div>
<button class="btn small ghost" id="btnResetForm" type="button">リセット</button>
</div>
<div class="panelBody">
<div class="field">
<label for="author">投稿者名(任意)</label>
<input id="author" type="text" maxlength="40" placeholder="例:Yuhei" />
<div class="help">同じブラウザ内だけの表示です。</div>
</div>
<div class="field">
<label for="title">作品タイトル *</label>
<input id="title" type="text" maxlength="60" placeholder="例:Elder Chronicle VR" required />
</div>
<div class="field">
<label for="tagline">ひとこと(キャッチコピー)</label>
<input id="tagline" type="text" maxlength="80" placeholder="例:探索と戦闘が気持ちいいVRアクションRPG" />
</div>
<div class="row">
<div class="field">
<label for="engine">使用エンジン</label>
<select id="engine">
<option value="">未設定</option>
<option>Unity</option>
<option>Unreal Engine</option>
<option>Godot</option>
<option>RPGツクール</option>
<option>自作</option>
<option>その他</option>
</select>
</div>
<div class="field">
<label for="status">状態</label>
<select id="status">
<option>開発中</option>
<option>体験版あり</option>
<option>公開中</option>
<option>凍結</option>
</select>
</div>
</div>
<div class="row">
<div class="field">
<label for="platform">対応プラットフォーム</label>
<input id="platform" type="text" maxlength="60" placeholder="例:PC / Web / Quest / Android" />
</div>
<div class="field">
<label for="genre">ジャンル</label>
<input id="genre" type="text" maxlength="60" placeholder="例:アクション / VR / サバイバル" />
</div>
</div>
<div class="field">
<label for="tags">タグ(カンマ区切り)</label>
<input id="tags" type="text" maxlength="120" placeholder="例:VR, 探索, ダンジョン, ボス戦" />
</div>
<div class="field">
<label for="desc">説明 *</label>
<textarea id="desc" maxlength="2000" placeholder="作品の魅力、遊び方、特徴、今後の予定など"></textarea>
<div class="help">最大2000文字。長い場合は要点→詳細の順で書くと強い。</div>
</div>
<div class="row">
<div class="field">
<label for="linkPlay">プレイURL / 公開ページ</label>
<input id="linkPlay" type="url" placeholder="https://..." />
</div>
<div class="field">
<label for="linkRepo">GitHub / リポジトリ</label>
<input id="linkRepo" type="url" placeholder="https://github.com/..." />
</div>
</div>
<div class="row">
<div class="field">
<label for="linkVideo">動画URL(YouTubeなど)</label>
<input id="linkVideo" type="url" placeholder="https://www.youtube.com/watch?v=..." />
</div>
<div class="field">
<label for="rating">自己評価(1〜5)</label>
<select id="rating">
<option value="0">未設定</option>
<option value="1">★1</option>
<option value="2">★2</option>
<option value="3">★3</option>
<option value="4">★4</option>
<option value="5">★5</option>
</select>
</div>
</div>
<div class="field">
<label for="shots">スクリーンショット(複数可 / 自動でデータ化して保存)</label>
<input id="shots" type="file" accept="image/*" multiple />
<div class="help">
画像はブラウザ内に保存されます(容量が大きいと重くなります)。<br/>
目安:1枚 500KB〜1MB程度に圧縮すると快適。
</div>
</div>
<div class="divider"></div>
<div style="display:flex; gap:10px; flex-wrap:wrap; align-items:center">
<button class="btn primary" id="btnSave" type="button">保存</button>
<button class="btn" id="btnDraft" type="button">下書き保存</button>
<span class="help" id="saveHelp">* 必須:タイトル / 説明</span>
</div>
<div class="divider"></div>
<div class="note">
<b>保存方式:</b>このページは <span class="mono">localStorage</span> に保存します。<br/>
つまり「あなたのブラウザ内だけ」に残ります。公開サイトで運用したいなら、次はサーバー保存(PHP/DB)に切り替える。
</div>
</div>
</section>
<!-- Right: 一覧 -->
<main class="panel" aria-label="作品一覧">
<div class="toolbar">
<input class="grow" id="q" type="search" placeholder="検索:タイトル/説明/タグ/エンジン/ジャンル..." />
<select id="filterStatus" title="状態">
<option value="">状態:すべて</option>
<option>開発中</option>
<option>体験版あり</option>
<option>公開中</option>
<option>凍結</option>
</select>
<select id="sort" title="並び替え">
<option value="new">新しい順</option>
<option value="old">古い順</option>
<option value="likes">いいね順</option>
<option value="rating">評価順</option>
<option value="title">タイトル順</option>
</select>
<button class="btn small good" id="btnNew" type="button">+ 新規投稿</button>
</div>
<div class="stats" id="stats"></div>
<div id="list" class="grid" aria-live="polite"></div>
<div id="empty" class="empty" hidden>
まだ作品がありません。左のフォームから投稿して、あなたの作品集を完成させよう。
</div>
</main>
</div>
<!-- 詳細モーダル -->
<dialog id="modal">
<div class="modalHeader">
<h3 id="mTitle">詳細</h3>
<div style="display:flex; gap:8px; align-items:center">
<button class="btn small" id="mEdit" type="button">編集</button>
<button class="btn small" id="mLike" type="button">いいね</button>
<button class="btn small danger" id="mDelete" type="button">削除</button>
<button class="btn small ghost" id="mClose" type="button">閉じる</button>
</div>
</div>
<div class="modalBody">
<div class="modalGrid">
<div>
<div class="note" id="mTagline"></div>
<div class="divider"></div>
<div class="gallery" id="mGallery"></div>
<div class="divider"></div>
<div class="note" id="mDesc"></div>
<div class="divider"></div>
<div class="panel" style="background: rgba(10,12,18,.12); box-shadow:none">
<div class="panelHeader" style="border-bottom:1px solid var(--line)">
<div>
<h2 style="margin:0;font-size:14px">コメント</h2>
<div class="hint">ローカル保存(このブラウザだけ)</div>
</div>
</div>
<div class="panelBody">
<div class="field">
<label for="cName">名前(任意)</label>
<input id="cName" type="text" maxlength="40" placeholder="例:Anonymous" />
</div>
<div class="field">
<label for="cText">コメント</label>
<textarea id="cText" maxlength="400" placeholder="感想 / フィードバック"></textarea>
<div class="help">最大400文字</div>
</div>
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap">
<button class="btn good" id="cAdd" type="button">コメント追加</button>
<span class="help" id="cHint"></span>
</div>
<div class="divider"></div>
<div id="cList" style="display:flex;flex-direction:column;gap:10px"></div>
</div>
</div>
</div>
<div>
<div class="note">
<div class="kvs" id="mMeta"></div>
</div>
<div class="divider"></div>
<div class="note" id="mLinks"></div>
<div class="divider"></div>
<div class="note" id="mSystem"></div>
</div>
</div>
</div>
</dialog>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<input id="importFile" type="file" accept="application/json" hidden />
<script>
"use strict";
// ====== Storage Keys ======
const KEY = "gameworks_posts_v1";
const KEY_DRAFT = "gameworks_draft_v1";
// ====== DOM ======
const el = (id) => document.getElementById(id);
const chipCount = el("chipCount");
const listEl = el("list");
const emptyEl = el("empty");
const statsEl = el("stats");
const qEl = el("q");
const filterStatusEl = el("filterStatus");
const sortEl = el("sort");
const formTitleEl = el("formTitle");
const formHintEl = el("formHint");
// form fields
const authorEl = el("author");
const titleEl = el("title");
const taglineEl = el("tagline");
const engineEl = el("engine");
const statusEl = el("status");
const platformEl = el("platform");
const genreEl = el("genre");
const tagsEl = el("tags");
const descEl = el("desc");
const linkPlayEl = el("linkPlay");
const linkRepoEl = el("linkRepo");
const linkVideoEl = el("linkVideo");
const ratingEl = el("rating");
const shotsEl = el("shots");
const btnSave = el("btnSave");
const btnDraft = el("btnDraft");
const btnResetForm = el("btnResetForm");
const btnSeed = el("btnSeed");
const btnExport = el("btnExport");
const btnImport = el("btnImport");
const btnWipe = el("btnWipe");
const btnNew = el("btnNew");
const toastEl = el("toast");
const importFileEl = el("importFile");
// modal
const modal = el("modal");
const mTitle = el("mTitle");
const mTagline = el("mTagline");
const mGallery = el("mGallery");
const mDesc = el("mDesc");
const mMeta = el("mMeta");
const mLinks = el("mLinks");
const mSystem = el("mSystem");
const mEdit = el("mEdit");
const mLike = el("mLike");
const mDelete = el("mDelete");
const mClose = el("mClose");
// comments
const cName = el("cName");
const cText = el("cText");
const cAdd = el("cAdd");
const cHint = el("cHint");
const cList = el("cList");
// ====== State ======
let posts = loadPosts();
let editingId = null;
let pendingImages = []; // base64 array for current form
let currentModalId = null;
// ====== Utils ======
const nowISO = () => new Date().toISOString();
const uid = () => "p_" + Math.random().toString(16).slice(2) + Date.now().toString(16);
function toast(msg){
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastEl._t);
toastEl._t = setTimeout(()=> toastEl.classList.remove("show"), 1700);
}
function safeText(str){
// basic escape for text rendering into HTML
return String(str ?? "")
.replaceAll("&","&")
.replaceAll("<","<")
.replaceAll(">",">")
.replaceAll('"',""")
.replaceAll("'","'");
}
function normalizeTags(input){
return String(input ?? "")
.split(",")
.map(s => s.trim())
.filter(Boolean)
.slice(0, 20);
}
function stars(n){
const v = Number(n || 0);
if(!v) return "未設定";
return "★".repeat(v) + "☆".repeat(5 - v);
}
function fmtDate(iso){
try{
const d = new Date(iso);
const y = d.getFullYear();
const m = String(d.getMonth()+1).padStart(2,"0");
const dd = String(d.getDate()).padStart(2,"0");
const hh = String(d.getHours()).padStart(2,"0");
const mm = String(d.getMinutes()).padStart(2,"0");
return `${y}/${m}/${dd} ${hh}:${mm}`;
}catch{
return String(iso);
}
}
function loadPosts(){
try{
const raw = localStorage.getItem(KEY);
const arr = raw ? JSON.parse(raw) : [];
return Array.isArray(arr) ? arr : [];
}catch{
return [];
}
}
function savePosts(){
localStorage.setItem(KEY, JSON.stringify(posts));
chipCount.textContent = `作品: ${posts.length}`;
}
function loadDraft(){
try{
const raw = localStorage.getItem(KEY_DRAFT);
return raw ? JSON.parse(raw) : null;
}catch{
return null;
}
}
function saveDraft(draft){
localStorage.setItem(KEY_DRAFT, JSON.stringify(draft));
}
function clearDraft(){
localStorage.removeItem(KEY_DRAFT);
}
function bytesApprox(){
// rough localStorage usage for our key
const raw = localStorage.getItem(KEY) || "";
return raw.length;
}
function buildStats(filteredCount){
const likeSum = posts.reduce((a,p)=> a + (p.likes||0), 0);
const bytes = bytesApprox();
const mb = (bytes / (1024*1024)).toFixed(2);
statsEl.innerHTML = `
<span class="chip">表示: <b>${filteredCount}</b></span>
<span class="chip">総いいね: <b>${likeSum}</b></span>
<span class="chip">保存サイズ目安: <b>${mb} MB</b></span>
`;
}
// ====== Image handling (compress to JPEG) ======
async function fileToDataUrlCompressed(file, maxW=1280, quality=0.82){
const img = await new Promise((res, rej)=>{
const i = new Image();
i.onload = ()=> res(i);
i.onerror = rej;
i.src = URL.createObjectURL(file);
});
const scale = Math.min(1, maxW / img.width);
const w = Math.round(img.width * scale);
const h = Math.round(img.height * scale);
const canvas = document.createElement("canvas");
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, w, h);
URL.revokeObjectURL(img.src);
return canvas.toDataURL("image/jpeg", quality);
}
shotsEl.addEventListener("change", async () => {
const files = Array.from(shotsEl.files || []);
if(!files.length) return;
toast("画像を処理中…");
const max = 9;
const slice = files.slice(0, max);
const out = [];
for(const f of slice){
if(!f.type.startsWith("image/")) continue;
try{
const d = await fileToDataUrlCompressed(f);
out.push(d);
}catch{}
}
pendingImages = out;
toast(`スクショ ${pendingImages.length}枚 準備OK`);
});
// ====== Render ======
function getFiltered(){
const q = (qEl.value || "").trim().toLowerCase();
const st = filterStatusEl.value || "";
let arr = [...posts];
if(q){
arr = arr.filter(p=>{
const blob = [
p.title, p.tagline, p.desc, p.engine, p.platform, p.genre,
...(p.tags||[]),
].join(" ").toLowerCase();
return blob.includes(q);
});
}
if(st){
arr = arr.filter(p => (p.status || "") === st);
}
// sort
const s = sortEl.value;
arr.sort((a,b)=>{
if(s === "new") return (b.createdAt||"").localeCompare(a.createdAt||"");
if(s === "old") return (a.createdAt||"").localeCompare(b.createdAt||"");
if(s === "likes") return (b.likes||0) - (a.likes||0);
if(s === "rating") return (Number(b.rating||0) - Number(a.rating||0)) || (b.likes||0)-(a.likes||0);
if(s === "title") return (a.title||"").localeCompare(b.title||"", "ja");
return 0;
});
return arr;
}
function statusBadgeClass(status){
if(status === "公開中") return "good";
if(status === "体験版あり") return "warn";
return "";
}
function render(){
savePosts(); // also updates chipCount
const arr = getFiltered();
buildStats(arr.length);
listEl.innerHTML = "";
emptyEl.hidden = arr.length !== 0;
for(const p of arr){
const cover = (p.images && p.images[0]) ? `<img alt="cover" src="${p.images[0]}">` : "";
const tags = (p.tags||[]).slice(0,3).map(t=>`<span class="pill">#${safeText(t)}</span>`).join("");
const engine = p.engine ? `<span class="pill">${safeText(p.engine)}</span>` : "";
const genre = p.genre ? `<span class="pill">${safeText(p.genre)}</span>` : "";
const rating = Number(p.rating||0) ? `<span class="pill">${safeText(stars(p.rating))}</span>` : "";
const card = document.createElement("article");
card.className = "card";
card.innerHTML = `
<div class="thumb">
${cover || `<div class="muted">No Image</div>`}
<div class="badgeRow">
<span class="badge ${statusBadgeClass(p.status)}">${safeText(p.status || "未設定")}</span>
${p.platform ? `<span class="badge muted">${safeText(p.platform)}</span>` : ""}
</div>
</div>
<div class="cardBody">
<div class="titleRow">
<div style="min-width:0">
<h3 title="${safeText(p.title)}">${safeText(p.title)}</h3>
<p class="tagline">${safeText(p.tagline || "—")}</p>
</div>
<div class="muted" style="font-size:12px; white-space:nowrap">
❤ ${p.likes||0}
</div>
</div>
<div class="meta">
${engine}
${genre}
${rating}
${tags}
</div>
<div class="muted" style="font-size:12px; line-height:1.55; display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden">
${safeText(p.desc || "")}
</div>
</div>
<div class="cardFooter">
<div class="muted" style="font-size:12px">更新: ${safeText(fmtDate(p.updatedAt || p.createdAt))}</div>
<div class="actions">
<button class="btn small" data-act="open" data-id="${p.id}">詳細</button>
<button class="btn small good" data-act="like" data-id="${p.id}">いいね</button>
</div>
</div>
`;
listEl.appendChild(card);
}
}
listEl.addEventListener("click", (e)=>{
const btn = e.target.closest("button");
if(!btn) return;
const id = btn.getAttribute("data-id");
const act = btn.getAttribute("data-act");
if(!id || !act) return;
if(act === "open") openModal(id);
if(act === "like") { likePost(id); render(); }
});
// ====== CRUD ======
function validateForm(){
const title = titleEl.value.trim();
const desc = descEl.value.trim();
if(!title || !desc){
toast("タイトルと説明は必須");
return false;
}
return true;
}
function readFormAsPost(){
const tags = normalizeTags(tagsEl.value);
return {
id: editingId || uid(),
author: authorEl.value.trim(),
title: titleEl.value.trim(),
tagline: taglineEl.value.trim(),
engine: engineEl.value,
status: statusEl.value,
platform: platformEl.value.trim(),
genre: genreEl.value.trim(),
tags,
desc: descEl.value.trim(),
linkPlay: linkPlayEl.value.trim(),
linkRepo: linkRepoEl.value.trim(),
linkVideo: linkVideoEl.value.trim(),
rating: Number(ratingEl.value || 0),
images: (pendingImages && pendingImages.length) ? pendingImages : [],
likes: 0,
comments: [],
createdAt: nowISO(),
updatedAt: nowISO()
};
}
function resetForm(keepDraft=false){
editingId = null;
pendingImages = [];
if(!keepDraft){
authorEl.value = "";
titleEl.value = "";
taglineEl.value = "";
engineEl.value = "";
statusEl.value = "開発中";
platformEl.value = "";
genreEl.value = "";
tagsEl.value = "";
descEl.value = "";
linkPlayEl.value = "";
linkRepoEl.value = "";
linkVideoEl.value = "";
ratingEl.value = "0";
shotsEl.value = "";
}
formTitleEl.textContent = "新規投稿";
formHintEl.textContent = "作品情報を入力して保存";
btnSave.textContent = "保存";
}
function setFormFromPost(p){
editingId = p.id;
pendingImages = Array.isArray(p.images) ? [...p.images] : [];
authorEl.value = p.author || "";
titleEl.value = p.title || "";
taglineEl.value = p.tagline || "";
engineEl.value = p.engine || "";
statusEl.value = p.status || "開発中";
platformEl.value = p.platform || "";
genreEl.value = p.genre || "";
tagsEl.value = (p.tags || []).join(", ");
descEl.value = p.desc || "";
linkPlayEl.value = p.linkPlay || "";
linkRepoEl.value = p.linkRepo || "";
linkVideoEl.value = p.linkVideo || "";
ratingEl.value = String(p.rating || 0);
shotsEl.value = "";
formTitleEl.textContent = "編集";
formHintEl.textContent = "内容を更新して保存";
btnSave.textContent = "更新";
toast("編集モード");
window.scrollTo({top:0, behavior:"smooth"});
}
function upsertPost(p){
const idx = posts.findIndex(x=>x.id===p.id);
if(idx >= 0){
const prev = posts[idx];
posts[idx] = {
...prev,
...p,
likes: prev.likes || 0,
comments: Array.isArray(prev.comments) ? prev.comments : [],
createdAt: prev.createdAt || nowISO(),
updatedAt: nowISO()
};
}else{
posts.unshift(p);
}
savePosts();
}
btnSave.addEventListener("click", ()=>{
if(!validateForm()) return;
const p = readFormAsPost();
// if editing, keep previous likes/comments/createdAt
if(editingId){
const prev = posts.find(x=>x.id===editingId);
if(prev){
p.likes = prev.likes || 0;
p.comments = Array.isArray(prev.comments) ? prev.comments : [];
p.createdAt = prev.createdAt || nowISO();
p.updatedAt = nowISO();
// if user did not reselect images, keep old images
if(!pendingImages.length && Array.isArray(prev.images)) p.images = prev.images;
}
}
upsertPost(p);
clearDraft();
resetForm();
render();
toast(editingId ? "更新した" : "保存した");
});
btnDraft.addEventListener("click", ()=>{
const draft = {
author: authorEl.value,
title: titleEl.value,
tagline: taglineEl.value,
engine: engineEl.value,
status: statusEl.value,
platform: platformEl.value,
genre: genreEl.value,
tags: tagsEl.value,
desc: descEl.value,
linkPlay: linkPlayEl.value,
linkRepo: linkRepoEl.value,
linkVideo: linkVideoEl.value,
rating: ratingEl.value,
images: pendingImages
};
saveDraft(draft);
toast("下書きを保存した");
});
btnResetForm.addEventListener("click", ()=>{
resetForm();
toast("フォームをリセット");
});
btnNew.addEventListener("click", ()=>{
resetForm();
toast("新規投稿モード");
window.scrollTo({top:0, behavior:"smooth"});
});
// ====== Like / Delete ======
function likePost(id){
const p = posts.find(x=>x.id===id);
if(!p) return;
p.likes = (p.likes||0) + 1;
p.updatedAt = nowISO();
savePosts();
toast("いいね +1");
if(currentModalId === id) refreshModal();
}
function deletePost(id){
const idx = posts.findIndex(x=>x.id===id);
if(idx < 0) return;
posts.splice(idx, 1);
savePosts();
toast("削除した");
}
// ====== Modal ======
function openModal(id){
const p = posts.find(x=>x.id===id);
if(!p) return;
currentModalId = id;
// count view? (optional)
modal.showModal();
refreshModal();
}
function refreshModal(){
const p = posts.find(x=>x.id===currentModalId);
if(!p) return;
mTitle.textContent = p.title || "詳細";
mTagline.innerHTML = `<b>${safeText(p.tagline || "—")}</b><br><span class="muted">❤ ${p.likes||0} / 評価: ${safeText(stars(p.rating))}</span>`;
// gallery
mGallery.innerHTML = "";
const imgs = Array.isArray(p.images) ? p.images : [];
if(imgs.length){
for(const src of imgs){
const div = document.createElement("div");
div.className = "gimg";
div.innerHTML = `<img alt="screenshot" src="${src}">`;
mGallery.appendChild(div);
}
}else{
mGallery.innerHTML = `<div class="note">スクショなし</div>`;
}
mDesc.innerHTML = safeText(p.desc || "").replaceAll("\n","<br>");
// meta
const t = (p.tags||[]).map(x=>`#${x}`).join(" ");
mMeta.innerHTML = `
<b>投稿者</b><div>${safeText(p.author || "—")}</div>
<b>状態</b><div>${safeText(p.status || "—")}</div>
<b>エンジン</b><div>${safeText(p.engine || "—")}</div>
<b>プラットフォーム</b><div>${safeText(p.platform || "—")}</div>
<b>ジャンル</b><div>${safeText(p.genre || "—")}</div>
<b>タグ</b><div>${safeText(t || "—")}</div>
<b>作成</b><div>${safeText(fmtDate(p.createdAt))}</div>
<b>更新</b><div>${safeText(fmtDate(p.updatedAt || p.createdAt))}</div>
`;
// links
const links = [];
if(p.linkPlay) links.push(`<a class="smallLink" href="${safeText(p.linkPlay)}" target="_blank" rel="noopener">▶ 公開/プレイページ</a>`);
if(p.linkRepo) links.push(`<a class="smallLink" href="${safeText(p.linkRepo)}" target="_blank" rel="noopener">⌂ リポジトリ</a>`);
if(p.linkVideo) links.push(`<a class="smallLink" href="${safeText(p.linkVideo)}" target="_blank" rel="noopener">🎬 動画</a>`);
mLinks.innerHTML = links.length ? links.join("<br>") : `<span class="muted">リンクなし</span>`;
mSystem.innerHTML = `
<b>操作</b><br>
・編集で左フォームに読み込み<br>
・いいねはローカルカウント<br>
・削除は取り消し不可
`;
// buttons
mLike.textContent = `いいね (${p.likes||0})`;
renderComments(p);
}
mClose.addEventListener("click", ()=> modal.close());
modal.addEventListener("click", (e)=>{
const rect = modal.getBoundingClientRect();
const inDialog = (
rect.top <= e.clientY && e.clientY <= rect.top + rect.height &&
rect.left <= e.clientX && e.clientX <= rect.left + rect.width
);
if(!inDialog) modal.close();
});
mLike.addEventListener("click", ()=>{
if(!currentModalId) return;
likePost(currentModalId);
render();
});
mDelete.addEventListener("click", ()=>{
if(!currentModalId) return;
const p = posts.find(x=>x.id===currentModalId);
if(!p) return;
const ok = confirm(`「${p.title}」を削除します。よろしいですか?`);
if(!ok) return;
deletePost(currentModalId);
modal.close();
currentModalId = null;
render();
});
mEdit.addEventListener("click", ()=>{
const p = posts.find(x=>x.id===currentModalId);
if(!p) return;
setFormFromPost(p);
modal.close();
});
// ====== Comments ======
function renderComments(p){
const arr = Array.isArray(p.comments) ? p.comments : [];
cHint.textContent = `コメント数: ${arr.length}`;
cList.innerHTML = "";
if(!arr.length){
cList.innerHTML = `<div class="note">まだコメントはありません。</div>`;
return;
}
for(const c of arr.slice().reverse()){
const box = document.createElement("div");
box.className = "note";
box.innerHTML = `
<b>${safeText(c.name || "Anonymous")}</b>
<span class="muted"> / ${safeText(fmtDate(c.at))}</span><br>
${safeText(c.text || "").replaceAll("\n","<br>")}
`;
cList.appendChild(box);
}
}
cAdd.addEventListener("click", ()=>{
const p = posts.find(x=>x.id===currentModalId);
if(!p) return;
const text = (cText.value || "").trim();
if(!text){
toast("コメントを入力");
return;
}
const name = (cName.value || "").trim();
p.comments = Array.isArray(p.comments) ? p.comments : [];
p.comments.push({ name, text, at: nowISO() });
p.updatedAt = nowISO();
savePosts();
cText.value = "";
toast("コメント追加");
refreshModal();
render();
});
// ====== Search / filter / sort ======
[qEl, filterStatusEl, sortEl].forEach(x => x.addEventListener("input", render));
// ====== Export / Import ======
btnExport.addEventListener("click", ()=>{
const payload = {
version: 1,
exportedAt: nowISO(),
posts
};
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 = `gameworks_export_${new Date().toISOString().slice(0,10)}.json`;
a.click();
URL.revokeObjectURL(url);
toast("エクスポートした");
});
btnImport.addEventListener("click", ()=>{
importFileEl.value = "";
importFileEl.click();
});
importFileEl.addEventListener("change", async ()=>{
const file = importFileEl.files && importFileEl.files[0];
if(!file) return;
try{
const text = await file.text();
const data = JSON.parse(text);
const arr = data && data.posts;
if(!Array.isArray(arr)) throw new Error("invalid");
// merge (by id)
const map = new Map(posts.map(p=>[p.id, p]));
for(const p of arr){
if(!p || !p.id) continue;
const prev = map.get(p.id);
map.set(p.id, prev ? ({...prev, ...p}) : p);
}
posts = Array.from(map.values());
// normalize
posts = posts.map(p=>({
id: p.id || uid(),
author: p.author || "",
title: p.title || "Untitled",
tagline: p.tagline || "",
engine: p.engine || "",
status: p.status || "開発中",
platform: p.platform || "",
genre: p.genre || "",
tags: Array.isArray(p.tags) ? p.tags : normalizeTags(p.tags),
desc: p.desc || "",
linkPlay: p.linkPlay || "",
linkRepo: p.linkRepo || "",
linkVideo: p.linkVideo || "",
rating: Number(p.rating || 0),
images: Array.isArray(p.images) ? p.images : [],
likes: Number(p.likes || 0),
comments: Array.isArray(p.comments) ? p.comments : [],
createdAt: p.createdAt || nowISO(),
updatedAt: p.updatedAt || p.createdAt || nowISO()
}));
savePosts();
render();
toast("インポート完了");
}catch{
toast("インポート失敗(JSON形式を確認)");
}
});
// ====== Wipe ======
btnWipe.addEventListener("click", ()=>{
const ok = confirm("全作品データを削除します。取り消しできません。よろしいですか?");
if(!ok) return;
posts = [];
savePosts();
render();
resetForm();
clearDraft();
toast("全削除した");
});
// ====== Seed ======
btnSeed.addEventListener("click", ()=>{
const seed = [
{
id: uid(),
author: "Yuhei",
title: "KnightSurvivors",
tagline: "短時間で熱くなれるサバイバルアクション",
engine: "Unity",
status: "体験版あり",
platform: "PC / Web",
genre: "サバイバル / アクション",
tags: ["サバイバル","爽快","ローグライト"],
desc: "敵の波をさばき、ビルドを組み替えて最適解を探す。\n短時間でも気持ちよく終われるテンポを意識。",
linkPlay: "",
linkRepo: "",
linkVideo: "",
rating: 4,
images: [],
likes: 12,
comments: [],
createdAt: nowISO(),
updatedAt: nowISO()
},
{
id: uid(),
author: "Yuhei",
title: "Elder Chronicle VR",
tagline: "探索・クエスト・戦闘を1つにまとめたVR世界",
engine: "A-Frame",
status: "開発中",
platform: "Quest / WebXR",
genre: "VR / RPG",
tags: ["VR","RPG","ダンジョン","クエスト"],
desc: "場所移動・クエスト受注・戦闘のループを磨いていく。\nUIと世界観の一体感を最優先。",
linkPlay: "",
linkRepo: "",
linkVideo: "",
rating: 5,
images: [],
likes: 30,
comments: [],
createdAt: nowISO(),
updatedAt: nowISO()
}
];
posts = [...seed, ...posts];
savePosts();
render();
toast("サンプルを追加した");
});
// ====== Load draft on start ======
(function init(){
chipCount.textContent = `作品: ${posts.length}`;
const draft = loadDraft();
if(draft){
authorEl.value = draft.author || "";
titleEl.value = draft.title || "";
taglineEl.value = draft.tagline || "";
engineEl.value = draft.engine || "";
statusEl.value = draft.status || "開発中";
platformEl.value = draft.platform || "";
genreEl.value = draft.genre || "";
tagsEl.value = draft.tags || "";
descEl.value = draft.desc || "";
linkPlayEl.value = draft.linkPlay || "";
linkRepoEl.value = draft.linkRepo || "";
linkVideoEl.value = draft.linkVideo || "";
ratingEl.value = String(draft.rating || 0);
pendingImages = Array.isArray(draft.images) ? draft.images : [];
formHintEl.textContent = "下書きを復元しました(保存を押すと投稿になります)";
toast("下書きを復元");
}
render();
})();
</script>
</body>
</html>
カテゴリー: WEBサービス
二次裏のクローンサイト
tyosuke20xx.com/nijiura.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>二次裏クローン(ローカルHTML版・強化)</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
/* ------------------------------
全体レイアウト / ベーススタイル
------------------------------ */
body {
background: #f2f2e9;
color: #333;
font-family: "YuGothic", "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
font-size: 14px;
margin: 0;
padding: 0;
}
header {
background: #d8d8c0;
padding: 10px;
border-bottom: 1px solid #b0b08f;
}
header h1 {
margin: 0;
font-size: 18px;
}
header small {
display: block;
font-size: 11px;
color: #555;
}
.container {
width: 95%;
max-width: 900px;
margin: 10px auto 40px auto;
background: #fff;
border: 1px solid #ccc;
padding: 10px 15px 20px 15px;
box-sizing: border-box;
}
a {
color: #0044cc;
text-decoration: none;
cursor: pointer;
}
a:hover {
text-decoration: underline;
}
.hidden {
display: none;
}
/* ------------------------------
上部ナビ・ステータス
------------------------------ */
.board-nav {
font-size: 12px;
padding: 5px 0 8px 0;
border-bottom: 1px solid #ddd;
margin-bottom: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.board-nav .nav-left {
flex: 1 1 auto;
}
.board-nav .nav-right {
flex: 1 1 auto;
text-align: right;
}
.board-nav label {
font-size: 12px;
margin-left: 6px;
}
.board-nav select,
.board-nav input[type="text"] {
font-size: 12px;
padding: 2px 4px;
}
.board-nav input[type="checkbox"] {
vertical-align: middle;
}
.stats {
font-size: 11px;
color: #555;
}
/* ------------------------------
スレッド一覧
------------------------------ */
.thread-list {
margin-bottom: 20px;
}
.thread-item {
border-bottom: 1px dotted #ccc;
padding: 5px 0;
display: flex;
align-items: flex-start;
gap: 4px;
}
.thread-main {
flex: 1 1 auto;
}
.thread-item:last-child {
border-bottom: none;
}
.thread-title {
font-weight: bold;
}
.thread-category {
font-size: 11px;
color: #444;
background: #f0f0d8;
padding: 1px 4px;
border-radius: 3px;
margin-right: 4px;
}
.meta {
font-size: 11px;
color: #666;
}
/* お気に入りスター */
.fav-toggle {
font-size: 16px;
cursor: pointer;
user-select: none;
padding: 0 2px;
}
.fav-true {
color: #d89a00;
}
.fav-false {
color: #ccc;
}
/* ------------------------------
フォーム共通
------------------------------ */
.form-block {
margin: 20px 0;
padding: 10px;
background: #f7f7ee;
border: 1px solid #ddd;
}
.form-block h2 {
margin: 0 0 5px 0;
font-size: 14px;
}
.form-row {
margin-bottom: 5px;
}
label {
font-size: 12px;
display: inline-block;
width: 70px;
}
input[type="text"],
textarea,
select {
width: 90%;
max-width: 500px;
box-sizing: border-box;
font-size: 13px;
}
textarea {
height: 80px;
}
input[type="submit"],
button {
font-size: 12px;
padding: 3px 10px;
margin-right: 4px;
}
.notice {
font-size: 11px;
color: #999;
margin-top: 5px;
}
/* ------------------------------
レス表示
------------------------------ */
.posts {
margin-top: 10px;
}
.post {
border-top: 1px dotted #ccc;
padding: 5px 0;
}
.post:first-child {
border-top: none;
}
.post-header {
font-size: 12px;
margin-bottom: 3px;
}
.post-body {
font-size: 13px;
white-space: pre-wrap;
word-wrap: break-word;
}
.post-id {
font-family: "Consolas", "Menlo", monospace;
font-size: 11px;
color: #666;
}
.post-no-link {
font-weight: bold;
}
/* ------------------------------
スレッドビューのナビ
------------------------------ */
.nav-top {
margin-bottom: 10px;
font-size: 12px;
}
/* ------------------------------
データツール用エリア
------------------------------ */
#backup-text {
width: 100%;
max-width: 100%;
box-sizing: border-box;
font-size: 12px;
}
/* ------------------------------
設定ブロック
------------------------------ */
#settings-block label {
width: auto;
}
#settings-block input[type="checkbox"] {
width: auto;
}
#setting-last-name {
width: 200px;
}
/* ------------------------------
レスポンシブ調整
------------------------------ */
@media (max-width: 600px) {
label {
display: block;
width: auto;
margin-bottom: 2px;
}
input[type="text"],
textarea,
select {
width: 100%;
}
.board-nav {
flex-direction: column;
align-items: flex-start;
}
.board-nav .nav-right {
text-align: left;
}
.thread-item {
flex-direction: row;
}
}
</style>
</head>
<body>
<header>
<h1>二次裏クローン(ローカルHTML版・強化)</h1>
<small>※ブラウザの localStorage に保存 / 本家とは一切関係ありません</small>
</header>
<div class="container" id="top">
<!-- スレ一覧ビュー -->
<div id="view-list">
<div class="board-nav">
<div class="nav-left">
<span class="stats">
スレ数:<span id="stat-threads">0</span> /
総レス数:<span id="stat-posts">0</span>
</span>
<label>
カテゴリ
<select id="category-filter">
<option value="">全カテゴリ</option>
<option value="雑談">雑談</option>
<option value="ゲーム">ゲーム</option>
<option value="アニメ">アニメ</option>
<option value="ニュース">ニュース</option>
<option value="その他">その他</option>
</select>
</label>
<label>
<input type="checkbox" id="fav-only">
お気に入りのみ
</label>
</div>
<div class="nav-right">
<label>
並び替え
<select id="sort-select">
<option value="updated">更新順</option>
<option value="created">作成順</option>
<option value="id">スレ番号順</option>
</select>
</label>
<label>
検索
<input type="text" id="search-input" placeholder="タイトル・本文から検索">
</label>
</div>
</div>
<h2>スレッド一覧</h2>
<div id="thread-list" class="thread-list">
<!-- JSで一覧を描画 -->
</div>
<div class="form-block">
<h2>新規スレッド作成</h2>
<form id="new-thread-form">
<div class="form-row">
<label>名前</label>
<input type="text" name="name" id="new-name" placeholder="名無し">
</div>
<div class="form-row">
<label>カテゴリ</label>
<select name="category" id="new-category">
<option value="雑談">雑談</option>
<option value="ゲーム">ゲーム</option>
<option value="アニメ">アニメ</option>
<option value="ニュース">ニュース</option>
<option value="その他">その他</option>
</select>
</div>
<div class="form-row">
<label>タイトル</label>
<input type="text" name="title" required>
</div>
<div class="form-row">
<label>本文</label>
<textarea name="body" required></textarea>
</div>
<div class="form-row">
<input type="submit" value="スレ立て">
</div>
<div class="notice">
※超シンプルローカル実装。データはこのブラウザ内だけに保存されます。<br>
※別ブラウザ・シークレットモードでは共有されません。
</div>
</form>
</div>
<!-- ローカルデータ管理ツール -->
<div class="form-block" id="data-tools">
<h2>ローカルデータ管理</h2>
<p class="notice">
このPC / ブラウザだけで使う簡易ツールです。<br>
別環境へ移したい場合は JSON をエクスポートして保存してください。
</p>
<div class="form-row">
<button type="button" id="btn-export">エクスポート</button>
<button type="button" id="btn-import">インポート</button>
<button type="button" id="btn-clear">全消去</button>
</div>
<div class="form-row">
<textarea id="backup-text" rows="4" placeholder="エクスポートしたJSONがここに出力されます。ここに貼り付けてインポートもできます。"></textarea>
</div>
</div>
<!-- 簡易設定 -->
<div class="form-block" id="settings-block">
<h2>ミニ設定</h2>
<div class="form-row">
<label>
<input type="checkbox" id="opt-autoname">
名前を記憶して自動で入れる
</label>
</div>
<div class="form-row">
<label>前回の名前</label>
<input type="text" id="setting-last-name" placeholder="名無し">
</div>
<div class="form-row">
<button type="button" id="btn-save-settings">設定を保存</button>
</div>
<div class="notice">
※名前を記憶しておくと、新規スレ・レス投稿時の名前欄に自動で反映されます。<br>
※これらも localStorage に保存されます。
</div>
</div>
</div>
<!-- 個別スレッドビュー -->
<div id="view-thread" class="hidden">
<div class="nav-top">
<a id="back-to-list"><< スレ一覧に戻る</a> |
<a href="#bottom">▼ 一番下へ</a> |
<a href="#top">▲ ページ先頭へ</a>
</div>
<h2 id="thread-title"></h2>
<div class="meta" id="thread-meta"></div>
<div id="posts" class="posts">
<!-- JSでレスを描画 -->
</div>
<div class="form-block">
<h2>レスを書く</h2>
<form id="reply-form">
<div class="form-row">
<label>名前</label>
<input type="text" name="name" id="reply-name" placeholder="名無し">
</div>
<div class="form-row">
<label>本文</label>
<textarea name="body" id="reply-body" required></textarea>
</div>
<div class="form-row">
<input type="submit" value="レス投稿">
</div>
<div class="notice">
※レス番号をクリックすると本文に「>>番号」が入ります。<br>
※画像アップロード機能はこのHTML版には入れていません。<br>
※本格運用したい場合はPHPやDB版で実装してください。
</div>
</form>
</div>
</div>
</div>
<div id="bottom"></div>
<script>
(function() {
"use strict";
const STORAGE_KEY = "nijiura_clone_threads_v1";
const SETTINGS_KEY = "nijiura_clone_settings_v1";
// --------------------
// データ構造
// --------------------
// threads = [
// {
// id: number,
// title: string,
// category: string,
// favorite: boolean,
// createdAt: string,
// updatedAt: string,
// posts: [
// { id, name, body, createdAt, uid }
// ]
// }, ...
// ]
//
// settings = {
// autoName: boolean,
// lastName: string
// }
let threads = loadThreads();
let settings = loadSettings();
let currentThreadId = null;
let currentSort = "updated"; // updated / created / id
const viewList = document.getElementById("view-list");
const viewThread = document.getElementById("view-thread");
const threadListEl = document.getElementById("thread-list");
const threadTitleEl = document.getElementById("thread-title");
const threadMetaEl = document.getElementById("thread-meta");
const postsEl = document.getElementById("posts");
const newThreadForm = document.getElementById("new-thread-form");
const replyForm = document.getElementById("reply-form");
const backToListBtn = document.getElementById("back-to-list");
const searchInput = document.getElementById("search-input");
const sortSelect = document.getElementById("sort-select");
const categoryFilter = document.getElementById("category-filter");
const favOnlyCheckbox = document.getElementById("fav-only");
const statThreadsEl = document.getElementById("stat-threads");
const statPostsEl = document.getElementById("stat-posts");
const btnExport = document.getElementById("btn-export");
const btnImport = document.getElementById("btn-import");
const btnClear = document.getElementById("btn-clear");
const backupText = document.getElementById("backup-text");
const optAutoname = document.getElementById("opt-autoname");
const settingLastName = document.getElementById("setting-last-name");
const btnSaveSettings = document.getElementById("btn-save-settings");
const newNameInput = document.getElementById("new-name");
const newCategorySelect = document.getElementById("new-category");
const replyNameInput = document.getElementById("reply-name");
const replyBodyTextarea = document.getElementById("reply-body");
// 既存データにカテゴリ・お気に入り・UIDがなければ補完
migrateThreads();
// 初期ソート&描画
sortThreadsByUpdated();
applySettingsToUI();
renderThreadList();
showListView();
// --------------------
// イベント: スレ立て
// --------------------
newThreadForm.addEventListener("submit", function(e) {
e.preventDefault();
const formData = new FormData(newThreadForm);
let name = (formData.get("name") || "").toString().trim();
const category = (formData.get("category") || "雑談").toString();
const title = (formData.get("title") || "").toString().trim();
const body = (formData.get("body") || "").toString().trim();
if (!name) {
name = "名無し";
}
if (!title || !body) {
alert("タイトルと本文は必須です。");
return;
}
const now = getNowStr();
const newId = getNewThreadId();
const newThread = {
id: newId,
title: title,
category: category || "雑談",
favorite: false,
createdAt: now,
updatedAt: now,
posts: [
{
id: 1,
name: name,
body: body,
createdAt: now,
uid: generateUid(newId, 1, name, body, now)
}
]
};
threads.push(newThread);
sortThreadsByUpdated();
saveThreads(threads);
// 名前を設定に反映
updateLastNameSetting(name);
newThreadForm.reset();
currentSort = "updated";
sortSelect.value = "updated";
openThread(newId);
});
// --------------------
// イベント: レス投稿
// --------------------
replyForm.addEventListener("submit", function(e) {
e.preventDefault();
if (currentThreadId === null) return;
const formData = new FormData(replyForm);
let name = (formData.get("name") || "").toString().trim();
const body = (formData.get("body") || "").toString().trim();
if (!name) {
name = "名無し";
}
if (!body) {
alert("本文は必須です。");
return;
}
const thread = threads.find(t => t.id === currentThreadId);
if (!thread) {
alert("スレッドが見つかりません。");
return;
}
const now = getNowStr();
const newPostId = getNewPostId(thread);
thread.posts.push({
id: newPostId,
name: name,
body: body,
createdAt: now,
uid: generateUid(thread.id, newPostId, name, body, now)
});
thread.updatedAt = now;
sortThreadsByUpdated();
saveThreads(threads);
// 名前を設定に反映
updateLastNameSetting(name);
replyForm.reset();
renderThread(thread);
renderThreadList(); // 更新日時が変わるので一覧も更新
});
// --------------------
// イベント: 一覧に戻る
// --------------------
backToListBtn.addEventListener("click", function() {
currentThreadId = null;
showListView();
});
// --------------------
// イベント: 並び替え&検索&フィルタ
// --------------------
sortSelect.addEventListener("change", function() {
currentSort = this.value;
renderThreadList();
});
searchInput.addEventListener("input", function() {
renderThreadList();
});
categoryFilter.addEventListener("change", function() {
renderThreadList();
});
favOnlyCheckbox.addEventListener("change", function() {
renderThreadList();
});
// --------------------
// イベント: データツール
// --------------------
btnExport.addEventListener("click", function() {
try {
const json = JSON.stringify(threads, null, 2);
backupText.value = json;
alert("現在のデータをJSONとして出力しました。必要ならコピーして保存してください。");
} catch (e) {
console.warn("export failed:", e);
alert("エクスポートに失敗しました。");
}
});
btnImport.addEventListener("click", function() {
const text = backupText.value.trim();
if (!text) {
alert("インポートするJSONが入力されていません。");
return;
}
if (!confirm("テキストエリアのJSONで現在のデータを上書きします。よろしいですか?")) {
return;
}
try {
const data = JSON.parse(text);
if (!Array.isArray(data)) {
alert("JSONの形式が不正です。(配列ではありません)");
return;
}
threads = data;
migrateThreads(); // 新フィールドを補完
sortThreadsByUpdated();
saveThreads(threads);
currentThreadId = null;
showListView();
alert("インポートに成功しました。");
} catch (e) {
console.warn("import failed:", e);
alert("JSONの解析に失敗しました。形式が正しいか確認してください。");
}
});
btnClear.addEventListener("click", function() {
if (!confirm("本当に全データを削除しますか?(取り消しできません)")) {
return;
}
try {
localStorage.removeItem(STORAGE_KEY);
} catch (e) {
console.warn("clear failed:", e);
}
threads = [];
currentThreadId = null;
renderThreadList();
showListView();
alert("全データを削除しました。");
});
// --------------------
// イベント: 設定
// --------------------
btnSaveSettings.addEventListener("click", function() {
const autoName = !!optAutoname.checked;
const lastName = (settingLastName.value || "").toString().trim() || "名無し";
settings.autoName = autoName;
settings.lastName = lastName;
saveSettings(settings);
applySettingsToUI();
alert("設定を保存しました。");
});
// --------------------
// イベント: レス番クリック(>>アンカー挿入)
// --------------------
postsEl.addEventListener("click", function(e) {
const target = e.target;
if (target && target.classList.contains("post-no-link")) {
e.preventDefault();
const no = target.getAttribute("data-no");
if (!no) return;
insertAnchorToReply(">>" + no + "\n");
}
});
// --------------------
// ビュー切替
// --------------------
function showListView() {
viewList.classList.remove("hidden");
viewThread.classList.add("hidden");
renderThreadList();
}
function showThreadView() {
viewList.classList.add("hidden");
viewThread.classList.remove("hidden");
}
// --------------------
// レンダリング: 一覧
// --------------------
function renderThreadList() {
updateStats();
if (!threads.length) {
threadListEl.innerHTML = "<p>まだスレッドはありません。</p>";
return;
}
const query = (searchInput.value || "").toString().trim();
const filterCategory = (categoryFilter.value || "").toString();
const favOnly = !!favOnlyCheckbox.checked;
let list = threads.slice();
// 検索
if (query) {
list = list.filter(function(t) {
const q = query;
if (t.title && t.title.indexOf(q) !== -1) return true;
if (Array.isArray(t.posts)) {
return t.posts.some(function(p) {
return p.body && p.body.indexOf(q) !== -1;
});
}
return false;
});
}
// カテゴリフィルタ
if (filterCategory) {
list = list.filter(function(t) {
return (t.category || "雑談") === filterCategory;
});
}
// お気に入りのみ
if (favOnly) {
list = list.filter(function(t) {
return !!t.favorite;
});
}
// 並び替え
if (currentSort === "created") {
list.sort(function(a, b) {
if (a.createdAt < b.createdAt) return 1;
if (a.createdAt > b.createdAt) return -1;
return 0;
});
} else if (currentSort === "id") {
list.sort(function(a, b) {
return b.id - a.id; // 新しい番号が上
});
} else {
// 更新順は threads 自体を sortThreadsByUpdated で管理しているのでそのまま
// ただしフィルタ・検索後も順序を維持するだけ
list.sort(function(a, b) {
if (a.updatedAt < b.updatedAt) return 1;
if (a.updatedAt > b.updatedAt) return -1;
return 0;
});
}
if (!list.length) {
threadListEl.innerHTML = "<p>条件に一致するスレッドはありません。</p>";
return;
}
let html = "";
list.forEach(function(t) {
const cat = t.category || "雑談";
const fav = !!t.favorite;
const favClass = fav ? "fav-true" : "fav-false";
const favSymbol = fav ? "★" : "☆";
html += `
<div class="thread-item">
<span class="fav-toggle ${favClass}" data-thread-id="${t.id}" title="お気に入り切り替え">${favSymbol}</span>
<div class="thread-main">
<span class="thread-title">
<span class="thread-category">[${escapeHtml(cat)}]</span>
<a data-thread-id="${t.id}" class="thread-link">
${escapeHtml(t.title)}
</a>
</span><br>
<span class="meta">
No.${t.id} / 作成:${escapeHtml(t.createdAt)} /
最終更新:${escapeHtml(t.updatedAt)} /
レス:${t.posts ? t.posts.length : 0}
</span>
</div>
</div>
`;
});
threadListEl.innerHTML = html;
// スレッドリンクイベント
const links = threadListEl.querySelectorAll(".thread-link");
links.forEach(function(link) {
link.addEventListener("click", function() {
const id = parseInt(this.getAttribute("data-thread-id"), 10);
openThread(id);
});
});
// お気に入り切り替えイベント
const favToggles = threadListEl.querySelectorAll(".fav-toggle");
favToggles.forEach(function(btn) {
btn.addEventListener("click", function() {
const id = parseInt(this.getAttribute("data-thread-id"), 10);
toggleFavorite(id, this);
});
});
}
// --------------------
// レンダリング: 個別スレ
// --------------------
function renderThread(thread) {
currentThreadId = thread.id;
const cat = thread.category || "雑談";
threadTitleEl.textContent = "[" + cat + "] " + thread.title;
threadMetaEl.textContent =
"スレ番号:" + thread.id +
" 作成:" + thread.createdAt +
" 最終更新:" + thread.updatedAt +
" レス数:" + (thread.posts ? thread.posts.length : 0);
if (!thread.posts || !thread.posts.length) {
postsEl.innerHTML = "<p>まだレスはありません。</p>";
return;
}
let html = "";
thread.posts.forEach(function(p) {
const uid = p.uid || generateUid(thread.id, p.id, p.name, p.body, p.createdAt);
html += `
<div class="post">
<div class="post-header">
<a href="#" class="post-no-link" data-no="${p.id}">No.${p.id}</a>
名前:${escapeHtml(p.name)}
投稿日:${escapeHtml(p.createdAt)}
ID:<span class="post-id">${escapeHtml(uid)}</span>
</div>
<div class="post-body">
${escapeHtml(p.body).replace(/\n/g, "<br>")}
</div>
</div>
`;
});
postsEl.innerHTML = html;
// 自動名前反映
applyAutoNameToReply();
}
function openThread(id) {
const thread = threads.find(t => t.id === id);
if (!thread) {
alert("スレッドが見つかりません。");
return;
}
renderThread(thread);
showThreadView();
}
// --------------------
// お気に入り切り替え
// --------------------
function toggleFavorite(threadId, element) {
const thread = threads.find(t => t.id === threadId);
if (!thread) return;
thread.favorite = !thread.favorite;
saveThreads(threads);
const fav = !!thread.favorite;
element.textContent = fav ? "★" : "☆";
element.classList.toggle("fav-true", fav);
element.classList.toggle("fav-false", !fav);
}
// --------------------
// ストレージ操作
// --------------------
function loadThreads() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const data = JSON.parse(raw);
if (!Array.isArray(data)) return [];
return data;
} catch (e) {
console.warn("failed to load threads:", e);
return [];
}
}
function saveThreads(data) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch (e) {
console.warn("failed to save threads:", e);
alert("保存に失敗しました。localStorage容量オーバーの可能性があります。");
}
}
function loadSettings() {
try {
const raw = localStorage.getItem(SETTINGS_KEY);
if (!raw) {
return {
autoName: false,
lastName: "名無し"
};
}
const data = JSON.parse(raw);
if (!data || typeof data !== "object") {
return {
autoName: false,
lastName: "名無し"
};
}
if (typeof data.autoName !== "boolean") {
data.autoName = false;
}
if (typeof data.lastName !== "string") {
data.lastName = "名無し";
}
return data;
} catch (e) {
console.warn("failed to load settings:", e);
return {
autoName: false,
lastName: "名無し"
};
}
}
function saveSettings(data) {
try {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(data));
} catch (e) {
console.warn("failed to save settings:", e);
}
}
// --------------------
// 統計更新
// --------------------
function updateStats() {
statThreadsEl.textContent = threads.length;
let totalPosts = 0;
threads.forEach(function(t) {
if (Array.isArray(t.posts)) {
totalPosts += t.posts.length;
}
});
statPostsEl.textContent = totalPosts;
}
// --------------------
// ID・日付・エスケープ
// --------------------
function getNewThreadId() {
if (!threads.length) return 1;
const ids = threads.map(function(t) { return t.id; });
return Math.max.apply(null, ids) + 1;
}
function getNewPostId(thread) {
if (!thread.posts || !thread.posts.length) return 1;
const ids = thread.posts.map(function(p) { return p.id; });
return Math.max.apply(null, ids) + 1;
}
function getNowStr() {
const d = new Date();
const y = d.getFullYear();
const m = ("0" + (d.getMonth() + 1)).slice(-2);
const day = ("0" + d.getDate()).slice(-2);
const h = ("0" + d.getHours()).slice(-2);
const min = ("0" + d.getMinutes()).slice(-2);
const s = ("0" + d.getSeconds()).slice(-2);
return y + "/" + m + "/" + day + " " + h + ":" + min + ":" + s;
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// 簡易ハッシュでUIDを生成(8桁16進)
function generateUid(threadId, postId, name, body, createdAt) {
const src = String(threadId) + ":" + String(postId) + ":" +
String(name) + ":" + String(body) + ":" + String(createdAt);
let hash = 0;
for (let i = 0; i < src.length; i++) {
hash = ((hash << 5) - hash) + src.charCodeAt(i);
hash |= 0; // 32bit
}
// 符号を外す
if (hash < 0) {
hash = ~hash + 1;
}
let hex = hash.toString(16);
if (hex.length < 8) {
hex = ("00000000" + hex).slice(-8);
} else if (hex.length > 8) {
hex = hex.slice(-8);
}
return hex;
}
// --------------------
// ソート
// --------------------
function sortThreadsByUpdated() {
threads.sort(function(a, b) {
if (a.updatedAt < b.updatedAt) return 1;
if (a.updatedAt > b.updatedAt) return -1;
return 0;
});
}
// --------------------
// 設定適用
// --------------------
function applySettingsToUI() {
optAutoname.checked = !!settings.autoName;
settingLastName.value = settings.lastName || "名無し";
if (settings.autoName) {
if (newNameInput) newNameInput.value = settings.lastName || "名無し";
if (replyNameInput) replyNameInput.value = settings.lastName || "名無し";
}
}
function applyAutoNameToReply() {
if (settings.autoName) {
replyNameInput.value = settings.lastName || "名無し";
}
}
function updateLastNameSetting(name) {
settings.lastName = name || "名無し";
if (settings.autoName) {
// UIへ即時反映
settingLastName.value = settings.lastName;
if (newNameInput) newNameInput.value = settings.lastName;
if (replyNameInput) replyNameInput.value = settings.lastName;
}
saveSettings(settings);
}
// --------------------
// レス番アンカー挿入
// --------------------
function insertAnchorToReply(text) {
replyBodyTextarea.focus();
const start = replyBodyTextarea.selectionStart;
const end = replyBodyTextarea.selectionEnd;
const value = replyBodyTextarea.value;
replyBodyTextarea.value = value.slice(0, start) + text + value.slice(end);
// キャレット位置を挿入したテキストの後ろに
const pos = start + text.length;
replyBodyTextarea.selectionStart = replyBodyTextarea.selectionEnd = pos;
}
// --------------------
// 既存データのマイグレーション
// --------------------
function migrateThreads() {
threads.forEach(function(t) {
if (!t.category) {
t.category = "雑談";
}
if (typeof t.favorite !== "boolean") {
t.favorite = false;
}
if (!Array.isArray(t.posts)) {
t.posts = [];
}
t.posts.forEach(function(p) {
if (!p.uid) {
p.uid = generateUid(t.id, p.id, p.name || "名無し", p.body || "", p.createdAt || "");
}
});
});
saveThreads(threads);
}
})();
</script>
</body>
</html>
Tsumugi
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Tsumugi</title>
<!-- Favicon -->
<link rel="shortcut icon"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path fill='%23667eea' d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z'/></svg>"/>
<!-- 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"/>
<style>
:root {
--grad-a: #667eea;
--grad-b: #764ba2;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: radial-gradient(circle at top left, #1f2937 0%, #111827 40%, #020617 100%);
min-height: 100vh;
color: #111827;
}
.glass-effect {
background: radial-gradient(circle at top left, rgba(255,255,255,0.15), rgba(255,255,255,0.03));
backdrop-filter: blur(18px);
border-radius: 18px;
border: 1px solid rgba(255,255,255,0.16);
}
.card-hover { transition: all 0.25s ease; }
.card-hover:hover { transform: translateY(-3px); box-shadow: 0 20px 30px -12px rgba(0,0,0,0.45); }
.gradient-text {
background: linear-gradient(90deg, var(--grad-a), var(--grad-b));
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.timeline-post {
background: rgba(15,23,42,0.96);
border-radius: 18px;
box-shadow: 0 14px 30px -16px rgba(0,0,0,0.7);
transition: all 0.25s ease;
border-left: 4px solid var(--grad-a);
}
.timeline-post:hover { transform: translateX(4px); }
.profile-avatar {
width: 100px; height: 100px; border-radius: 50%;
object-fit: cover; border: 3px solid rgba(255,255,255,0.9);
box-shadow: 0 10px 24px rgba(0,0,0,0.35);
}
.mini-avatar {
width: 46px; height: 46px; border-radius: 50%; object-fit: cover;
border: 2px solid rgba(255,255,255,0.9);
}
.btn-primary {
background: linear-gradient(135deg, var(--grad-a), var(--grad-b));
border: none; transition: all 0.2s ease;
}
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 10px 18px rgba(0,0,0,0.4); }
.section-divider {
height: 3px; background: linear-gradient(90deg, var(--grad-a), var(--grad-b));
border-radius: 999px; margin: 2rem 0;
}
.username-badge {
background: radial-gradient(circle at top left, var(--grad-a), var(--grad-b));
color: white; padding: 0.2rem 0.6rem; border-radius: 999px;
font-size: 0.75rem; font-weight: 600; display: inline-flex;
align-items: center; margin-left: 0.5rem;
}
.username-badge i { margin-right: 4px; }
.share-menu { position: absolute; z-index: 50; min-width: 180px; right: 0; top: 110%; background: #020617; border-radius: 12px; box-shadow: 0 18px 45px rgba(0,0,0,0.75); border: 1px solid rgba(148,163,184,0.5); }
.share-menu button { width: 100%; text-align: left; padding: 10px 20px; border: none; background: none; cursor: pointer; font-size: 0.95rem; color: #e5e7eb; }
.share-menu button:hover { background: rgba(51,65,85,0.9); }
.status-indicator { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
.status-active { background-color: #10b981; animation: pulse 1.5s infinite; }
.status-inactive { background-color: #6b7280; }
.log-container {
max-height: 180px; overflow-y: auto; background: rgba(15,23,42,0.85);
border-radius: 10px; padding: 10px; margin-top: 10px; font-size: 0.8rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
border: 1px solid rgba(148,163,184,0.5); color: #e5e7eb;
}
.error-message { color: #fecaca; background: rgba(127,29,29,0.6); padding: 8px; border-radius: 8px; margin: 5px 0; }
.success-message { color: #bbf7d0; background: rgba(6,95,70,0.6); padding: 8px; border-radius: 8px; margin: 5px 0; }
@keyframes pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(1.1); } }
.dark .glass-effect { background: rgba(15,23,42,0.92); border: 1px solid rgba(148,163,184,0.4); }
.dark .timeline-post { background: #020617; color: #f9fafb; border-left-color: #4f46e5; }
.dark .share-menu { background: #020617; color: #e5e7eb; }
.dark .success-message { background: rgba(6,95,70,0.7); }
.dark .error-message { background: rgba(127,29,29,0.7); }
.icon-label {
font-size: 0.8rem; color: #e5e7eb; text-transform: uppercase; letter-spacing: 0.05em;
}
@media print {
body { background: white !important; -webkit-print-color-adjust: exact; }
.glass-effect { background: white !important; backdrop-filter: none !important; border: 1px solid #e5e7eb !important; }
.timeline-post { background: white !important; color: #111827 !important; }
}
</style>
</head>
<body class="dark text-gray-100">
<!-- ログイン/登録モーダル -->
<div id="auth-modal" class="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50" style="display:none">
<div class="bg-gray-900 rounded-2xl shadow-2xl w-full max-w-sm p-6 border border-gray-700">
<h2 class="text-2xl font-bold mb-4 text-center gradient-text" id="auth-title">ログイン</h2>
<div id="auth-error" class="error-message mb-2" style="display:none"></div>
<form id="auth-form" autocomplete="off">
<div class="mb-3">
<label class="block mb-1 text-xs font-semibold text-gray-300">メールアドレス</label>
<input type="email" id="auth-email" class="w-full border border-gray-700 bg-gray-800 rounded px-3 py-2 text-sm text-gray-100" required>
</div>
<div class="mb-3">
<label class="block mb-1 text-xs font-semibold text-gray-300">パスワード</label>
<input type="password" id="auth-password" class="w-full border border-gray-700 bg-gray-800 rounded px-3 py-2 text-sm text-gray-100" required>
</div>
<button type="submit" class="btn-primary w-full py-2 rounded-lg text-white font-semibold text-sm mt-2">
<i class="fas fa-sign-in-alt mr-2"></i>ログイン
</button>
</form>
<div class="mt-4 text-center">
<button id="toggle-auth-mode" class="text-indigo-400 underline text-xs">新規登録はこちら</button>
</div>
</div>
</div>
<!-- ヘッダー -->
<header class="glass-effect mx-4 mt-4 p-6 border border-indigo-500/40">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0">
<div class="text-left">
<h1 class="text-3xl md:text-4xl font-extrabold text-white mb-1 tracking-tight">
<i class="fas fa-comments mr-3 text-indigo-300"></i>
<span class="gradient-text">Tsumugi</span>
<span class="ml-2 text-xs px-2 py-1 rounded-full bg-indigo-500/20 border border-indigo-400/60 align-middle">Verse Core v3.0</span>
</h1>
<p class="text-indigo-100 text-sm md:text-base opacity-90">
次世代ソーシャルネットワーク • RSS / BOT 専用エディション(AI機能なし)
</p>
</div>
<div class="flex flex-wrap items-center justify-end gap-3">
<div class="flex items-center bg-slate-900/70 rounded-2xl px-3 py-2 shadow-inner border border-slate-700">
<img id="header-profile-icon" class="mini-avatar" src="https://via.placeholder.com/80" alt="プロフィール">
<div class="ml-3 text-left">
<div class="font-semibold text-sm" id="header-username">未設定</div>
<div class="text-xs text-slate-300 opacity-75" id="header-user-email"></div>
</div>
</div>
<button onclick="toggleDarkMode()" class="btn-primary px-4 py-2 rounded-full text-white text-xs flex items-center">
<i class="fas fa-moon mr-2"></i><span>テーマ切替</span>
</button>
<button onclick="showSystemStatus()" class="bg-slate-900 hover:bg-slate-800 px-4 py-2 rounded-full text-white text-xs border border-slate-600 flex items-center">
<i class="fas fa-info-circle mr-2"></i>ステータス
</button>
<button onclick="clearVerseCache()" class="bg-yellow-400 hover:bg-yellow-500 px-4 py-2 rounded-full text-black text-xs flex items-center">
<i class="fas fa-broom mr-2"></i>キャッシュクリア
</button>
<button id="logout-btn" onclick="logout()" class="bg-red-600 hover:bg-red-700 px-4 py-2 rounded-full text-white text-xs flex items-center hidden">
<i class="fas fa-sign-out-alt mr-2"></i>ログアウト
</button>
</div>
</div>
</header>
<!-- メイン -->
<div class="max-w-6xl mx-auto px-4 py-6" id="main-content" style="display:none">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 左カラム:プロフィール/BOT/RSS -->
<div class="lg:col-span-1 space-y-6">
<!-- プロフィール -->
<div class="glass-effect p-6 card-hover border border-slate-600">
<h3 class="text-2xl font-bold gradient-text mb-4 flex items-center">
<i class="fas fa-user-circle mr-2 text-indigo-300"></i>プロフィール
</h3>
<div class="text-center mb-6">
<img id="profile-icon" class="profile-avatar mx-auto mb-4" src="https://via.placeholder.com/100" alt="プロフィール">
<input type="file" id="profile-upload" accept="image/*" onchange="uploadProfileIcon(event)" class="hidden">
<button onclick="document.getElementById('profile-upload').click()" 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="space-y-4">
<div>
<label class="block text-gray-200 font-semibold mb-1 text-xs">ユーザー名</label>
<input type="text" id="username" class="w-full p-3 border border-slate-600 rounded-lg bg-slate-900 text-gray-100 text-sm" placeholder="ユーザー名を入力" maxlength="20">
</div>
<div>
<label class="block text-gray-200 font-semibold mb-1 text-xs">自己紹介</label>
<textarea id="self-intro" class="w-full p-3 border border-slate-600 rounded-lg bg-slate-900 text-gray-100 text-sm" rows="4" placeholder="自己紹介を入力"></textarea>
</div>
<button onclick="saveProfile()" 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="p-3 bg-slate-900/80 rounded-lg border border-slate-700">
<h5 class="font-semibold text-gray-200 mb-2 text-xs">プレビュー</h5>
<div class="text-gray-300 text-sm">
<div class="font-semibold mb-1" id="username-preview">未設定</div>
<div id="self-intro-preview" class="text-xs whitespace-pre-line min-h-8">まだ自己紹介がありません</div>
</div>
</div>
</div>
</div>
<!-- BOT/Feed アイコン設定 -->
<div class="glass-effect p-6 card-hover border border-indigo-500/40">
<h3 class="text-xl font-bold text-indigo-100 mb-4 flex items-center">
<i class="fas fa-icons mr-2 text-indigo-300"></i>BOT / Feed アイコン設定
</h3>
<div class="space-y-4 text-xs">
<div class="flex items-center space-x-3">
<img id="bot-icon-preview" class="mini-avatar" src="https://cdn-icons-png.flaticon.com/512/4712/4712109.png" alt="BOT">
<div class="flex-1">
<div class="icon-label">BOT / Markov BOT</div>
<input type="file" id="bot-icon-upload" accept="image/*" class="hidden" onchange="uploadIcon('bot', event)">
<button onclick="document.getElementById('bot-icon-upload').click()" class="bg-slate-900 hover:bg-slate-800 px-3 py-1 rounded-full text-gray-100 text-[11px] border border-slate-600 mt-1">
<i class="fas fa-robot mr-1"></i>BOTアイコン変更
</button>
</div>
</div>
<div class="flex items-center space-x-3">
<img id="feed-icon-preview" class="mini-avatar" src="https://cdn-icons-png.flaticon.com/512/3416/3416046.png" alt="Feed">
<div class="flex-1">
<div class="icon-label">RSS FEED BOT</div>
<input type="file" id="feed-icon-upload" accept="image/*" class="hidden" onchange="uploadIcon('feed', event)">
<button onclick="document.getElementById('feed-icon-upload').click()" class="bg-slate-900 hover:bg-slate-800 px-3 py-1 rounded-full text-gray-100 text-[11px] border border-slate-600 mt-1">
<i class="fas fa-rss mr-1"></i>Feedアイコン変更
</button>
</div>
</div>
</div>
</div>
<!-- RSS自動投稿機能 -->
<div class="glass-effect p-6 card-hover border border-amber-500/40">
<h3 class="text-xl font-bold text-amber-100 mb-4">
<i class="fas fa-rss mr-2 text-amber-300"></i>RSS自動投稿(全体共有)
<span class="status-indicator" id="rss-status"></span>
<span id="rss-status-text" class="text-xs opacity-75">停止中</span>
</h3>
<div>
<input id="rss-url" type="text" class="w-full p-2 border border-slate-600 rounded-lg bg-slate-900 text-gray-100 text-xs mb-2" placeholder="RSSフィードURLを入力">
<button onclick="addRssFeed()" class="btn-primary w-full py-2 rounded-lg text-white mb-2 text-xs">
<i class="fas fa-plus mr-2"></i>追加
</button>
<div id="rss-list" class="mb-3 text-xs"></div>
<div class="flex items-center space-x-2 mb-2">
<input type="number" id="rss-interval" class="w-1/2 p-2 border border-slate-600 rounded-lg bg-slate-900 text-gray-100 text-xs" min="10" max="3600" value="300" placeholder="間隔(秒)">
<button onclick="setRssInterval()" class="btn-primary flex-1 py-2 rounded-lg text-white text-xs">
<i class="fas fa-clock mr-2"></i>間隔設定
</button>
</div>
<div class="flex items-center space-x-2 mb-2">
<button onclick="fetchRssNow()" class="btn-primary flex-1 py-2 rounded-lg text-white text-xs">
<i class="fas fa-sync mr-2"></i>今すぐ取得
</button>
<button onclick="stopRssAuto()" class="bg-red-600 hover:bg-red-700 flex-1 py-2 rounded-lg text-white text-xs">
<i class="fas fa-stop mr-2"></i>自動停止
</button>
</div>
<div class="flex items-center space-x-2 text-xs">
<button onclick="setAllRssEnabled(true)" class="btn-primary flex-1 py-2 rounded-lg text-white text-[11px]">
<i class="fas fa-toggle-on mr-1"></i>すべてON
</button>
<button onclick="setAllRssEnabled(false)" class="bg-gray-600 hover:bg-gray-700 flex-1 py-2 rounded-lg text-white text-[11px]">
<i class="fas fa-toggle-off mr-1"></i>すべてOFF
</button>
</div>
<div id="rss-log" class="log-container text-xs mt-3"></div>
</div>
</div>
<!-- BOT機能 -->
<div class="glass-effect p-6 card-hover border border-emerald-500/40">
<h3 class="text-xl font-bold text-emerald-100 mb-4">
<i class="fas fa-robot mr-2 text-emerald-300"></i>BOT機能
<span class="status-indicator" id="bot-status"></span>
<span id="bot-status-text" class="text-xs opacity-75">停止中</span>
</h3>
<div class="space-y-4">
<div>
<textarea id="botContent" class="w-full p-3 border border-slate-600 rounded-lg bg-slate-900 text-gray-100 text-sm" rows="3" placeholder="BOT投稿内容"></textarea>
<button onclick="postBotMessage()" class="btn-primary w-full mt-2 py-2 rounded-lg text-white text-xs">
<i class="fas fa-robot mr-2"></i>BOT投稿
</button>
</div>
<div>
<input type="number" id="botIntervalSec" class="w-full p-2 border border-slate-600 rounded-lg bg-slate-900 text-gray-100 text-xs" placeholder="マルコフ自動投稿間隔(秒)" min="10" max="3600" value="60">
<div class="flex space-x-2 mt-2">
<button onclick="postMarkovBot()" class="btn-primary flex-1 py-2 rounded-lg text-white text-xs">
<i class="fas fa-dice mr-2"></i>マルコフ生成
</button>
<button onclick="startBotAutoPost()" class="btn-primary flex-1 py-2 rounded-lg text-white text-xs">
<i class="fas fa-play mr-2"></i>自動開始
</button>
<button onclick="stopBotAutoPost()" class="bg-red-600 hover:bg-red-700 flex-1 py-2 rounded-lg text-white text-xs">
<i class="fas fa-stop mr-2"></i>停止
</button>
</div>
</div>
<div class="text-emerald-100 text-xs opacity-80">
<i class="fas fa-info-circle mr-1"></i>
マルコフ連鎖ではユーザー/BOT投稿のみを学習し、RSS記事本文は学習対象から除外します。
</div>
<div id="bot-log" class="log-container text-xs"></div>
</div>
</div>
</div>
<!-- 右カラム:投稿&タイムライン -->
<div class="lg:col-span-2 space-y-6">
<!-- 新規投稿 -->
<div class="glass-effect p-6 card-hover border border-slate-600">
<h3 class="text-2xl font-bold gradient-text mb-4 flex items-center">
<i class="fas fa-edit mr-2 text-indigo-300"></i>新規投稿
</h3>
<div>
<textarea id="postContent" class="w-full p-4 border border-slate-600 rounded-lg bg-slate-900 text-gray-100 text-sm" rows="4" placeholder="今何を考えていますか?(Ctrl+Enter で投稿)" maxlength="500"></textarea>
<div class="mt-4 flex flex-col md:flex-row md:items-center md:justify-between space-y-3 md:space-y-0">
<div class="text-indigo-100 text-xs opacity-90 flex items-center space-x-2">
<i class="fas fa-info-circle"></i>
<span>あなたの思いを共有しましょう</span>
<span id="char-count" class="ml-2 px-2 py-1 rounded-full bg-slate-900 border border-slate-600">(0/500)</span>
</div>
<div class="flex space-x-2">
<button onclick="createUserPost()" class="btn-primary px-5 py-2 rounded-lg text-white text-xs font-semibold">
<i class="fas fa-paper-plane mr-2"></i>投稿する
</button>
</div>
</div>
</div>
</div>
<div class="section-divider"></div>
<!-- タイムライン -->
<div class="glass-effect p-6 border border-slate-600">
<div class="flex flex-col md:flex-row md:justify-between md:items-center mb-4 space-y-3 md:space-y-0">
<h3 class="text-2xl font-bold text-indigo-100 flex items-center">
<i class="fas fa-stream mr-2 text-indigo-300"></i>タイムライン
<span id="post-count" class="text-sm font-normal opacity-75 ml-2">(0件の投稿)</span>
</h3>
<div class="flex space-x-2">
<button onclick="clearAllPosts()" class="bg-red-600 hover:bg-red-700 px-3 py-1 rounded text-white text-xs flex items-center">
<i class="fas fa-trash mr-1"></i>全削除
</button>
<button onclick="exportData()" class="bg-emerald-600 hover:bg-emerald-700 px-3 py-1 rounded text-white text-xs flex items-center">
<i class="fas fa-download mr-1"></i>エクスポート
</button>
</div>
</div>
<!-- フィルター&検索 -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-4 space-y-3 md:space-y-0">
<div class="flex flex-wrap gap-2 text-xs">
<button id="filter-all" onclick="setFilter('all')" class="px-3 py-1 rounded-full border border-slate-600 bg-indigo-600 text-white flex items-center">
<i class="fas fa-globe mr-1"></i>すべて
</button>
<button id="filter-user" onclick="setFilter('user')" class="px-3 py-1 rounded-full border border-slate-600 text-slate-200 flex items-center">
<i class="fas fa-user mr-1"></i>ユーザー
</button>
<button id="filter-bot" onclick="setFilter('bot')" class="px-3 py-1 rounded-full border border-slate-600 text-slate-200 flex items-center">
<i class="fas fa-robot mr-1"></i>BOT
</button>
<button id="filter-feed" onclick="setFilter('feed')" class="px-3 py-1 rounded-full border border-slate-600 text-slate-200 flex items-center">
<i class="fas fa-rss mr-1"></i>Feed
</button>
</div>
<div class="relative w-full md:w-64">
<input id="timeline-search" type="text" class="w-full pl-8 pr-3 py-2 rounded-full bg-slate-900 border border-slate-600 text-xs text-slate-100" placeholder="キーワード検索(本文・ユーザー名)">
<i class="fas fa-search text-slate-400 text-xs absolute left-2.5 top-1/2 transform -translate-y-1/2"></i>
</div>
</div>
<div id="timeline" class="space-y-4"></div>
<div id="empty-timeline" class="text-center py-12 text-slate-200 opacity-80">
<i class="fas fa-comments text-4xl mb-4 text-indigo-300"></i>
<p class="text-lg">まだ投稿がありません</p>
<p class="text-xs text-slate-300">最初の投稿をして、タイムラインを始めましょう!</p>
</div>
</div>
</div>
</div>
</div>
<footer class="glass-effect mx-4 mb-4 p-4 text-center border border-slate-700">
<p class="text-slate-200 opacity-80 text-xs">
<i class="fas fa-copyright mr-1"></i>
2025 Verse – 次世代ソーシャルネットワーク v3.0
<span class="ml-4 inline-flex items-center">
<i class="fas fa-rss mr-1 text-amber-300"></i>共有RSS / 個別ON/OFF / BOT・マルコフ自動投稿
</span>
</p>
</footer>
<script>
// ==== 初期RSS ====
const PRESET_RSS = [
"http://2ch-2.net/rss/all.xml",
"http://2ch-ranking.net/rss/livemarket1.rdf",
"http://2ch-ranking.net/rss/livemarket2.rdf",
"http://kabumatome.doorblog.jp/index.rdf",
"http://momoniji.com/feed",
"http://oekakigakusyuu.blog97.fc2.com/?xml",
"http://otanews.livedoor.biz/atom.xml",
"http://otanews.livedoor.biz/index.rdf",
"http://news4vip.livedoor.biz/index.rdf",
"http://news.kakaku.com/prdnews/rss.asp",
"http://www.jma-net.go.jp/rss/jma.rss",
"http://rss.asahi.com/rss/asahi/newsheadlines.rdf",
"https://uploadvr.com/feed/",
"http://www.atmarkit.co.jp/rss/rss2dc.xml",
"http://liginc.co.jp/feed",
"http://liginc.co.jp/feed/",
"http://blog.livedoor.jp/shachiani/index.rdf",
"http://manga.lemon-s.com/atom.xml",
"http://b.hatena.ne.jp/search/text?safe=on&q=%E3%82%BB%E3%82%AD%E3%83%A5%E3%83%AA%E3%83%86%E3%82%A3&users=500&mode=rss",
"http://creator-life.net/feed/",
"http://feedblog.ameba.jp/rss/ameblo/ca-1pixel/rss20.xml",
"http://rssblog.ameba.jp/ca-1pixel/rss20.xml",
"http://weekly.ascii.jp/cate/1/rss.xml",
"http://blog.livedoor.jp/coleblog/atom.xml",
"http://2chantena.antenam.biz/rss1s.rss",
"http://www.4gamer.net/rss/index.xml",
"http://www.4gamer.net/rss/news_topics.xml",
"http://nyan.eggtree.net/feed.xml",
"http://nunnnunn.hatenablog.com/rss",
"http://www.nikkansports.com/general/atom.xml",
"http://feeds.afpbb.com/rss/afpbb/access_ranking",
"http://akiba-pc.watch.impress.co.jp/cda/rss/akiba-pc.rdf",
"https://area.autodesk.jp/rss.xml",
"http://av.watch.impress.co.jp/sublink/av.rdf",
"http://rss.allabout.co.jp/aa/latest/ch/netpc/",
"http://www.ar-ch.org/atom.xml",
"http://feeds.arstechnica.com/arstechnica/BAaf",
"https://feeds.feedburner.com/awwwards-sites-of-the-day",
"http://news.bbc.co.uk/rss/newsonline_uk_edition/front_page/rss091.xml",
"http://www.criteo.com/blog/rss/",
"https://blueskyweb.xyz/rss.xml",
"http://boingboing.net/rss.xml",
"http://www.cc2.co.jp/blog/?feed=rss2",
"http://cgarena.com/cgarena.xml",
"http://cgtracking.net/feed",
"http://japan.cnet.com/rss/index.rdf",
"http://newclassic.jp/feed",
"https://www.cssmania.com/feed/",
"http://ceron.jp/top/?type=rss",
"http://blog.btrax.com/jp/comments/feed/",
"http://2ch.logpo.jp/1hour.xml",
"http://menthas.com/javascript/rss",
"http://www.nhk.or.jp/rss/news/cat0.xml",
"http://ozpa-h4.com/feed/",
"https://www.youtube.com/feeds/videos.xml?channel_id=UC1DCedRgGHBdm81E1llLhOQ",
"http://rass.blog43.fc2.com/?xml",
"http://stackoverflow.com/feeds",
"http://www.slideshare.net/rss/latest",
"http://www.jp.square-enix.com/whatsnew2/whatsnew.rdf",
"http://www.ituore.com/feed",
"http://synodos.jp/comments/feed",
"http://www.shinkigensha.co.jp/feed/",
"http://e-shuushuu.net/index.rss",
"http://slashdot.org/index.rss",
"http://feeds.feedburner.com/TheHackersNews?format=xml",
"http://googleblog.blogspot.com/atom.xml",
"http://www.theregister.co.uk/tonys/slashdot.rdf",
"http://thinkit.co.jp/rss.xml",
"http://blog.livedoor.jp/news23vip/atom.xml",
"http://blog.livedoor.jp/news23vip/index.rdf",
"http://www.webcreatorbox.com/feed/",
"http://web-d.navigater.info/atom.xml",
"http://2ch-c.net/?xml_all",
"http://smhn.info/feed",
"http://feeds.japan.zdnet.com/rss/zdnet/all.rdf",
"http://20kaido.com/index.rdf",
"http://2chnode.com/rss/feed/all",
"http://akiba-souken.com/feed/all/",
"http://amaebi.net/index.rdf",
"http://amakakeru.blog59.fc2.com/?xml",
"http://artskype.com/rss/feed.xml",
"http://asitagamienai.blog118.fc2.com/?xml",
"http://beta.egmnow.com/feed/",
"http://blog.livedoor.jp/ogenre/index.rdf",
"http://blog.nicovideo.jp/atom.xml",
"http://blog.tsubuani.com/feed",
"http://blogs.adobe.com/flex/atom.xml",
"http://blogs.adobe.com/index.xml",
"http://bm.s5-style.com/feed",
"http://business.nikkeibp.co.jp/rss/all_nbo.rdf",
"http://createlier.sitemix.jp/feed/",
"http://crocro.com/news/nc.cgi?action=search&skin=rdf_srch_xml",
"http://d.hatena.ne.jp/thk/rss",
"http://damage0.blomaga.jp/index.rdf",
"http://danbooru.donmai.us/posts.atom",
"http://danbooru.donmai.us/posts.atom?tags=rss",
"http://dengekionline.com/cate/11/rss.xml",
"http://dictionary.reference.com/wordoftheday/wotd.rss",
"http://doujin-games88.net/feed",
"http://doujin.sekurosu.com/rss",
"http://dousyoko.blog.fc2.com/?xml",
"http://eroaniblog.blog.fc2.com/?xml",
"http://eroanimedougakan.blog.fc2.com/?xml",
"http://erogetrailers.com/api?md=latest",
"http://eronizimage.blog.fc2.com/?xml",
"http://erosanime.blog121.fc2.com/?xml",
"http://erotaganime.blog.fc2.com/?xml",
"http://feed.nikkeibp.co.jp/rss/nikkeibp/index.rdf",
"http://feed.rssad.jp/rss/gigazine/rss_2.0",
"http://feed.rssad.jp/rss/jcast/index.xml",
"http://feed.rssad.jp/rss/klug/fxnews/rss5.xml",
"http://feedblog.ameba.jp/rss/ameblo/yusayusa0211/rss20.xml",
"http://feeds.adobe.com/xml/rss.cfm?query=byMostRecent&languages=1",
"http://feeds.builder.japan.zdnet.com/rss/builder/all.rdf",
"http://feeds.fc2.com/fc2/xml?host=anrism.blog&format=xml",
"http://feeds.fc2.com/fc2/xml?host=kahouha2jigen.blog&format=xml",
"http://feeds.feedburner.com/gekiura",
"http://feeds.journal.mycom.co.jp/rss/mycom/index",
"http://feeds.reuters.com/reuters/JPTopNews?format=xml",
"http://galten705.blog.fc2.com/?xml",
"http://gamanjiru.net/feed",
"http://gamanjiru.net/feed/atom",
"http://gamebiz.jp/?feed=rss",
"http://gamenode.jp/rss/feed/all",
"http://ggsoku.com/feed/atom/",
"http://girlcelly.blog.fc2.com/?xml&trackback",
"http://hairana.blog.fc2.com/?xml",
"http://haruka-yumenoato.net/static/rss/index.rss",
"http://headline.harikonotora.net/rss2.xml",
"http://hentaidoujinanime.com/?xml",
"http://homepage1.nifty.com/maname/index.rdf",
"http://horiemon.com/feed/",
"http://ideahacker.net/feed/",
"http://itpro.nikkeibp.co.jp/rss/develop.rdf",
"http://itpro.nikkeibp.co.jp/rss/news.rss",
"http://itpro.nikkeibp.co.jp/rss/oss.rdf",
"http://itpro.nikkeibp.co.jp/rss/win.rdf",
"http://japan.internet.com/rss/rdf/index.rdf",
"http://jp.leopard-raws.org/rss.php",
"http://jp.techcrunch.com/feed/",
"http://kakaku.com/trendnews/rss.xml",
"http://kamisoku.blog47.fc2.com/?xml",
"http://kanesoku.com/index.rdf",
"http://kibougamotenai.blog.fc2.com/?xml",
"http://kiisu.jpn.org/rss/now.xml",
"http://konachan.com/post/piclens?page=1&tags=loli",
"http://labo.tv/2chnews/index.xml",
"http://lineblog.me/yamamotoichiro/atom.xml",
"http://majimougen.blog.fc2.com/?xml",
"http://mantan-web.jp/rss/mantan.xml",
"http://matome.naver.jp/feed/hot",
"http://matome.naver.jp/feed/tech",
"http://matome.sekurosu.com/rss",
"http://mizuhonokuni2ch.com/?xml",
"http://momoiroanime.blog.fc2.com/?xml",
"http://moroahedoujin.com/?xml",
"http://nesingazou.blog.fc2.com/?xml",
"http://newnews-moe.com/index.rdf",
"http://news.ameba.jp/index.xml",
"http://news.com.com/2547-1_3-0-5.xml",
"http://news.nicovideo.jp/?rss=2.0",
"http://news.nicovideo.jp/ranking/hot?rss=2.0",
"http://newsbiz.yahoo.co.jp/topnews.rss",
"http://nijitora.blog.fc2.com/?xml",
"http://nodvd21ver2.blog.fc2.com/?xml",
"http://orebibou.com/feed/",
"http://osu.ppy.sh/feed/ranked/",
"http://otakomu.jp/feed",
"http://pcgameconquest.blog.fc2.com/?xml",
"http://picks.dir.yahoo.co.jp/dailypicks/rss/",
"http://piknik2ch.blog76.fc2.com/?xml",
"http://plus.appgiga.jp/feed/user",
"http://purisoku.com/index.rdf",
"http://rdsig.yahoo.co.jp/RV=1/RU=aHR0cDovL3NlYXJjaHJhbmtpbmcueWFob28uY28uanAvcnNzL2J1cnN0X3JhbmtpbmctcnNzLnhtbA--;_ylt=A2RhjFhfAi9XEi0A6Glhdu57",
"http://read2ch.net/rss/",
"http://rss.dailynews.yahoo.co.jp/fc/computer/rss.xml",
"http://rss.rssad.jp/rss/akibapc/akiba-pc.rdf",
"http://rss.rssad.jp/rss/ascii/biz/rss.xml",
"http://rss.rssad.jp/rss/ascii/hobby/rss.xml",
"http://rss.rssad.jp/rss/ascii/it/rss.xml",
"http://rss.rssad.jp/rss/ascii/mac/rss.xml",
"http://rss.rssad.jp/rss/ascii/pc/rss.xml",
"http://rss.rssad.jp/rss/ascii/rss.xml",
"http://rss.rssad.jp/rss/codezine/new/20/index.xml",
"http://rss.rssad.jp/rss/forest/rss.xml",
"http://rss.rssad.jp/rss/gihyo/feed/atom",
"http://rss.rssad.jp/rss/headline/headline.rdf",
"http://rss.rssad.jp/rss/impresswatch/pcwatch.rdf",
"http://rss.rssad.jp/rss/itm/1.0/makoto.xml",
"http://rss.rssad.jp/rss/itm/1.0/netlab.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_akiba.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_android_appli.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_apple.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_facebook.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_google.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_ipad.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_iphone.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_iphone_appli.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_mixi.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_smartphone.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_twitter.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_ustream.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_youtube.xml",
"http://rss.rssad.jp/rss/itmbizid/1.0/bizid.xml",
"http://rss.rssad.jp/rss/itmnews/2.0/news_bursts.xml",
"http://rss.rssad.jp/rss/japaninternetcom/index.rdf",
"http://rss.rssad.jp/rss/oshietekun/atom.xml",
"http://rss.rssad.jp/rss/slashdot/slashdot.rss",
"http://rss.rssad.jp/rss/zaikeishimbun/main.xml",
"http://rssc.dokoda.jp/r/8a1dd8f128047929ba4390dab3c8065e/http/searchranking.yahoo.co.jp/realtime_buzz/",
"http://sakurabaryo.com/feed/",
"http://sankei.jp.msn.com/rss/news/points.xml",
"http://sankei.jp.msn.com/rss/news/west_points.xml",
"http://search.goo.ne.jp/rss/newkw.rdf",
"http://sekurosu.com/rss",
"http://streaming.yahoo.co.jp/rss/newly/anime/",
"http://sub0000528116.hmk-temp.com/wordpress/?feed=rss2",
"http://sukebei.nyaa.se/?page=rss&sort=2",
"http://tenshoku.mynavi.jp/knowhow/rss.xml",
"http://tensinyakimeshi.blog98.fc2.com/?xml",
"http://thefreedom12.blog41.fc2.com/?xml",
"http://togetter.com/rss/hot/culture/62",
"http://togetter.com/rss/hot/culture/63",
"http://torimatome.main.jp/blogs/comments/feed",
"http://torimatome.main.jp/blogs/feed",
"http://toshinokyouko.com/rss.php",
"http://tvanimedouga.blog93.fc2.com/?xml",
"http://uranourainformation.blog21.fc2.com/?xml",
"http://video.fc2.com/a/feed_popular.php?m=week",
"http://weather.livedoor.com/forecast/rss/area/400010.xml",
"http://wotopi.jp/feed",
"http://www.100shiki.com/feed",
"http://www.alistapart.com/rss.xml",
"http://www.anime-sharing.com/forum/external.php?type=RSS2&forumids=36",
"http://www.anime-sharing.com/forum/external.php?type=RSS2&forumids=38",
"http://www.anime-sharing.com/forum/external.php?type=RSS2&forumids=47",
"http://www.blosxom.com/?feed=rss2",
"http://www.britannica.com/eb/dailycontent/rss",
"http://www.csmonitor.com/rss/top.rss",
"http://www.ehackingnews.com/feeds/posts/default",
"http://www.falcom.co.jp/new.xml",
"http://www.famitsu.com/rss/category/fcom_game.rdf",
"http://www.famitsu.com/rss/fcom_all.rdf",
"http://www.ganganonline.com/rss/index.xml",
"http://www.ideaxidea.com/feed",
"http://www.itnews711.com/index.rdf",
"http://www.jp.playstation.com/whatsnew/whatsnew.rdf",
"http://www.keyman.or.jp/rss/v1/?rss_type=all",
"http://www.koubo.co.jp/rss.xml",
"http://www.nyaa.se/?page=rss&sort=2",
"http://www.nyaa.se/?page=rss&user=118009",
"http://www.nytimes.com/services/xml/rss/userland/HomePage.xml",
"http://www.phianime.tv/feed/",
"http://www.rebootdevelop.hr/feed/",
"http://www.rictus.com/muchado/feed/",
"http://www.sbcr.jp/atom.xml",
"http://www.slashgear.com/comments/feed/",
"http://www.torrent-anime.com/feed",
"http://www.torrent-anime.com/feed/",
"http://www.webimemo.com/feed/",
"http://www.wired.com/news_drop/netcenter/netcenter.rdf",
"http://www.xvideos.com/rss/rss.xml",
"http://www.youtube.com/rss/user/KADOKAWAanime/videos.rss",
"http://www.youtube.com/rss/user/demosouko/videos.rss",
"http://www.yukawanet.com/index.rdf",
"http://www.zou3.net/php/rss/nikkei2rss.php?head=main",
"http://xml.ehgt.org/ehtracker.xml",
"http://xml.metafilter.com/rss.xml",
"http://xvideos.2jiero.info/feed",
"http://yaraon.blog109.fc2.com/?xml",
"http://yusaani.com/home/feed/",
"http://zipdeyaruo.blog42.fc2.com/?xml",
"http://www.portalgraphics.net/rss/latest_image_list.xml",
"http://api.syosetu.com/writernovel/430380.Atom",
"http://creive.me/feed/",
"http://gihyo.jp/dev/feed/atom",
"http://gihyo.jp/feed/rss1",
"http://hakase255.blog135.fc2.com/?xml",
"http://2ch-ranking.net/rss/zenban.rdf",
"http://www.isus.jp/feed/",
"http://www.jiji.com/rss/ranking.rdf",
"http://jp.gamesindustry.biz/rss/index.xml",
"https://www.youtube.com/feeds/videos.xml?channel_id=UCx1nAvtVDIsaGmCMSe8ofsQ",
"http://zakuzaku911.com/index.rdf",
"http://ke-tai.org/blog/feed/",
"http://data.newantenna.net/ero/rss/all.xml",
"http://developer.mixi.co.jp/feed/atom",
"http://neoneetch.blog.fc2.com/?xml",
"http://rss.itmedia.co.jp/rss/1.0/netlab.xml",
"http://netgeek.biz/feed",
"http://blog.esuteru.com/index.rdf",
"http://b.hatena.ne.jp/hotentry/game.rss",
"http://b.hatena.ne.jp/hotentry.rss",
"http://mobile.seisyun.net/rss/hot.rdf",
"http://yomi.mobi/rss/hot.rdf",
"http://saymygame.com/feed/",
"http://blog.webcreativepark.net/atom.xml",
"http://buhidoh.net/?xml",
"http://www.webcyou.com/?feed=rss2",
"http://withnews.jp/rss/consumer/new.rdf",
"https://yande.re/post/atom?tags=loli",
"http://blog.livedoor.jp/nizigami/atom.xml",
"http://nvmzaq.blog.fc2.com/?xml",
"http://keieimanga.net/index.rdf",
"http://megumi.ldblog.jp/atom.xml",
"http://kirik.tea-nifty.com/diary/index.rdf",
"http://sinri.net/comments/feed",
"http://himasoku.com/atom.xml",
"http://himasoku.com/index.rdf",
"http://20kaido.com/index.rdf",
"http://h723.blog.fc2.com/?xml",
"http://onecall2ch.com/index.rdf",
"http://www.forest.impress.co.jp/rss.xml",
"http://www.zaikei.co.jp/rss/sections/it.xml",
"http://akiba.keizai.biz/rss.xml",
"http://agag.tw/feed/2d-popular.rss",
"http://adult-vr.jp/feed/",
"http://www.anige-sokuhouvip.com/?xml",
"http://animeanime.jp/rss/index.rdf",
"http://alfalfalfa.com/index.rdf",
"http://feeds.feedburner.com/fc2/GhfA",
"http://erogetaiken072.blog.fc2.com/?xml",
"http://otanew.jp/atom.xml",
"http://jin115.com/index.rdf",
"http://www.onlinegamer.jp/rss/news.rdf",
"http://karapaia.livedoor.biz/index.rdf",
"http://getnews.jp/feed/ext/orig",
"http://www.gungho.co.jp/news/xml/rss.xml",
"http://blog.livedoor.jp/kinisoku/index.rdf",
"http://feeds.gizmodo.jp/rss/gizmodo/index.xml",
"http://himado.in/?sort=movie_id&rss=1",
"http://k-tai.impress.co.jp/cda/rss/ktai.rdf",
"http://gehasoku.com/atom.xml",
"http://feedblog.ameba.jp/rss/ameblo/principia-ca/rss20.xml",
"http://zai.diamond.jp/list/feed/rssfxnews",
"http://capacitor.blog.fc2.com/?xml",
"http://blog.livedoor.jp/vipsister23/index.rdf",
"http://vipsister23.com/atom.xml",
"http://b.hatena.ne.jp/search/tag?safe=on&q=2ch&users=500&mode=rss",
"http://b.hatena.ne.jp/search/tag?safe=on&q=%E3%83%8D%E3%83%83%E3%83%88%E3%83%AF%E3%83%BC%E3%82%AF&users=500&mode=rss",
"http://b.hatena.ne.jp/search/tag?safe=off&q=%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B0&users=500&mode=rss",
"http://shikaku2ch.doorblog.jp/atom.xml",
"http://dotinstall.com/lessons.rss",
"http://2ch-ranking.net/rss/news4vip.rdf",
"http://blog.livedoor.jp/insidears/index.rdf",
"http://2ch-ranking.net/rss/newsplus.rdf",
"http://2ch-ranking.net/rss/news.rdf",
"http://nullpoantenna.com/game.rdf",
"http://workingnews.blog117.fc2.com/?xml",
"http://bm.s5-style.com/feed",
"http://2ch-ranking.net/rss/ghard.rdf",
"http://www.724685.com/blog/rss.xml",
"http://www.yukawanet.com/index.rdf",
"http://2ch-ranking.net/rss/bizplus.rdf",
"http://www.nicovideo.jp/ranking/fav/daily/all?rss=2.0&lang=ja-jp",
"http://www.tarikin.net/rss0.rdf",
"http://blog.livedoor.jp/dqnplus/index.rdf",
"http://www.seojapan.com/blog/feed",
"http://2ch-ranking.net/rss/morningcoffee.rdf",
"http://2ch-ranking.net/mt50k.rdf",
"http://rssblog.ameba.jp/yandereotto/rss20.xml",
"https://business.nikkei.com/rss/sns/nb.rdf",
"http://daredemopc.blog51.fc2.com/?xml",
"http://erogetaikenban.blog65.fc2.com/?xml",
"http://news.goo.ne.jp/rss/topstories/gootop/index.rdf",
"http://lanovelien.blog121.fc2.com/?xml",
"http://news.livedoor.com/topics/rss/eco.xml",
"http://ragnarokonline.gungho.jp/index.rdf",
"http://rocketnews24.com/feed/",
"https://news.denfaminicogamer.jp/feed",
"http://www.igda.jp/?feed=rss2",
"http://feeds.cnn.co.jp/cnn/rss"
];
// ==== アプリ状態 ====
if (!localStorage.getItem('verse_shared_rssFeeds')) {
localStorage.setItem('verse_shared_rssFeeds', JSON.stringify(PRESET_RSS));
}
let users = JSON.parse(localStorage.getItem('verse_users') || '[]');
let currentUser = JSON.parse(localStorage.getItem('verse_currentUser') || 'null');
let posts = JSON.parse(localStorage.getItem('verse_posts') || '[]');
let isDarkMode = localStorage.getItem('verse_darkMode') === 'true';
let isInitialized = false;
// BOT / RSS 状態
let botInterval = null;
let rssInterval = null;
// タイムラインフィルタ&検索
let currentFilter = 'all';
let currentSearch = '';
// 共有RSS設定
let sharedRssFeeds = JSON.parse(localStorage.getItem('verse_shared_rssFeeds') || '[]');
let sharedRssInterval = Number(localStorage.getItem('verse_shared_rssInterval')) || 300;
let sharedRssLastIds = JSON.parse(localStorage.getItem('verse_shared_rssLastIds') || '{}');
let sharedRssEnabled = JSON.parse(localStorage.getItem('verse_shared_rssEnabled') || '{}');
// アイコン設定(BOT, Feed)
let verseIcons = JSON.parse(localStorage.getItem('verse_icons') || 'null');
if (!verseIcons) {
verseIcons = {
bot: 'https://cdn-icons-png.flaticon.com/512/4712/4712109.png',
feed: 'https://cdn-icons-png.flaticon.com/512/3416/3416046.png'
};
localStorage.setItem('verse_icons', JSON.stringify(verseIcons));
}
function saveIcons() {
localStorage.setItem('verse_icons', JSON.stringify(verseIcons));
updateAllUI();
}
function uploadIcon(type, e) {
const f = e.target.files[0];
if (!f) return;
if (f.size > 5 * 1024 * 1024) { alert('5MB以下にしてください。'); return; }
const r = new FileReader();
r.onload = () => {
verseIcons[type] = r.result;
saveIcons();
alert(type.toUpperCase() + ' アイコンを更新しました');
};
r.readAsDataURL(f);
}
// ==== キャッシュクリア ====
function clearVerseCache() {
if (!confirm('Tsumugi / Verse のローカルキャッシュ(ユーザー, 投稿, RSS設定など)をすべて削除します。よろしいですか?')) return;
Object.keys(localStorage).forEach(k => {
if (k.startsWith('verse_')) localStorage.removeItem(k);
});
alert('ローカルキャッシュを削除しました。ページを再読み込みします。');
location.reload();
}
// ===== 認証UI =====
function showAuthModal(mode = 'login', errorMsg = '') {
document.getElementById('auth-title').textContent = (mode === 'register') ? '新規登録' : 'ログイン';
document.getElementById('auth-form').authMode = mode;
document.getElementById('auth-email').value = '';
document.getElementById('auth-password').value = '';
document.getElementById('auth-modal').style.display = '';
document.getElementById('main-content').style.display = 'none';
document.getElementById('auth-error').textContent = errorMsg || '';
document.getElementById('auth-error').style.display = errorMsg ? '' : 'none';
document.getElementById('toggle-auth-mode').textContent = (mode === 'register') ? 'ログインはこちら' : '新規登録はこちら';
}
function hideAuthModal() {
document.getElementById('auth-modal').style.display = 'none';
document.getElementById('main-content').style.display = '';
}
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('auth-form').onsubmit = function(e) {
e.preventDefault();
const email = document.getElementById('auth-email').value.trim().toLowerCase();
const password = document.getElementById('auth-password').value;
if (!email || !password) {
showAuthModal(this.authMode, 'メールアドレスとパスワードを入力してください');
return;
}
if (this.authMode === 'register') {
if (users.find(u => u.email === email)) {
showAuthModal('register', 'このメールアドレスは既に登録されています');
return;
}
const newUser = {
email,
password,
profile: { icon: 'https://via.placeholder.com/100', username: email.split('@')[0], selfIntro: '' }
};
users.push(newUser);
localStorage.setItem('verse_users', JSON.stringify(users));
currentUser = { email };
localStorage.setItem('verse_currentUser', JSON.stringify(currentUser));
showAuthModal('login', '登録完了!ログインしてください');
} else {
const user = users.find(u => u.email === email && u.password === password);
if (!user) { showAuthModal('login', 'メールアドレスまたはパスワードが違います'); return; }
currentUser = { email };
localStorage.setItem('verse_currentUser', JSON.stringify(currentUser));
hideAuthModal();
initializeApp();
}
};
document.getElementById('toggle-auth-mode').onclick = function() {
const mode = (document.getElementById('auth-title').textContent === '新規登録') ? 'login' : 'register';
showAuthModal(mode);
};
if (!currentUser) showAuthModal('login'); else { hideAuthModal(); initializeApp(); }
});
function logout() {
localStorage.removeItem('verse_currentUser');
currentUser = null;
stopRssAuto();
stopBotAutoPost();
showAuthModal('login');
}
// ===== 初期化 =====
function initializeApp() {
if (isInitialized) return;
if (!currentUser) { showAuthModal('login'); return; }
users = JSON.parse(localStorage.getItem('verse_users') || '[]');
posts = JSON.parse(localStorage.getItem('verse_posts') || '[]');
isDarkMode = localStorage.getItem('verse_darkMode') === 'true';
const user = users.find(u => u.email === currentUser.email);
window.profile = user ? user.profile : { icon: 'https://via.placeholder.com/100', username: 'ゲストユーザー', selfIntro: '' };
updateAllUI();
updateStatusIndicators();
updateRssUI();
if (isDarkMode) {
document.body.classList.add('dark');
} else {
document.body.classList.remove('dark');
}
document.getElementById('main-content').style.display = '';
document.getElementById('logout-btn').classList.remove('hidden');
isInitialized = true;
startRssAuto();
addLog('bot-log', 'BOT機能初期化完了', 'success');
addLog('rss-log', 'RSS自動投稿(全体共有)初期化完了', 'success');
}
// ===== 投稿(ユーザー/BOT/Feed/Markov) =====
function createUserPost() {
const ta = document.getElementById('postContent');
const txt = ta.value.trim();
if (!txt) return alert('投稿内容を入力してください。');
if (!currentUser) return alert('ログインが必要です。');
createPost(txt, 'user', profile.username, profile.icon);
ta.value = '';
updateCharCount();
}
function createPost(content, type = 'user', username = null, icon = null, extra = {}) {
if (!content || !content.trim()) return false;
let finalIcon = icon;
if (!finalIcon) {
if (type === 'bot' || type === 'markov') finalIcon = verseIcons.bot;
else if (type === 'feed') finalIcon = verseIcons.feed;
else finalIcon = profile.icon;
}
const post = {
id: Date.now() + Math.random(),
content: content.trim(),
likes: 0,
timestamp: new Date().toLocaleString('ja-JP'),
type,
username: username || profile.username,
icon: finalIcon,
userEmail: currentUser ? currentUser.email : '',
...extra
};
posts.unshift(post);
saveData();
renderTimeline();
return true;
}
function likePost(id) {
const idx = posts.findIndex(p => p.id === id);
if (idx >= 0) {
posts[idx].likes++;
saveData();
renderTimeline();
}
}
function deletePost(id) {
if (!confirm('この投稿を削除しますか?')) return;
posts = posts.filter(p => p.id !== id);
saveData();
renderTimeline();
}
function clearAllPosts() {
if (!confirm('全ての投稿を削除しますか?')) return;
posts = [];
saveData();
renderTimeline();
}
function exportData() {
const blob = new Blob([JSON.stringify(posts, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'verse_posts.json';
a.click();
URL.revokeObjectURL(url);
}
// ===== タイムライン描画 =====
function escapeHtml(s) {
return (s || '').replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"','\'':'''}[m]));
}
function renderTimeline() {
const tl = document.getElementById('timeline');
const emp = document.getElementById('empty-timeline');
const cnt = document.getElementById('post-count');
if (!tl || !emp || !cnt) return;
let displayPosts = posts.slice();
// フィルター
displayPosts = displayPosts.filter(p => {
if (currentFilter === 'user' && p.type !== 'user') return false;
if (currentFilter === 'bot' && !['bot','markov'].includes(p.type)) return false;
if (currentFilter === 'feed' && p.type !== 'feed') return false;
return true;
});
// 検索
if (currentSearch && currentSearch.trim() !== '') {
const q = currentSearch.trim().toLowerCase();
displayPosts = displayPosts.filter(p => {
const text = (p.content || '') + ' ' + (p.username || '');
return text.toLowerCase().includes(q);
});
}
if (displayPosts.length === 0) {
tl.innerHTML = '';
emp.style.display = 'block';
cnt.textContent = '(0件の投稿)';
return;
}
emp.style.display = 'none';
cnt.textContent = `(${displayPosts.length}件の投稿)`;
tl.innerHTML = displayPosts.map(p => {
const info = {
bot: '<i class="fas fa-robot mr-1"></i>BOT',
markov: '<i class="fas fa-dice mr-1"></i>MarkovBOT',
user: '<i class="fas fa-user mr-1"></i>ユーザー',
feed: '<i class="fas fa-rss mr-1"></i>FEEDBOT'
}[p.type] || '<i class="fas fa-user mr-1"></i>';
const main = p.link
? `<a href="${p.link}" target="_blank" class="text-sky-400 underline">${escapeHtml(p.content)}</a>`
: `${escapeHtml(p.content)}`;
return `
<div class="timeline-post p-6">
<div class="flex justify-between items-start mb-4">
<div class="flex items-center space-x-3">
<img src="${p.icon}" class="w-10 h-10 rounded-full object-cover border border-slate-500" onerror="this.src='https://via.placeholder.com/40'">
<div>
<div class="flex items-center">
<span class="font-semibold text-slate-100">${escapeHtml(p.username)}</span>
<span class="username-badge text-[10px]">${info}</span>
</div>
<div class="text-[11px] text-slate-400">${p.timestamp}</div>
</div>
</div>
</div>
<div class="text-slate-100 mb-4 leading-relaxed text-sm">${main}</div>
<div class="flex items-center space-x-4 pt-4 border-t border-slate-700">
<button onclick="likePost(${p.id})" class="flex items-center space-x-2 text-slate-300 hover:text-red-400 text-xs">
<i class="fas fa-heart"></i><span>${p.likes}</span>
</button>
<div class="relative">
<button onclick="toggleShareMenu(${p.id})" class="flex items-center space-x-2 text-slate-300 hover:text-sky-400 text-xs">
<i class="fas fa-share"></i><span>シェア</span>
</button>
<div id="share-menu-${p.id}" class="share-menu hidden">
<button onclick="shareToX(${p.id})"><i class="fab fa-x-twitter text-sky-400 mr-2"></i>Xでシェア</button>
<button onclick="shareToLine(${p.id})"><i class="fab fa-line text-green-400 mr-2"></i>LINEでシェア</button>
<button onclick="copyPost(${p.id})"><i class="fas fa-copy mr-2"></i>コピー</button>
</div>
</div>
<button onclick="deletePost(${p.id})" class="flex items-center space-x-2 text-slate-400 hover:text-red-400 ml-auto text-xs">
<i class="fas fa-trash"></i><span>削除</span>
</button>
</div>
</div>
`;
}).join('');
}
function getPostContentText(id) {
const p = posts.find(x => x.id === id);
if (!p) return '';
const tmp = document.createElement('div');
tmp.innerHTML = p.content;
return tmp.textContent || tmp.innerText || '';
}
function toggleShareMenu(id) {
document.querySelectorAll('[id^="share-menu-"]').forEach(el => el.classList.add('hidden'));
const m = document.getElementById('share-menu-' + id);
if (m) m.classList.toggle('hidden');
}
function shareToX(id) {
const t = encodeURIComponent(getPostContentText(id));
const u = encodeURIComponent(location.href);
window.open(`https://twitter.com/intent/tweet?text=${t}&url=${u}`, '_blank');
}
function shareToLine(id) {
const u = encodeURIComponent(location.href);
window.open(`https://social-plugins.line.me/lineit/share?url=${u}`, '_blank');
}
function copyPost(id) {
const t = getPostContentText(id);
if (navigator.clipboard) {
navigator.clipboard.writeText(t).then(() => alert('コピーしました')).catch(() => fallbackCopy(t));
} else fallbackCopy(t);
}
function fallbackCopy(t) {
const ta = document.createElement('textarea');
ta.value = t; document.body.appendChild(ta);
ta.select(); document.execCommand('copy');
document.body.removeChild(ta);
alert('コピーしました');
}
// ===== タイムライン フィルタ&検索 =====
function setFilter(f) {
currentFilter = f;
['all','user','bot','feed'].forEach(k => {
const btn = document.getElementById('filter-' + k);
if (!btn) return;
if (k === f) {
btn.classList.add('bg-indigo-600','text-white');
} else {
btn.classList.remove('bg-indigo-600','text-white');
}
});
renderTimeline();
}
// ===== RSS UI(個別ON/OFF + 一括ON/OFF) =====
function updateRssUI() {
const listDiv = document.getElementById('rss-list');
if (!listDiv) return;
if (!sharedRssFeeds || sharedRssFeeds.length === 0) {
listDiv.innerHTML = '<div class="text-slate-200 text-[11px] opacity-80">RSSフィード未登録</div>';
} else {
listDiv.innerHTML = sharedRssFeeds.map((url, i) => {
const enabled = sharedRssEnabled[url] !== false;
const enc = encodeURIComponent(url);
return `
<div class="flex items-center space-x-2 bg-slate-900 rounded px-2 py-2 mb-1 border border-slate-700 text-[11px]">
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="toggleRssEnabled('${enc}', this.checked)" title="ON/OFF">
<div class="truncate flex-1 text-slate-100" title="${escapeHtml(url)}">${escapeHtml(url)}</div>
<button onclick="delRssFeed(${i})" class="text-red-400 hover:text-red-500" title="削除"><i class="fas fa-trash"></i></button>
</div>
`;
}).join('');
}
const iv = document.getElementById('rss-interval');
if (iv) iv.value = sharedRssInterval || 300;
}
function toggleRssEnabled(encUrl, on) {
const url = decodeURIComponent(encUrl);
sharedRssEnabled[url] = !!on;
saveSharedRss();
addLog('rss-log', `FEED ${on ? 'ON' : 'OFF'}: ${url}`, 'info');
}
function setAllRssEnabled(on) {
(sharedRssFeeds || []).forEach(u => sharedRssEnabled[u] = !!on);
saveSharedRss();
updateRssUI();
addLog('rss-log', `全フィードを${on ? 'ON' : 'OFF'}にしました`, 'success');
}
function saveSharedRss() {
localStorage.setItem('verse_shared_rssFeeds', JSON.stringify(sharedRssFeeds));
localStorage.setItem('verse_shared_rssInterval', String(sharedRssInterval));
localStorage.setItem('verse_shared_rssLastIds', JSON.stringify(sharedRssLastIds));
localStorage.setItem('verse_shared_rssEnabled', JSON.stringify(sharedRssEnabled));
}
function addRssFeed() {
const url = document.getElementById('rss-url').value.trim();
if (!/^https?:\/\/.+/.test(url)) { addLog('rss-log', '正しいRSSフィードURLを入力してください', 'error'); return; }
if (!sharedRssFeeds) sharedRssFeeds = [];
if (sharedRssFeeds.includes(url)) { addLog('rss-log', 'すでに登録済みです', 'error'); return; }
sharedRssFeeds.push(url);
sharedRssEnabled[url] = true;
saveSharedRss();
updateRssUI();
addLog('rss-log', `RSS追加: ${url}`, 'success');
document.getElementById('rss-url').value = '';
}
function delRssFeed(i) {
if (!sharedRssFeeds[i]) return;
if (!confirm('このフィードを削除しますか?')) return;
const url = sharedRssFeeds[i];
sharedRssFeeds.splice(i, 1);
delete sharedRssEnabled[url];
delete sharedRssLastIds[url];
saveSharedRss();
updateRssUI();
addLog('rss-log', 'RSS削除', 'info');
}
function setRssInterval() {
const iv = document.getElementById('rss-interval').valueAsNumber || 300;
if (iv < 10) return alert('間隔は10秒以上で設定してください。');
sharedRssInterval = iv;
saveSharedRss();
updateRssUI();
startRssAuto();
addLog('rss-log', `自動投稿間隔を${iv}秒に設定`, 'success');
}
function fetchRssNow() { fetchRssFeeds(); }
function fetchRssFeeds() {
if (!sharedRssFeeds || sharedRssFeeds.length === 0) return;
sharedRssFeeds.forEach(feedUrl => {
if (sharedRssEnabled[feedUrl] === false) {
addLog('rss-log', `OFFのため取得スキップ: ${feedUrl}`, 'info');
return;
}
fetch('https://api.rss2json.com/v1/api.json?rss_url=' + encodeURIComponent(feedUrl))
.then(resp => resp.json())
.then(data => {
if (!data.items || !data.items.length) return;
let lastId = sharedRssLastIds[feedUrl] || '';
let newItems = [];
for (const item of data.items) {
const guid = item.guid || item.link || item.pubDate || item.title;
if (!lastId || String(guid) > String(lastId)) newItems.push(item);
}
if (newItems.length === 0) return;
newItems.reverse().forEach(item => {
const guid = item.guid || item.link || item.pubDate || item.title;
if (!posts.some(p => p.type === 'feed' && p.link === item.link)) {
createPost(item.title, 'feed', 'FEEDBOT', verseIcons.feed, { link: item.link });
addLog('rss-log', `新しい記事: ${item.title}`, 'success');
}
sharedRssLastIds[feedUrl] = guid;
});
saveSharedRss();
})
.catch(() => addLog('rss-log', 'RSS取得エラー: ' + feedUrl, 'error'));
});
}
function startRssAuto() {
stopRssAuto();
fetchRssFeeds();
rssInterval = setInterval(fetchRssFeeds, (sharedRssInterval || 300) * 1000);
updateStatusIndicators();
addLog('rss-log', `RSS自動投稿を開始 (${sharedRssInterval}秒間隔)`, 'success');
}
function stopRssAuto() {
if (rssInterval) clearInterval(rssInterval);
rssInterval = null;
updateStatusIndicators();
addLog('rss-log', 'RSS自動投稿を停止しました', 'info');
}
// ===== BOT =====
function postBotMessage() {
const ta = document.getElementById('botContent');
const txt = ta.value.trim();
if (!txt) return alert('BOT投稿内容を入力してください。');
if (!currentUser) return alert('ログインが必要です。');
if (createPost(txt, 'bot', 'BOT', verseIcons.bot)) {
ta.value = '';
addLog('bot-log', `BOT投稿: "${txt.substring(0, 30)}..."`, 'success');
}
}
function generateMarkovText() {
let text = posts
.filter(p => ['user','bot','markov'].includes(p.type))
.map(p => {
const d = document.createElement('div');
d.innerHTML = p.content;
return (d.textContent || d.innerText || '')
.replace(/\s+/g, ' ').replace(/https?:\/\/\S+/g, '').trim();
}).join(' ');
if (text.length < 20) {
const fallbacks = [
"今日はいい天気ですね!",
"最近面白いニュースありましたか?",
"新しいアイデアが浮かんできました。",
"みんなはどう思いますか?"
];
return fallbacks[Math.floor(Math.random() * fallbacks.length)];
}
const tokens = text.match(/[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\w]+|[。、!?\r\n]/g) || [];
if (tokens.length < 2) return tokens.join('');
const markov = {};
for (let i = 0; i < tokens.length - 2; i++) {
const key = tokens[i] + '|' + tokens[i+1];
if (!markov[key]) markov[key] = [];
markov[key].push(tokens[i+2]);
}
let idx = Math.floor(Math.random() * (tokens.length - 2));
let key = tokens[idx] + '|' + tokens[idx+1];
let result = [tokens[idx], tokens[idx+1]];
let maxLen = 60 + Math.floor(Math.random() * 40);
for (let i = 0; i < maxLen; i++) {
const nexts = markov[key];
if (!nexts || nexts.length === 0) break;
const next = nexts[Math.floor(Math.random() * nexts.length)];
result.push(next);
if (/[。!?\n]/.test(next)) break;
key = result[result.length - 2] + '|' + result[result.length - 1];
}
return result.join('').replace(/\n/g, '');
}
function postMarkovBot() {
const txt = generateMarkovText();
if (createPost(txt, 'markov', 'MarkovBOT', verseIcons.bot)) {
addLog('bot-log', `マルコフ投稿: "${txt.substring(0, 40)}..."`, 'success');
}
}
function startBotAutoPost() {
const iv = document.getElementById('botIntervalSec').valueAsNumber || 60;
if (iv < 10) { alert('間隔は10秒以上で設定してください。'); return; }
stopBotAutoPost();
setTimeout(postMarkovBot, 3000);
botInterval = setInterval(postMarkovBot, iv * 1000);
updateStatusIndicators();
addLog('bot-log', `マルコフBOT自動投稿開始 (${iv}秒間隔)`, 'success');
}
function stopBotAutoPost() {
if (botInterval) {
clearInterval(botInterval);
botInterval = null;
updateStatusIndicators();
addLog('bot-log', 'マルコフBOT自動投稿を停止しました', 'info');
}
}
// ===== 共通UI/保存 =====
function addLog(id, msg, type = 'info') {
const el = document.getElementById(id);
const ts = new Date().toLocaleTimeString('ja-JP');
const div = document.createElement('div');
const cls = { error: 'error-message', success: 'success-message', info: 'text-slate-100 text-[11px]' }[type] || 'text-slate-100 text-[11px]';
div.className = cls;
div.innerHTML = `<span class="opacity-70">[${ts}]</span> ${escapeHtml(msg)}`;
if (el) {
el.appendChild(div);
el.scrollTop = el.scrollHeight;
while (el.children.length > 100) el.removeChild(el.firstChild);
}
try { console.log(`[${ts}] ${msg}`); } catch(_) {}
}
function updateStatusIndicators() {
const botI = document.getElementById('bot-status');
const botT = document.getElementById('bot-status-text');
if (botI && botT) {
const active = botInterval !== null;
botI.className = `status-indicator ${active ? 'status-active' : 'status-inactive'}`;
botT.textContent = active ? '動作中' : '停止中';
}
const rssI = document.getElementById('rss-status');
const rssT = document.getElementById('rss-status-text');
if (rssI && rssT) {
const active = rssInterval !== null;
rssI.className = `status-indicator ${active ? 'status-active' : 'status-inactive'}`;
rssT.textContent = active ? '動作中' : '停止中';
}
}
function showSystemStatus() {
alert(
`=== Verse システムステータス ===
全体投稿数: ${posts.length}
RSS登録数: ${sharedRssFeeds.length}
BOT投稿数: ${posts.filter(p => ['bot', 'markov'].includes(p.type)).length}
BOT自動投稿: ${botInterval ? '動作中' : '停止中'}
RSS自動投稿: ${rssInterval ? '動作中' : '停止中'}`
);
}
function uploadProfileIcon(e) {
const f = e.target.files[0];
if (!f) return;
if (f.size > 5 * 1024 * 1024) { alert('5MB以下にしてください。'); return; }
const r = new FileReader();
r.onload = () => {
profile.icon = r.result;
saveProfileNoAlert();
updateAllUI();
alert('プロフィール画像更新!');
};
r.readAsDataURL(f);
}
function saveProfile() {
const un = document.getElementById('username').value.trim();
const si = document.getElementById('self-intro').value.trim();
if (un.length > 20) { alert('ユーザー名は20文字以内で。'); return; }
profile.username = un || 'ゲストユーザー';
profile.selfIntro = si;
saveProfileNoAlert();
updateAllUI();
alert('プロフィール保存!');
}
function saveProfileNoAlert() {
users = JSON.parse(localStorage.getItem('verse_users') || '[]');
const idx = users.findIndex(u => u.email === (currentUser && currentUser.email));
if (idx >= 0) {
users[idx].profile = profile;
localStorage.setItem('verse_users', JSON.stringify(users));
}
}
function updateAllUI() {
const pi = document.getElementById('profile-icon');
const hi = document.getElementById('header-profile-icon');
if (pi) pi.src = profile.icon;
if (hi) hi.src = profile.icon;
['username-preview','header-username'].forEach(id => {
const el = document.getElementById(id);
if (el) el.textContent = profile.username;
});
const sip = document.getElementById('self-intro-preview');
if (sip) sip.textContent = profile.selfIntro || 'まだ自己紹介がありません';
const emailEl = document.getElementById('header-user-email');
if (emailEl && currentUser) emailEl.textContent = currentUser.email;
const bi = document.getElementById('bot-icon-preview');
const fi = document.getElementById('feed-icon-preview');
if (bi) bi.src = verseIcons.bot;
if (fi) fi.src = verseIcons.feed;
renderTimeline();
}
function updateCharCount() {
const pc = document.getElementById('postContent');
const cc = document.getElementById('char-count');
if (pc && cc) {
const l = pc.value.length;
cc.textContent = `(${l}/500)`;
cc.style.color = l > 450 ? '#fca5a5' : '';
}
}
function toggleDarkMode() {
isDarkMode = !isDarkMode;
if (isDarkMode) {
document.body.classList.add('dark');
} else {
document.body.classList.remove('dark');
}
localStorage.setItem('verse_darkMode', isDarkMode.toString());
}
function saveData() { localStorage.setItem('verse_posts', JSON.stringify(posts)); }
// 入力UIフック
document.addEventListener('DOMContentLoaded', () => {
const pc = document.getElementById('postContent');
if (pc) {
pc.addEventListener('input', updateCharCount);
pc.addEventListener('keydown', e => { if (e.key === 'Enter' && e.ctrlKey) { e.preventDefault(); createUserPost(); } });
}
const ui = document.getElementById('username');
if (ui) ui.addEventListener('input', () => {
const v = ui.value.trim() || 'ゲストユーザー';
['username-preview','header-username'].forEach(id => {
const el = document.getElementById(id);
if (el) el.textContent = v;
});
});
const si = document.getElementById('self-intro');
if (si) si.addEventListener('input', () => {
const v = si.value.trim() || 'まだ自己紹介がありません';
const el = document.getElementById('self-intro-preview');
if (el) el.textContent = v;
});
const ts = document.getElementById('timeline-search');
if (ts) ts.addEventListener('input', () => {
currentSearch = ts.value || '';
renderTimeline();
});
setFilter('all');
});
window.addEventListener('beforeunload', () => { stopBotAutoPost(); stopRssAuto(); saveData(); });
window.addEventListener('error', e => { addLog('bot-log', `システムエラー: ${e.message}`, 'error'); });
</script>
</body>
</html>
QuestFoundry
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Quest Foundry | 世界観からクエスト自動設計</title>
<meta name="description" content="世界観のキーワードからNPC・アイテム・場所・クエストを一括生成。JSON/CSVエクスポート、依存関係、難易度バランス、シード固定対応。" />
<!-- Tailwind CDN (Node不要) -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ["Noto Sans JP", "ui-sans-serif", "system-ui"] },
colors: { brand: { 50: '#eef2ff', 100:'#e0e7ff', 200:'#c7d2fe', 300:'#a5b4fc', 400:'#818cf8', 500:'#6366f1', 600:'#4f46e5', 700:'#4338ca', 800:'#3730a3', 900:'#312e81'} }
}
}
};
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700;900&display=swap" rel="stylesheet">
<style>
html, body { height: 100%; }
.glass { backdrop-filter: blur(10px); background: rgba(255,255,255,0.7); }
.prose pre { white-space: pre-wrap; word-break: break-word; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.card { @apply rounded-2xl shadow-lg p-5 bg-white; }
.prose h1{font-size:1.5rem;line-height:1.3;margin:0 0 .6rem;font-weight:800}
.prose h2{font-size:1.2rem;line-height:1.35;margin:1.2rem 0 .4rem;font-weight:700;border-left:4px solid #6366f1;padding-left:.6rem}
.prose h3{font-size:1rem;line-height:1.4;margin:1rem 0 .3rem;font-weight:700}
.prose ul{list-style:disc;padding-left:1.25rem;margin:.4rem 0 .8rem}
.prose li{margin:.2rem 0}
.badge{display:inline-block;font-size:.72rem;line-height:1;background:#eef2ff;color:#3730a3;border:1px solid #c7d2fe;border-radius:.5rem;padding:.15rem .45rem;margin-right:.25rem}
details.quest{border:1px solid #e5e7eb;border-radius:.75rem;padding:.6rem .8rem;margin:.5rem 0;background:#fff}
details.quest > summary{cursor:pointer;list-style:none}
details.quest > summary::-webkit-details-marker{display:none}
.kv{display:inline-grid;grid-template-columns:auto auto;gap:.2rem .6rem;align-items:center}
</style>
</head>
<body class="min-h-screen bg-gradient-to-br from-brand-50 to-white text-slate-800">
<header class="sticky top-0 z-40 border-b bg-white/80 backdrop-blur">
<div class="mx-auto max-w-7xl px-4 py-3 flex items-center gap-4">
<div class="text-2xl font-black tracking-tight"><span class="text-brand-700">Quest</span> Foundry</div>
<div class="text-xs text-slate-500">世界観→NPC/アイテム/場所/クエストを自動生成(JSON/CSV出力可)</div>
<div class="ml-auto flex items-center gap-2">
<button id="btnSave" class="px-3 py-2 text-sm rounded-lg border hover:bg-slate-50">保存</button>
<button id="btnLoad" class="px-3 py-2 text-sm rounded-lg border hover:bg-slate-50">読込</button>
<button id="btnPrint" class="px-3 py-2 text-sm rounded-lg border hover:bg-slate-50">印刷/PDF</button>
</div>
</div>
</header>
<main class="mx-auto max-w-7xl px-4 py-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 左:設定フォーム -->
<section class="lg:col-span-1 card">
<h2 class="text-lg font-bold mb-4">ワールド設定</h2>
<form id="worldForm" class="space-y-4">
<div>
<label class="block text-sm font-medium">世界名</label>
<input id="worldName" type="text" class="w-full mt-1 rounded-lg border px-3 py-2" placeholder="例:アトラティア" />
</div>
<div>
<label class="block text-sm font-medium">テーマ・キーワード(読点・スペース区切り)</label>
<input id="themes" type="text" class="w-full mt-1 rounded-lg border px-3 py-2" placeholder="例:古代遺跡 砂漠 精霊 冒険者ギルド" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium">難易度</label>
<select id="difficulty" class="w-full mt-1 rounded-lg border px-3 py-2">
<option value="easy">Easy</option>
<option value="normal" selected>Normal</option>
<option value="hard">Hard</option>
<option value="epic">Epic</option>
</select>
</div>
<div>
<label class="block text-sm font-medium">クエスト数</label>
<input id="questCount" type="number" min="1" max="30" value="8" class="w-full mt-1 rounded-lg border px-3 py-2" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium">シード(同じ結果を再現)</label>
<input id="seed" type="text" class="w-full mt-1 rounded-lg border px-3 py-2" placeholder="未入力なら自動" />
</div>
<div class="flex items-end gap-2">
<input id="lockSeed" type="checkbox" class="h-5 w-5" />
<label for="lockSeed" class="text-sm">シード固定(再生成でも変化しない)</label>
</div>
</div>
<div>
<label class="block text-sm font-medium">トーン</label>
<select id="tone" class="w-full mt-1 rounded-lg border px-3 py-2">
<option value="classic" selected>古典ファンタジー</option>
<option value="dark">ダーク</option>
<option value="steampunk">スチームパンク</option>
<option value="myth">神話/叙事詩</option>
<option value="sci">サイファンタジー</option>
</select>
</div>
<div class="flex flex-wrap gap-2 pt-2">
<button id="btnGenerate" type="button" class="px-4 py-2 rounded-xl bg-brand-600 text-white hover:bg-brand-700">生成</button>
<button id="btnRegenerate" type="button" class="px-4 py-2 rounded-xl bg-slate-800 text-white hover:bg-slate-900">再生成(同条件)</button>
<button id="btnShuffleSeed" type="button" class="px-4 py-2 rounded-xl border">シード再抽選</button>
</div>
</form>
<p class="text-xs text-slate-500 mt-4">※外部API不使用。テンプレート×確率モデルでローカル生成。ブラウザ上で完結。</p>
</section>
<!-- 中央:結果(テキスト) -->
<section class="lg:col-span-2 card">
<div class="flex items-center gap-2 mb-4">
<h2 class="text-lg font-bold">生成結果</h2>
<span id="meta" class="ml-auto text-xs text-slate-500"></span>
</div>
<div class="flex flex-wrap gap-2 mb-4">
<button id="btnCopyText" class="px-3 py-2 rounded-lg border">テキストをコピー</button>
<button id="btnDownloadJSON" class="px-3 py-2 rounded-lg border">JSONダウンロード</button>
<button id="btnExportCSV" class="px-3 py-2 rounded-lg border">CSV書き出し</button>
<button id="btnToggleJson" class="px-3 py-2 rounded-lg border">JSON表示切替</button>
</div>
<div id="outText" class="prose max-w-none text-sm leading-6"></div>
<details id="jsonBlock" class="mt-4 hidden">
<summary class="cursor-pointer select-none text-sm text-slate-600">JSON表示</summary>
<pre id="outJSON" class="mono text-xs bg-slate-50 p-3 rounded-lg overflow-x-auto"></pre>
</details>
</section>
<!-- 下:プレビュー(カードレイアウト) -->
<section class="lg:col-span-3 card">
<h2 class="text-lg font-bold mb-4">カードビュー</h2>
<div class="grid md:grid-cols-3 gap-4" id="cards"></div>
</section>
</main>
<footer class="py-8 text-center text-xs text-slate-500">
© 2025 Quest Foundry — Local-first Fantasy Content Generator
</footer>
<script>
/* =========================
* 乱数とユーティリティ
* ========================= */
function cyrb128(str){ let h1=1779033703,h2=3144134277,h3=1013904242,h4=2773480762; for(let i=0;i<str.length;i++){ let k=str.charCodeAt(i); h1=h2^(Math.imul(h1^k,597399067)); h2=h3^(Math.imul(h2^k,2869860233)); h3=h4^(Math.imul(h3^k,951274213)); h4=h1^(Math.imul(h4^k,2716044179)); } h1=Math.imul(h3^(h1>>>18),597399067); h2=Math.imul(h4^(h2>>>22),2869860233); h3=Math.imul(h1^(h3>>>17),951274213); h4=Math.imul(h2^(h4>>>19),2716044179); let r=(h1^h2^h3^h4)>>>0; return r.toString(36); }
function mulberry32(a){ return function(){ let t=a+=0x6D2B79F5; t=Math.imul(t^(t>>>15), t|1); t^=t+Math.imul(t^(t>>>7), t|61); return ((t^(t>>>14))>>>0)/4294967296; } }
function rngFromSeed(seed){ let n=0; for(const ch of seed) n=(n*31 + ch.charCodeAt(0))>>>0; return mulberry32(n||1); }
function choice(r, arr){ return arr[Math.floor(r()*arr.length)] }
function pickN(r, arr, n){ const a=[...arr]; const out=[]; for(let i=0;i<n && a.length;i++){ out.push(a.splice(Math.floor(r()*a.length),1)[0]); } return out; }
function cap(s){ return s.charAt(0).toUpperCase()+s.slice(1) }
function id(prefix, i){ return `${prefix}-${String(i).padStart(3,'0')}` }
function syllableName(r, tone){
const syll = {
classic:["an","ar","bel","ca","da","el","fa","gal","har","is","jor","kel","lir","mor","nel","or","pa","qua","rhi","sa","tor","ur","val","wen","xel","yor","zel"],
dark:["mor","noir","gloam","umb","dol","grav","nek","var","zul","vex","drei","thar","khar","wyrm"],
steampunk:["gear","steam","bolt","cog","brass","tink","pneu","copper","fuse","riv","spindle"],
myth:["aeg","od","ish","ra","zeph","io","sol","lun","tyr","fre","eir","hel"],
sci:["neo","ion","quant","cyber","astra","plasma","proto","omega","nova","phase","flux"]
};
const pool = (syll[tone]||[]).concat(syll.classic);
const len = 2 + Math.floor(r()*2);
let s=""; for(let i=0;i<len;i++) s+= choice(r,pool);
return cap(s);
}
/* =========================
* テンプレ/語彙
* ========================= */
const LEX = {
roles: ["ギルドマスター","考古学者","巡回騎士","密偵","占星術師","錬金術師","旅の商人","巫女","司書","鍛冶師","船乗り","薬師","狩人","吟遊詩人","修道士"],
traits: ["勇敢","狡猾","博識","短気","誠実","猜疑心が強い","陽気","冷静","計算高い","臆病","義理堅い","野心家"],
factions: ["碧星同盟","砂冠商会","螺旋教団","古図書騎士団","白霧旅団","錆鉄工房","風詠み集落","赤砂盗賊団"],
biomes: ["砂漠","湿原","黒森","高地","沿岸","雪原","火山地帯","古代都市跡"],
itemTypes: ["剣","短剣","槍","杖","弓","護符","指輪","書","設計図","薬","鉱石","布","レンズ","コイル"],
rarities: ["Common","Uncommon","Rare","Epic","Legendary"],
verbs: ["救出せよ","護衛せよ","探索せよ","奪還せよ","調査せよ","討伐せよ","修復せよ","封印せよ","交渉せよ","護送せよ","潜入せよ"],
twists: ["依頼主は真犯人","実は時間制限あり","二重スパイがいる","偽物が混じっている","古き呪いが再発","天候異常が発生","儀式の日が前倒し"],
rewardsExtra: ["評判+10","ギルドランク昇格","隠し店舗の解放","旅人の加護","快速移動の解放"]
};
const DIFF_MULT = { easy: 0.8, normal: 1.0, hard: 1.3, epic: 1.7 };
/* =========================
* 生成器
* ========================= */
function genFactions(r, themes){
const count = Math.min(5, 2 + Math.floor(r()*4));
return Array.from({length:count}, (_,i)=>({ id: id('F',i+1), name: `${choice(r,LEX.factions)}`, goal: `${choice(r,["遺物の独占","古文書の解読","交易路の掌握","禁術の復活","辺境防衛"])}`, vibe: choice(r,["協調的","中立","敵対的"]) }));
}
function genLocations(r, themes){
const count = Math.min(8, 4 + Math.floor(r()*5));
return Array.from({length:count}, (_,i)=>({ id: id('L',i+1), name: `${choice(r,LEX.biomes)}の${syllableName(r,'classic')}`, feature: choice(r,["崩れた門","封じ石","光る碑文","隠し水路","浮遊足場","古代機構"]) }));
}
function genNPCs(r, tone, factions){
const count = Math.min(12, 6 + Math.floor(r()*6));
return Array.from({length:count}, (_,i)=>{
const fac = choice(r, factions);
return {
id: id('N',i+1),
name: syllableName(r,tone),
role: choice(r, LEX.roles),
trait: choice(r, LEX.traits),
faction: fac?.id || null
}
});
}
function genItems(r, tone){
const count = Math.min(18, 8 + Math.floor(r()*10));
return Array.from({length:count}, (_,i)=>{
const t = choice(r, LEX.itemTypes);
const rare = choice(r, LEX.rarities);
return {
id: id('I',i+1),
name: `${syllableName(r,tone)}の${t}`,
type: t,
rarity: rare,
value: Math.floor((10+ r()*90) * (1 + 0.3*LEX.rarities.indexOf(rare)))
}
});
}
function genQuests(r, tone, count, npcs, locations, items, difficulty){
const q = [];
const scale = DIFF_MULT[difficulty] || 1.0;
for(let i=0;i<count;i++){
const giver = choice(r, npcs);
const loc = choice(r, locations);
const verb = choice(r, LEX.verbs);
const keyItem = choice(r, items);
const level = Math.max(1, Math.round((i+1)*scale + r()*3));
const objectives = [
`${loc.name}で手掛かりを見つける`,
`${giver.name}(${giver.role})に報告する`,
`${keyItem.name}を入手する`
];
// 依存関係:稀に前のクエストを前提にする
let dependsOn = null;
if(i>0 && r()<0.4){ dependsOn = q[Math.floor(r()*i)].id; }
// ツイストは低確率で
const twist = r()<0.35 ? choice(r, LEX.twists) : null;
const rewardGold = Math.floor((100+ r()*200) * scale * (1 + i*0.05));
const rewardItems = pickN(r, items, r()<0.6?1:2).map(o=>o.id);
q.push({
id: id('Q',i+1),
title: `${verb}:${loc.name}`,
level,
giver: giver.id,
location: loc.id,
objectives,
requires: dependsOn,
reward: { gold: rewardGold, items: rewardItems, extra: r()<0.25? choice(r, LEX.rewardsExtra): null },
twist
});
}
return q;
}
function assembleWorld(input){
const seed = input.seed || `${Date.now().toString(36)}-${cyrb128(input.worldName + (input.themes||''))}`;
const r = rngFromSeed(seed);
const tone = input.tone || 'classic';
const factions = genFactions(r, input.themes);
const locations = genLocations(r, input.themes);
const npcs = genNPCs(r, tone, factions);
const items = genItems(r, tone);
const quests = genQuests(r, tone, input.questCount, npcs, locations, items, input.difficulty);
return { meta: { seed, createdAt: new Date().toISOString(), worldName: input.worldName||syllableName(r,tone), themes: input.themes, difficulty: input.difficulty, tone }, factions, locations, npcs, items, quests };
}
/* =========================
* 出力レンダリング
* ========================= */
function renderText(world){
const idmap = (arr)=> Object.fromEntries(arr.map(a=>[a.id,a]));
const NPC = idmap(world.npcs);
const LOC = idmap(world.locations);
const ITM = idmap(world.items);
const lines = [];
lines.push(`# 世界:${world.meta.worldName}`);
lines.push(`- テーマ:${world.meta.themes||'—'} / トーン:${world.meta.tone} / 難易度:${world.meta.difficulty}`);
lines.push(`- 生成日時:${new Date(world.meta.createdAt).toLocaleString()}`);
lines.push(`- シード:${world.meta.seed}`);
lines.push(`\n## 勢力(${world.factions.length})`);
world.factions.forEach(f=>{ lines.push(`- [${f.id}] ${f.name}|目的:${f.goal}|態度:${f.vibe}`) });
lines.push(`\n## 場所(${world.locations.length})`);
world.locations.forEach(l=>{ lines.push(`- [${l.id}] ${l.name}|特徴:${l.feature}`) });
lines.push(`\n## NPC(${world.npcs.length})`);
world.npcs.forEach(n=>{ lines.push(`- [${n.id}] ${n.name}(${n.role}/${n.trait}) 所属:${n.faction||'なし'}`) });
lines.push(`\n## アイテム(${world.items.length})`);
world.items.forEach(i=>{ lines.push(`- [${i.id}] ${i.name}|種類:${i.type}|希少度:${i.rarity}|価値:${i.value}`) });
lines.push(`\n## クエスト(${world.quests.length})`);
world.quests.forEach(q=>{
const giver = NPC[q.giver]?.name || q.giver;
const loc = LOC[q.location]?.name || q.location;
const req = q.requires? `(前提:${q.requires})` : '';
lines.push(`\n### [${q.id}] ${q.title} Lv.${q.level} ${req}`);
lines.push(`- 依頼主:${giver}`);
lines.push(`- 場所:${loc}`);
lines.push(`- 目的:`);
q.objectives.forEach(o=>lines.push(` - ${o}`));
const rewardItems = q.reward.items.map(id=> ITM[id]?.name || id).join('、');
lines.push(`- 報酬:${q.reward.gold}G / アイテム:${rewardItems}${q.reward.extra? ' / '+q.reward.extra:''}`);
if(q.twist) lines.push(`- ツイスト:${q.twist}`);
});
return lines.join('\n');
}
function renderHTML(world){
const idmap = (arr)=> Object.fromEntries(arr.map(a=>[a.id,a]));
const NPC = idmap(world.npcs);
const LOC = idmap(world.locations);
const ITM = idmap(world.items);
const head = `
<h1>世界:${world.meta.worldName}</h1>
<div class="kv text-sm text-slate-600 gap-x-2">
<span class="badge">トーン:${world.meta.tone}</span>
<span class="badge">難易度:${world.meta.difficulty}</span>
<span class="badge">クエスト:${world.quests.length}</span>
<span class="badge">シード:${world.meta.seed}</span>
</div>
<p class="mt-2 text-sm text-slate-600">テーマ:${world.meta.themes||'—'} / 生成日時:${new Date(world.meta.createdAt).toLocaleString()}</p>
`;
const factions = `
<h2>勢力(${world.factions.length})</h2>
<ul>
${world.factions.map(f=>`<li><code>[${f.id}]</code> ${f.name}|目的:${f.goal}|態度:${f.vibe}</li>`).join('')}
</ul>
`;
const locs = `
<h2>場所(${world.locations.length})</h2>
<ul>
${world.locations.map(l=>`<li><code>[${l.id}]</code> ${l.name}|特徴:${l.feature}</li>`).join('')}
</ul>
`;
const npcs = `
<h2>NPC(${world.npcs.length})</h2>
<ul>
${world.npcs.map(n=>`<li><code>[${n.id}]</code> ${n.name}(${n.role}/${n.trait}) 所属:${n.faction||'なし'}</li>`).join('')}
</ul>
`;
const items = `
<h2>アイテム(${world.items.length})</h2>
<ul>
${world.items.map(i=>`<li><code>[${i.id}]</code> ${i.name}|種類:${i.type}|希少度:${i.rarity}|価値:${i.value}</li>`).join('')}
</ul>
`;
const quests = `
<h2>クエスト(${world.quests.length})</h2>
${world.quests.map(q=>{
const giver = NPC[q.giver]?.name || q.giver;
const loc = LOC[q.location]?.name || q.location;
const req = q.requires? `(前提:${q.requires})` : '';
const rewardItems = q.reward.items.map(id=> ITM[id]?.name || id).join('、');
return `
<details class="quest">
<summary><strong><code>[${q.id}]</code> ${q.title}</strong> <span class="text-sm text-slate-600">Lv.${q.level} ${req}</span></summary>
<div class="mt-2 text-sm">
<div>依頼主:${giver}</div>
<div>場所:${loc}</div>
<div class="mt-1">目的:</div>
<ul>
${q.objectives.map(o=>`<li>${o}</li>`).join('')}
</ul>
<div class="mt-1">報酬:${q.reward.gold}G / アイテム:${rewardItems}${q.reward.extra? ' / '+q.reward.extra:''}</div>
${q.twist? `<div class="mt-1 text-rose-700">ツイスト:${q.twist}</div>`:''}
</div>
</details>`;
}).join('')}
`;
return [head, factions, locs, npcs, items, quests].join('');
}
function renderCards(world){
const $cards = document.getElementById('cards');
$cards.innerHTML = '';
const make = (title, body)=>{
const el = document.createElement('div');
el.className = 'rounded-2xl border p-4 bg-white';
el.innerHTML = `<div class="text-sm font-bold mb-2">${title}</div><div class="text-xs text-slate-700 whitespace-pre-wrap">${body}</div>`;
$cards.appendChild(el);
};
make('ワールド', `名前:${world.meta.worldName}\n難易度:${world.meta.difficulty}\nトーン:${world.meta.tone}\nシード:${world.meta.seed}`);
make('勢力', world.factions.map(f=>`[${f.id}] ${f.name}/目的:${f.goal}`).join('\n'));
make('場所', world.locations.map(l=>`[${l.id}] ${l.name}/${l.feature}`).join('\n'));
make('NPC', world.npcs.slice(0,12).map(n=>`[${n.id}] ${n.name}/${n.role}`).join('\n'));
make('アイテム', world.items.slice(0,15).map(i=>`[${i.id}] ${i.name}/${i.rarity}`).join('\n'));
make('クエスト', world.quests.map(q=>`[${q.id}] ${q.title} Lv.${q.level}${q.requires? '(前提:'+q.requires+')':''}`).join('\n'));
}
/* =========================
* CSV/JSON/コピー/保存
* ========================= */
function toCSV(rows){
return rows.map(r=> r.map(v=>`"${String(v).replaceAll('"','""')}"`).join(',')).join('\n');
}
function exportCSVs(world){
const npcRows = [["id","name","role","trait","faction"]].concat(world.npcs.map(n=>[n.id,n.name,n.role,n.trait,n.faction||'']));
const itemRows = [["id","name","type","rarity","value"]].concat(world.items.map(i=>[i.id,i.name,i.type,i.rarity,i.value]));
const questRows = [["id","title","level","giver","location","requires","objectives","reward_gold","reward_items","twist"]].concat(
world.quests.map(q=>[
q.id, q.title, q.level, q.giver, q.location, q.requires||'', q.objectives.join(' / '), q.reward.gold, q.reward.items.join('|'), q.twist||''
])
);
const files = [
{name:`${world.meta.worldName}_NPC.csv`, data: toCSV(npcRows)},
{name:`${world.meta.worldName}_Items.csv`, data: toCSV(itemRows)},
{name:`${world.meta.worldName}_Quests.csv`, data: toCSV(questRows)}
];
files.forEach(f=>{
const blob = new Blob(["\ufeff"+f.data], {type:'text/csv'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = f.name; a.click(); URL.revokeObjectURL(a.href);
});
}
function downloadJSON(world){
const blob = new Blob([JSON.stringify(world, null, 2)], {type:'application/json'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `${world.meta.worldName}_world.json`; a.click(); URL.revokeObjectURL(a.href);
}
function copyText(text){
navigator.clipboard.writeText(text).then(()=>{
toast('テキストをコピーしました');
});
}
function saveLocal(world){ localStorage.setItem('quest_foundry_last', JSON.stringify(world)); toast('保存しました'); }
function loadLocal(){ const s=localStorage.getItem('quest_foundry_last'); if(!s){ toast('保存データなし'); return null; } try{ return JSON.parse(s);}catch(e){ toast('読込失敗'); return null; } }
/* =========================
* UI
* ========================= */
function toast(msg){
const t = document.createElement('div');
t.className = 'fixed bottom-4 left-1/2 -translate-x-1/2 bg-slate-900 text-white text-sm px-4 py-2 rounded-xl shadow-lg';
t.textContent = msg; document.body.appendChild(t);
setTimeout(()=>{ t.classList.add('opacity-0'); t.style.transition='opacity .6s'; }, 1600);
setTimeout(()=> t.remove(), 2300);
}
let lastInput = null;
let lastWorld = null;
function currentInput(){
const worldName = document.getElementById('worldName').value.trim();
const themes = document.getElementById('themes').value.trim();
const difficulty = document.getElementById('difficulty').value;
const questCount = Math.max(1, Math.min(30, parseInt(document.getElementById('questCount').value || '8')));
const seed = document.getElementById('seed').value.trim();
const tone = document.getElementById('tone').value;
return { worldName, themes, difficulty, questCount, seed, tone };
}
function applyWorld(world){
lastWorld = world;
document.getElementById('meta').textContent = `ワールド:${world.meta.worldName} / クエスト:${world.quests.length}件`;
document.getElementById('outText').innerHTML = renderHTML(world);
document.getElementById('outJSON').textContent = JSON.stringify(world, null, 2);
renderCards(world);
}
function generate(withNewSeed=false){
const input = currentInput();
if(withNewSeed && !document.getElementById('lockSeed').checked){ input.seed = ''; }
if(!input.seed) { input.seed = cyrb128((input.worldName||'World') + (input.themes||'') + Date.now()); document.getElementById('seed').value = input.seed; }
lastInput = input;
const world = assembleWorld(input);
applyWorld(world);
}
// イベント
document.getElementById('btnGenerate').addEventListener('click', ()=> generate(false));
document.getElementById('btnRegenerate').addEventListener('click', ()=> generate(false));
document.getElementById('btnShuffleSeed').addEventListener('click', ()=> generate(true));
document.getElementById('btnCopyText').addEventListener('click', ()=>{ if(lastWorld) copyText(renderText(lastWorld)); });
document.getElementById('btnDownloadJSON').addEventListener('click', ()=>{ if(lastWorld) downloadJSON(lastWorld); });
document.getElementById('btnExportCSV').addEventListener('click', ()=>{ if(lastWorld) exportCSVs(lastWorld); });
document.getElementById('btnToggleJson').addEventListener('click', ()=>{ document.getElementById('jsonBlock').classList.toggle('hidden'); });
document.getElementById('btnSave').addEventListener('click', ()=>{ if(lastWorld) saveLocal(lastWorld); });
document.getElementById('btnLoad').addEventListener('click', ()=>{ const w=loadLocal(); if(w) applyWorld(w); });
document.getElementById('btnPrint').addEventListener('click', ()=> window.print());
// 初期プレースホルダ生成
window.addEventListener('DOMContentLoaded', ()=>{
document.getElementById('worldName').value = '運命の剣界';
document.getElementById('themes').value = '古代遺跡 風の精霊 砂漠 旅人ギルド';
generate(true);
});
</script>
</body>
</html>
X風サイト.html
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>X-like UI – Polished (Left-aligned)</title>
<style>
/* ===== Design Tokens ===== */
:root{
/* colors */
--bg: #0b0d10;
--surface: #0e1116;
--panel: rgba(16,18,24,.75);
--card: #0f1319;
--line: #1c2230; /* single-pixel separators */
--text: #e7ecf3;
--muted: #9aa7ba;
--accent: #1da1f2;
--accent-2: #7dd3fc;
--accent-3: #60a5fa;
/* radius & shadow */
--r: 14px;
--shadow-sm: 0 1px 0 rgba(255,255,255,.02) inset, 0 8px 24px rgba(0,0,0,.35);
--shadow-card: 0 10px 30px rgba(0,0,0,.25);
/* layout */
--col-left: 84px;
--col-center-min: 360px;
--col-center-max: 720px;
--col-right-min: 280px;
--col-right-max: 420px;
/* NEW: tighter left gutter for center column */
--gutter-x: 10px;
/* motion */
--e1: cubic-bezier(.2,.8,.2,1);
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
background:
radial-gradient(1200px 600px at 10% -10%, rgba(29,161,242,.15), transparent 40%),
radial-gradient(800px 500px at 110% -10%, rgba(96,165,250,.12), transparent 40%),
var(--bg);
color:var(--text);
font:14px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans JP", "Hiragino Kaku Gothic ProN", "Helvetica Neue", Arial;
}
/* focus ring */
:where(a,button,[role="button"],input,textarea,.sb-btn,.ic-btn,.plus,.btn,.btn-ghost,.btn-primary){outline:none}
:where(a,button,[role="button"],input,textarea,.sb-btn,.ic-btn,.plus,.btn,.btn-ghost,.btn-primary):focus-visible{
box-shadow:0 0 0 3px rgba(29,161,242,.35);
border-radius:10px;
}
/* ===== Layout ===== */
.app{
display:grid; gap:0;
grid-template-columns: var(--col-left) minmax(var(--col-center-min),var(--col-center-max)) minmax(var(--col-right-min),var(--col-right-max));
height:100%;
max-width:1280px;
margin:0 auto;
}
.sidebar{
position:sticky; top:0; height:100vh;
backdrop-filter: blur(10px);
background:linear-gradient(180deg, rgba(16,18,24,.7), rgba(16,18,24,.3));
/* NOTE: borders are drawn via ::after to avoid subpixel drift */
padding:10px 8px; display:flex; flex-direction:column; gap:6px;
}
.sb-btn{
border-radius:999px; padding:12px 14px; cursor:pointer;
display:flex; align-items:center; gap:14px; transition:background .2s var(--e1), transform .12s var(--e1);
}
.sb-btn:hover{background:rgba(255,255,255,.04)}
.sb-btn:active{transform:translateY(1px)}
.sb-icon{width:28px;height:28px;display:grid;place-items:center;border-radius:999px}
.sb-btn .label{font-weight:700;letter-spacing:.02em}
.compose-fab{
position:fixed; left:18px; bottom:18px; z-index:50; cursor:pointer;
border:none; color:#fff; font-weight:800; letter-spacing:.02em;
border-radius:999px; padding:12px 18px;
background:linear-gradient(135deg, var(--accent), var(--accent-3));
box-shadow:0 10px 30px rgba(29,161,242,.35);
transition: transform .12s var(--e1), filter .2s var(--e1);
}
.compose-fab:hover{filter:brightness(1.05)}
.compose-fab:active{transform:translateY(1px)}
.you-chip{
position:fixed; left:18px; bottom:84px; width:48px;height:48px;border-radius:999px;
display:grid;place-items:center;font-weight:900; background:conic-gradient(from 180deg at 50% 50%, #0f629e, #0c3c68 70%, #0f629e);
color:#fff; border:1px solid rgba(255,255,255,.08);
}
.main{
position:relative; /* for ::after separator */
min-height:100vh; background:rgba(0,0,0,.25);
backdrop-filter: blur(6px);
}
.rightcol{
padding:14px; position:sticky; top:0; height:100vh; overflow:auto;
}
.searchbar{
display:flex; align-items:center; gap:10px; padding:10px 14px;
background:rgba(255,255,255,.04); border:1px solid rgba(255,255,255,.06);
border-radius:999px;
}
/* ===== Single-pixel separators (no drift) ===== */
.sidebar::after,
.main::after{
content:"";
position:absolute; top:0; bottom:0; width:1px; background:var(--line);
pointer-events:none; z-index:10;
}
.sidebar::after{ right:0; } /* Sidebar ↔ Main */
.main::after{ right:0; } /* Main ↔ Right column */
/* ===== Header Tabs ===== */
.header-title{padding:10px var(--gutter-x); font-size:20px; font-weight:900; border-bottom:1px solid var(--line)}
.tabs{
position:sticky; top:0; z-index:6;
display:flex; gap:24px; padding:0 var(--gutter-x); /* <<< tightened left padding */
background:linear-gradient(180deg, rgba(14,17,22,.8), rgba(14,17,22,.55));
backdrop-filter: saturate(120%) blur(8px);
border-bottom:1px solid var(--line);
}
.tab{
padding:16px 6px; cursor:pointer; color:var(--muted); font-weight:800; border-bottom:3px solid transparent;
transition:color .2s var(--e1), border-color .2s var(--e1);
}
.tab.active{color:var(--text); border-color:var(--accent)}
/* ===== Composer ===== */
.composer{
padding:14px var(--gutter-x); /* <<< tightened */
border-bottom:1px solid var(--line); display:flex; gap:12px;
background:linear-gradient(180deg, rgba(17,22,29,.8), rgba(17,22,29,.4));
}
.avatar{
width:42px; height:42px; border-radius:999px; display:grid; place-items:center; font-weight:900; letter-spacing:.02em;
color:#fff; border:1px solid rgba(255,255,255,.08);
background:radial-gradient(120% 120% at 20% 15%, #1e81c5, #0f2a43 70%);
}
.composer-box{flex:1}
.composer textarea{
width:100%; min-height:76px; resize:vertical; background:transparent; border:none; color:var(--text);
outline:none; font-size:18px; caret-color:var(--accent);
}
.reply-scope{color:var(--accent);font-weight:700;font-size:13px}
.row{display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:6px}
.icons{display:flex; gap:8px}
.ic-btn{width:32px;height:32px;display:grid;place-items:center;border-radius:10px;cursor:pointer;transition:background .2s}
.ic-btn:hover{background:rgba(255,255,255,.06)}
.post-btn{
border:none; color:#0b1220; font-weight:900; letter-spacing:.02em;
padding:9px 18px; border-radius:999px; cursor:not-allowed;
background:linear-gradient(135deg, #6b7280 0%, #9aa7ba 100%);
filter:saturate(.7); opacity:.7; transition:filter .2s, transform .12s;
}
.post-btn.enabled{
cursor:pointer; opacity:1; color:#fff; filter:none;
background:linear-gradient(135deg, var(--accent), var(--accent-3));
box-shadow:0 12px 30px rgba(29,161,242,.3);
}
.post-btn.enabled:hover{filter:brightness(1.05)}
.post-btn.enabled:active{transform:translateY(1px)}
/* ===== Cards ===== */
.card{
border-bottom:1px solid var(--line); display:flex; gap:12px;
padding:14px var(--gutter-x); /* <<< tightened */
background:linear-gradient(180deg, rgba(16,20,27,.5), rgba(16,20,27,.25));
}
.meta{display:flex; gap:6px; color:var(--muted)}
.name{font-weight:900}
.handle{color:var(--muted)}
.hash a{color:var(--accent)}
.post-img{
border-radius:18px; border:1px solid rgba(255,255,255,.06); width:100%; margin-top:10px;
box-shadow:var(--shadow-card)
}
.actions{display:flex; gap:26px; margin-top:8px; color:var(--muted)}
.action{display:flex; gap:6px; align-items:center; cursor:pointer; transition:color .15s}
.action:hover{color:var(--accent)}
/* ===== Right column ===== */
.rightcol .rc-card{
background:linear-gradient(180deg, rgba(17,22,29,.65), rgba(17,22,29,.35));
border:1px solid rgba(255,255,255,.08); border-radius:18px; overflow:hidden; margin-top:12px; box-shadow:var(--shadow-sm)
}
.rc-title{font-weight:900; padding:12px 16px; border-bottom:1px solid rgba(255,255,255,.06)}
.rc-item{padding:12px 16px; border-top:1px solid rgba(255,255,255,.04)}
.chip{background:linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.02)); border:1px solid rgba(255,255,255,.08);
padding:4px 10px; border-radius:999px; color:var(--muted); font-size:12px}
/* ===== Subtabs / Explore ===== */
.subtabs{display:flex; gap:18px; padding:10px var(--gutter-x); border-bottom:1px solid var(--line); background:rgba(255,255,255,.02)} /* <<< tightened */
.subtab{padding:10px 2px; color:var(--muted); font-weight:800; cursor:pointer; border-bottom:3px solid transparent}
.subtab.active{color:var(--text); border-color:var(--accent)}
/* ===== Empties / Profile / Lists ===== */
.empty{display:grid; place-items:center; padding:72px var(--gutter-x); color:var(--muted); text-align:center}
.empty h2{margin:0 0 6px; color:var(--text)}
.cover{height:170px; background:
radial-gradient(70% 120% at 10% 0%, rgba(29,161,242,.25), transparent 40%),
radial-gradient(60% 120% at 100% 0%, rgba(96,165,250,.2), transparent 40%),
linear-gradient(180deg,#121722,#0b0f17)}
.prof-wrap{padding:0 var(--gutter-x) 16px}
.prof-row{display:flex; justify-content:space-between; align-items:end; margin-top:-36px}
.pfp{width:96px;height:96px;border-radius:999px;border:4px solid var(--surface);background:radial-gradient(120% 120% at 20% 15%, #1e81c5, #0f2a43 70%);display:grid;place-items:center;color:#fff;font-weight:900}
.btn{background:transparent; border:1px solid rgba(255,255,255,.18); color:#fff; border-radius:999px; padding:8px 14px; cursor:pointer; transition:background .2s}
.btn:hover{background:rgba(255,255,255,.05)}
.alert{background:rgba(18,42,24,.65); border:1px solid #165c36; color:#a9f2b7; border-radius:14px; padding:12px 14px; margin:12px var(--gutter-x); display:flex; gap:10px; align-items:center}
.check{width:18px;height:18px;border-radius:4px;border:2px solid #a9f2b7;display:grid;place-items:center}
.list-row{display:flex; align-items:center; justify-content:space-between; gap:12px; padding:12px 16px}
.list-pill{width:44px;height:44px;border-radius:12px; background:linear-gradient(135deg,#374151,#1f2937)}
.plus{width:28px;height:28px;border-radius:999px;border:1px solid rgba(255,255,255,.18);display:grid;place-items:center}
/* ===== Grok ===== */
.grok{display:grid; place-items:center; padding:44px var(--gutter-x)}
.grok .logo{font-size:28px; font-weight:1000; display:flex; align-items:center; gap:10px}
.grok .input{margin-top:18px; display:flex; gap:8px; width:min(680px,90vw)}
.grok .input input{flex:1; padding:14px; border-radius:12px; border:1px solid rgba(255,255,255,.18); background:#0c1118; color:#fff; transition:border .2s}
.grok .input input:focus{border-color:rgba(125,211,252,.6)}
.grok .input button{padding:12px 16px; border-radius:12px; border:1px solid rgba(255,255,255,.18); background:linear-gradient(135deg,#10151f,#0e131b); color:#fff}
.grok .tools{display:flex; gap:8px; margin-top:12px}
/* ===== E2EE Page ===== */
.e2ee{padding:56px var(--gutter-x); text-align:center}
.e2ee h1{font-size:26px; margin:0 0 12px}
.bullet{display:flex; gap:10px; align-items:flex-start; justify-content:center; color:#cfd9ea}
.e2ee .cta{display:flex; gap:12px; justify-content:center; margin-top:18px}
.btn-ghost{border:1px solid rgba(255,255,255,.18); background:transparent; color:#fff; padding:10px 14px; border-radius:999px}
.btn-primary{border:1px solid rgba(29,161,242,.55); background:linear-gradient(135deg, var(--accent), var(--accent-2)); color:#001e33; font-weight:900; padding:10px 16px; border-radius:999px}
/* ===== Modal ===== */
.modal-backdrop{
position:fixed; inset:0; background:rgba(4,8,12,.6); display:none; align-items:center; justify-content:center; z-index:100;
animation:fadeIn .2s var(--e1);
}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
.modal{
width:min(640px,92vw); background:linear-gradient(180deg, rgba(14,18,24,.95), rgba(14,18,24,.85));
border:1px solid rgba(255,255,255,.12); border-radius:20px; overflow:hidden; box-shadow:var(--shadow-card)
}
.modal .top{display:flex; align-items:center; justify-content:space-between; padding:10px 14px; border-bottom:1px solid rgba(255,255,255,.06)}
.close-x{width:32px;height:32px;border-radius:999px;display:grid;place-items:center;cursor:pointer;transition:background .2s}
.close-x:hover{background:rgba(255,255,255,.06)}
.small{font-size:12px;color:var(--muted)}
/* ===== Toast ===== */
.toast{
position:fixed; left:50%; transform:translateX(-50%) translateY(20px);
bottom:20px; background:rgba(18,22,28,.92); border:1px solid rgba(255,255,255,.12);
padding:10px 14px; border-radius:999px; display:none; gap:10px; align-items:center; z-index:120;
box-shadow:var(--shadow-card); animation:slideUp .25s var(--e1);
}
.toast .view{background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.12); padding:6px 10px; border-radius:999px}
@keyframes slideUp{from{opacity:0; transform:translateX(-50%) translateY(40px)} to{opacity:1; transform:translateX(-50%) translateY(0)}}
/* ===== Utilities ===== */
.hide{display:none !important}
/* ===== Responsive ===== */
@media (max-width:1100px){
.app{grid-template-columns: 72px 1fr}
.rightcol{display:none}
}
@media (max-width:520px){
.sb-btn .label{display:none}
.sidebar{align-items:center}
}
</style>
</head>
<body>
<div class="app">
<!-- Sidebar -->
<aside class="sidebar" aria-label="Sidebar">
<div class="sb-btn" data-nav="home"><div class="sb-icon">🏠</div><div class="label">Home</div></div>
<div class="sb-btn" data-nav="explore"><div class="sb-icon">🔎</div><div class="label">Explore</div></div>
<div class="sb-btn" data-nav="notifications"><div class="sb-icon">🔔</div><div class="label">Notifications</div></div>
<div class="sb-btn" data-nav="messages"><div class="sb-icon">✉️</div><div class="label">Messages</div></div>
<div class="sb-btn" data-nav="grok"><div class="sb-icon">⚡</div><div class="label">Grok</div></div>
<div class="sb-btn" data-nav="lists"><div class="sb-icon">🗂️</div><div class="label">Lists</div></div>
<div class="sb-btn" data-nav="profile"><div class="sb-icon">👤</div><div class="label">Profile</div></div>
<div class="sb-btn" data-nav="encrypted"><div class="sb-icon">🔒</div><div class="label">Chat</div></div>
</aside>
<!-- Main -->
<main class="main" id="main">
<!-- Home -->
<section id="view-home" role="region" aria-label="Home timeline">
<div class="tabs">
<div class="tab active" data-home-tab="foryou">For you</div>
<div class="tab" data-home-tab="following">Following</div>
</div>
<div class="composer">
<div class="avatar">裕平</div>
<div class="composer-box">
<div class="reply-scope">Everyone can reply</div>
<textarea id="compose-input" placeholder="What’s happening?"></textarea>
<div class="row">
<div class="icons" aria-label="Composer actions">
<div class="ic-btn" title="Media">🖼️</div>
<div class="ic-btn" title="GIF">🌀</div>
<div class="ic-btn" title="Poll">📊</div>
<div class="ic-btn" title="Emoji">😊</div>
<div class="ic-btn" title="Schedule">🗓️</div>
<div class="ic-btn" title="Location">📍</div>
</div>
<button id="post-btn" class="post-btn" disabled>Post</button>
</div>
</div>
</div>
<article class="card">
<div class="avatar">H</div>
<div style="flex:1">
<div class="meta"><span class="name">HANA</span><span class="handle">@HANA__BRAVE · 9h</span></div>
<div>🎂 <span class="hash"><a href="#">#HAPPYJISOODAY</a></span><br/>Happy Birthday JISOO 🤍<br/><span class="hash"><a href="#">#HANA</a> <a href="#">#JISOO</a></span></div>
<img class="post-img" alt="sample" src="https://picsum.photos/seed/jisoo/720/380" />
<div class="actions">
<div class="action">💬 <span>24</span></div>
<div class="action">🔁 <span>10</span></div>
<div class="action">❤️ <span>128</span></div>
<div class="action">↗️</div>
</div>
</div>
</article>
<article class="card">
<div class="avatar">X</div>
<div style="flex:1">
<div class="meta"><span class="name">MTV VMA · LIVE</span><span class="handle"> · now</span></div>
<div class="hash"><a href="#">#VMAs</a></div>
<img class="post-img" alt="vmas" src="https://picsum.photos/seed/vma/720/300" />
<div class="actions">
<div class="action">💬 <span>8</span></div>
<div class="action">🔁 <span>3</span></div>
<div class="action">❤️ <span>42</span></div>
<div class="action">↗️</div>
</div>
</div>
</article>
<div id="feed-anchor"></div>
</section>
<!-- Explore -->
<section id="view-explore" class="hide" role="region" aria-label="Explore">
<div class="header-title">
<div class="searchbar"><span>🔎</span><input style="flex:1;background:transparent;border:none;color:#fff;outline:none" placeholder="Search" /></div>
</div>
<div class="subtabs">
<div class="subtab active">For You</div>
<div class="subtab">Trending</div>
<div class="subtab">News</div>
<div class="subtab">Sports</div>
<div class="subtab">Entertainment</div>
</div>
<article class="card">
<div class="avatar">T</div>
<div style="flex:1">
<div class="meta"><span class="name">Tokyo 2025</span><span class="handle"> · promoted</span></div>
<img class="post-img" alt="tokyo" src="https://picsum.photos/seed/tokyo2025/720/260" />
</div>
</article>
</section>
<!-- Notifications -->
<section id="view-notifications" class="hide" role="region" aria-label="Notifications">
<div class="tabs">
<div class="tab active">All</div>
<div class="tab">Verified</div>
<div class="tab">Mentions</div>
</div>
<div class="empty">
<div>
<h2>Nothing to see here — yet</h2>
<div>From likes to reposts and a whole lot more, this is where all the action happens.</div>
</div>
</div>
</section>
<!-- Messages -->
<section id="view-messages" class="hide" role="region" aria-label="Messages">
<div class="inbox" style="min-height:60vh">
<div class="dm-left" style="border-right:1px solid var(--line)">
<div style="padding:16px">
<h3 style="margin:4px 0">Welcome to your inbox!</h3>
<div class="muted">Drop a line, share posts and more with private conversations between you and others on X.</div>
<div style="height:10px"></div>
<button class="btn">Write a message</button>
</div>
</div>
<div>
<div class="empty">
<div>
<h2>Select a message</h2>
<div class="muted">Choose from your existing conversations, start a new one, or just keep swimming.</div>
<div style="height:10px"></div>
<button class="btn">New message</button>
</div>
</div>
</div>
</div>
</section>
<!-- Lists -->
<section id="view-lists" class="hide" role="region" aria-label="Lists">
<div class="header-title">Lists</div>
<div style="padding:16px">
<div class="muted" style="padding:8px 16px">Discover new Lists</div>
<div class="rc-card" role="list">
<div class="list-row">
<div style="display:flex;gap:12px;align-items:center">
<div class="list-pill"></div>
<div>
<div>J.League · <span class="muted">60 members</span></div>
<div class="muted">2K followers including @sascha348</div>
</div>
</div>
<div class="plus">+</div>
</div>
<div class="list-row">
<div style="display:flex;gap:12px;align-items:center">
<div class="list-pill"></div>
<div>
<div>Official Accounts · <span class="muted">83 members</span></div>
<div class="muted">263 followers including @dencetuno</div>
</div>
</div>
<div class="plus">+</div>
</div>
<div class="list-row">
<div style="display:flex;gap:12px;align-items:center">
<div class="list-pill"></div>
<div>
<div>kitchen · <span class="muted">52 members</span></div>
<div class="muted">181 followers including @Carolina_3254</div>
</div>
</div>
<div class="plus">+</div>
</div>
</div>
<div class="muted" style="padding:22px 16px 8px">Your Lists</div>
<div class="empty" style="opacity:.75"><div>You haven't created or followed any Lists. When you do, they'll show up here.</div></div>
</div>
</section>
<!-- Profile -->
<section id="view-profile" class="hide" role="region" aria-label="Profile">
<div class="cover"></div>
<div class="prof-wrap">
<div class="prof-row">
<div class="pfp">裕平</div>
<button class="btn">Edit profile</button>
</div>
<h2 style="margin:10px 0 0">長留裕平</h2>
<div class="muted">@PingZhang89719 · Joined September 2025</div>
<div style="height:8px"></div>
<div class="muted">0 Following · 0 Followers</div>
</div>
<div class="alert">
<div class="check">✔</div>
<div>
<div class="name" style="font-weight:900">You aren’t verified yet</div>
<div class="muted">Get verified for boosted replies, analytics, ad-free browsing, and more.</div>
</div>
</div>
<article class="card">
<div class="avatar">裕平</div>
<div style="flex:1">
<div class="meta"><span class="name">長留裕平</span><span class="handle"> · 1m</span></div>
<div>はじめました。</div>
</div>
</article>
</section>
<!-- Grok -->
<section id="view-grok" class="hide" role="region" aria-label="Grok">
<div class="grok">
<div class="logo">⚡ <span>Grok</span></div>
<div class="muted">Ask anything</div>
<div class="input">
<input placeholder="Ask anything" />
<button>➤</button>
</div>
<div class="tools">
<button class="btn">Create Images</button>
<button class="btn">Edit Image</button>
</div>
<div class="muted" style="margin-top:16px">Fast ▾ · History</div>
</div>
</section>
<!-- Encrypted Chat -->
<section id="view-encrypted" class="hide" role="region" aria-label="Encrypted Chat">
<div class="e2ee">
<h1>Meet new Chat, now fully encrypted.</h1>
<div class="muted">X Chat are now protected with end-to-end encryption on all your devices.</div>
<div style="height:12px"></div>
<div class="bullet">🔒 <div><b>End-to-End Encryption</b><br/>Your messages are protected across devices.</div></div>
<div class="bullet">🛡️ <div><b>Uncompromising Privacy</b><br/>No one — not even X — can access or read your messages.</div></div>
<div class="cta">
<button class="btn-ghost">Maybe later</button>
<button class="btn-primary">Set up now</button>
</div>
</div>
</section>
</main>
<!-- Right -->
<aside class="rightcol" aria-label="Right column">
<div class="rc-card">
<div class="rc-title">What’s happening</div>
<div class="rc-item"><b>MTV Video Music Awards 2025</b><div class="muted">LIVE</div></div>
<div class="rc-item"><b>東京2025 世界陸上</b><div class="muted">Trending · 8,724 posts</div></div>
<div class="rc-item"><b>JISOO</b><div class="chip">K-POP · Trending</div></div>
</div>
<div class="rc-card">
<div class="rc-title">Who to follow</div>
<div class="rc-item">🅿️ <b>Product Dev</b> · <span class="muted">@buildhub</span> <button class="btn" style="float:right">Follow</button></div>
<div class="rc-item">🧠 <b>AI Lab</b> · <span class="muted">@ailab</span> <button class="btn" style="float:right">Follow</button></div>
</div>
</aside>
</div>
<!-- Floating -->
<button class="compose-fab" id="open-compose">Post</button>
<div class="you-chip">裕平</div>
<!-- Modal -->
<div class="modal-backdrop" id="composer-modal" aria-hidden="true">
<div class="modal" role="dialog" aria-modal="true" aria-label="New post">
<div class="top">
<div class="close-x" id="close-compose">✕</div>
<a class="small" href="#" id="drafts-link">Drafts</a>
</div>
<div class="composer" style="border:none">
<div class="avatar">裕平</div>
<div class="composer-box">
<div class="reply-scope">Everyone can reply</div>
<textarea id="modal-input" placeholder="What’s happening?"></textarea>
<div class="row">
<div class="icons">
<div class="ic-btn">🖼️</div><div class="ic-btn">🌀</div><div class="ic-btn">📊</div><div class="ic-btn">😊</div><div class="ic-btn">🗓️</div><div class="ic-btn">📍</div>
</div>
<button id="modal-post" class="post-btn" disabled>Post</button>
</div>
</div>
</div>
</div>
</div>
<!-- Toast -->
<div class="toast" id="toast">
<div>✅ Your post was sent.</div>
<a class="view" href="#feed-anchor">View</a>
</div>
<script>
/* ---------- Router ---------- */
const views = {
home: document.getElementById('view-home'),
explore: document.getElementById('view-explore'),
notifications: document.getElementById('view-notifications'),
messages: document.getElementById('view-messages'),
lists: document.getElementById('view-lists'),
profile: document.getElementById('view-profile'),
grok: document.getElementById('view-grok'),
encrypted: document.getElementById('view-encrypted'),
};
function show(view){
for(const k in views){ views[k].classList.add('hide'); }
(views[view]||views.home).classList.remove('hide');
window.location.hash = view;
}
document.querySelectorAll('.sb-btn').forEach(btn=>{
btn.addEventListener('click', ()=>show(btn.dataset.nav));
});
window.addEventListener('load', ()=>{
const v = location.hash.replace('#','');
if(v && views[v]) show(v);
});
/* ---------- Composer (inline) ---------- */
const composeInput = document.getElementById('compose-input');
const postBtn = document.getElementById('post-btn');
composeInput.addEventListener('input',()=>{
const on = composeInput.value.trim().length>0;
postBtn.disabled = !on; postBtn.classList.toggle('enabled', on);
});
postBtn.addEventListener('click', ()=>{
addPost(composeInput.value.trim());
composeInput.value=''; postBtn.disabled=true; postBtn.classList.remove('enabled');
showToast();
});
/* ---------- Composer (modal) ---------- */
const modal = document.getElementById('composer-modal');
const openCompose = document.getElementById('open-compose');
const closeCompose = document.getElementById('close-compose');
const modalInput = document.getElementById('modal-input');
const modalPost = document.getElementById('modal-post');
openCompose.addEventListener('click', ()=>{ modal.style.display='flex'; modalInput.focus(); });
closeCompose.addEventListener('click', ()=>{ modal.style.display='none'; });
modalInput.addEventListener('input', ()=>{
const on = modalInput.value.trim().length>0;
modalPost.disabled = !on; modalPost.classList.toggle('enabled', on);
});
modalPost.addEventListener('click', ()=>{
addPost(modalInput.value.trim());
modalInput.value=''; modalPost.disabled=true; modalPost.classList.remove('enabled');
modal.style.display='none'; showToast();
});
modal.addEventListener('click', (e)=>{ if(e.target===modal) modal.style.display='none'; });
/* ---------- Add Post ---------- */
function addPost(text){
if(!text) return;
const card = document.createElement('article');
card.className='card';
card.innerHTML = `
<div class="avatar">裕平</div>
<div style="flex:1">
<div class="meta"><span class="name">長留裕平</span><span class="handle"> · now</span></div>
<div>${escapeHTML(text)}</div>
<div class="actions">
<div class="action">💬 <span>0</span></div>
<div class="action">🔁 <span>0</span></div>
<div class="action">❤️ <span>0</span></div>
<div class="action">↗️</div>
</div>
</div>`;
const anchor = document.getElementById('feed-anchor');
anchor.parentNode.insertBefore(card, anchor);
}
function escapeHTML(s){return s.replaceAll('&','&').replaceAll('<','<').replaceAll('>','>')}
/* ---------- Toast ---------- */
const toast = document.getElementById('toast');
function showToast(){
toast.style.display='flex';
clearTimeout(showToast._t);
showToast._t = setTimeout(()=> toast.style.display='none', 2600);
}
/* ---------- Home tab visual only ---------- */
document.querySelectorAll('[data-home-tab]').forEach(t=>{
t.addEventListener('click', ()=>{
document.querySelectorAll('[data-home-tab]').forEach(x=>x.classList.remove('active'));
t.classList.add('active');
});
});
</script>
</body>
</html>
ミニ百科.html
<!DOCTYPE html>
<html lang="ja" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ミニ百科 – シングルファイル版</title>
<meta name="description" content="検索・カテゴリ・タグ・ブックマーク対応のシングルファイル百科事典。" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" />
<!-- TailwindCSS (CDN) -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome (icons) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" integrity="sha512-5I0VnK5tQhJ0eZ5Ck1gC3b6h9fJ3k6l9FeI3K6J0q9JtO1Yw1l2Y7N5M6d2xQf8Q2F6mZ8l2s3A=" crossorigin="anonymous" referrerpolicy="no-referrer" />
<!-- Favicon (inline SVG) -->
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Cpath fill='%234f46e5' d='M32 56c0-13.3 10.7-24 24-24h144c13.3 0 24 10.7 24 24v144c0 13.3-10.7 24-24 24H56c-13.3 0-24-10.7-24-24z'/%3E%3Cpath fill='white' d='M72 80h112v16H72zM72 112h80v16H72zM72 144h112v16H72zM72 176h96v16H72z'/%3E%3C/svg%3E" />
<style>
/* 追加の細かなスタイル */
.line-clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.prose h2{scroll-margin-top:6rem}
.toc a{display:block;padding:.25rem .5rem;border-radius:.5rem}
.toc a.active{background:rgba(99,102,241,.12)}
</style>
<script>
// ダークモード初期化
(function(){
const theme=localStorage.getItem('theme');
if(theme==='dark'||(!theme&&window.matchMedia('(prefers-color-scheme: dark)').matches)){
document.documentElement.classList.add('dark');
}
})();
</script>
</head>
<body class="bg-slate-50 text-slate-800 dark:bg-slate-900 dark:text-slate-100 min-h-screen">
<!-- Skip link -->
<a href="#main" class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:bg-indigo-600 focus:text-white focus:px-3 focus:py-2 focus:rounded">本文へスキップ</a>
<!-- Header -->
<header class="sticky top-0 z-40 backdrop-blur border-b border-slate-200/60 dark:border-slate-700/60 bg-white/70 dark:bg-slate-900/70">
<div class="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8 py-3 flex items-center gap-3">
<button id="btnHome" class="shrink-0 px-2 py-1 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/40" title="ホーム">
<i class="fa-solid fa-book-open text-indigo-600"></i>
</button>
<h1 class="text-lg sm:text-2xl font-bold tracking-tight">ミニ百科 <span class="text-indigo-600">Mini Encyclopedia</span></h1>
<div class="ms-auto flex items-center gap-2">
<button id="btnRandom" class="px-3 py-2 rounded-xl bg-indigo-600 text-white text-sm hover:opacity-90"><i class="fa-solid fa-shuffle me-1"></i>ランダム</button>
<button id="btnBookmarks" class="px-3 py-2 rounded-xl bg-amber-500 text-white text-sm hover:opacity-90"><i class="fa-solid fa-star me-1"></i>ブックマーク</button>
<button id="btnDark" class="px-3 py-2 rounded-xl bg-slate-800 text-white text-sm dark:bg-slate-700 hover:opacity-90" title="ダーク/ライト切替"><i class="fa-solid fa-moon"></i></button>
</div>
</div>
</header>
<!-- Toolbar -->
<section class="border-b border-slate-200/60 dark:border-slate-700/60 bg-white/60 dark:bg-slate-900/60">
<div class="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8 py-4 grid gap-3 sm:grid-cols-12 items-end">
<div class="sm:col-span-6">
<label for="search" class="block text-sm text-slate-600 dark:text-slate-300 mb-1">記事検索</label>
<div class="relative">
<input id="search" type="search" placeholder="キーワード(例: 富士山 / 恐竜 / インターネット)" class="w-full rounded-2xl border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800/80 px-4 py-2 pe-10 outline-none focus:ring-2 focus:ring-indigo-500" />
<i class="fa-solid fa-magnifying-glass absolute right-3 top-1/2 -translate-y-1/2 text-slate-400"></i>
</div>
</div>
<div class="sm:col-span-3">
<label for="category" class="block text-sm text-slate-600 dark:text-slate-300 mb-1">カテゴリ</label>
<select id="category" class="w-full rounded-2xl border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800/80 px-4 py-2 outline-none focus:ring-2 focus:ring-indigo-500">
<option value="">すべて</option>
</select>
</div>
<div class="sm:col-span-3">
<label for="sort" class="block text-sm text-slate-600 dark:text-slate-300 mb-1">並び替え</label>
<select id="sort" class="w-full rounded-2xl border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800/80 px-4 py-2 outline-none focus:ring-2 focus:ring-indigo-500">
<option value="recent">更新が新しい順</option>
<option value="title">タイトル順</option>
</select>
</div>
<div class="sm:col-span-12" id="tagBar" aria-label="タグフィルタ" class="flex flex-wrap gap-2"></div>
</div>
</section>
<main id="main" class="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8 py-6">
<!-- Home / List View -->
<section id="view-home" class="grid gap-6">
<div class="flex items-center justify-between">
<h2 class="text-xl sm:text-2xl font-semibold">記事一覧</h2>
<div class="text-sm text-slate-500"><span id="resultCount">0</span> 件</div>
</div>
<div id="cards" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<!-- cards injected -->
</div>
<div class="flex items-center justify-center gap-2 pt-2" id="pager"></div>
</section>
<!-- Article View -->
<section id="view-article" class="hidden lg:grid lg:grid-cols-12 gap-8">
<aside class="lg:col-span-3 order-last lg:order-first">
<div class="sticky top-[6.5rem] border border-slate-200 dark:border-slate-700 rounded-2xl p-4">
<h3 class="font-semibold mb-2">目次</h3>
<nav id="toc" class="toc text-sm space-y-1"></nav>
</div>
</aside>
<article class="lg:col-span-9">
<nav class="text-sm text-slate-500 mb-3" id="breadcrumb"></nav>
<header class="mb-4">
<h1 id="articleTitle" class="text-2xl sm:text-3xl font-bold tracking-tight"></h1>
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm text-slate-500" id="articleMeta"></div>
<div class="mt-3 flex items-center gap-2">
<button id="btnCopyLink" class="px-3 py-2 rounded-xl border border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-800"><i class="fa-solid fa-link me-1"></i>リンクをコピー</button>
<button id="btnToggleBookmark" class="px-3 py-2 rounded-xl border border-amber-400 text-amber-600 hover:bg-amber-50"><i class="fa-regular fa-star me-1"></i>ブックマーク</button>
</div>
</header>
<div id="articleContent" class="prose prose-slate dark:prose-invert max-w-none"></div>
<section class="mt-8">
<h3 class="font-semibold mb-2">関連タグ</h3>
<div id="articleTags" class="flex flex-wrap gap-2"></div>
</section>
</article>
</section>
</main>
<footer class="border-t border-slate-200 dark:border-slate-700 py-8">
<div class="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8 text-sm text-slate-500 flex flex-wrap items-center gap-2">
<span>© <span id="year"></span> ミニ百科</span>
<span class="mx-1">•</span>
<button id="btnExport" class="underline underline-offset-4">データを書き出す(JSON)</button>
<span class="mx-1">•</span>
<label class="cursor-pointer">JSON読み込み <input id="fileImport" type="file" accept="application/json" class="hidden" /></label>
</div>
</footer>
<!-- Structured data placeholder (updated on article view) -->
<script id="ldjson" type="application/ld+json">{}</script>
<script>
// ======================
// サンプル記事データ
// ======================
/**
* 記事スキーマ:
* id, slug, title, category, tags[], summary, updated(ISO), author, content(HTML)
*/
const ARTICLES = [
{
id: 1,
slug: 'fuji-san',
title: '富士山',
category: '地理',
tags: ['日本','山','世界文化遺産'],
summary: '日本の象徴ともいわれる成層火山。標高3,776mで日本最高峰。',
updated: '2025-08-15',
author: 'ミニ百科編集部',
content: `
<p>富士山は本州中部に位置する<span>成層火山</span>で、標高は3,776m。2013年に世界文化遺産に登録されました。古来より信仰の対象であり、芸術や文学にも多く登場します。</p>
<h2 id="geo">地形と地質</h2>
<p>富士山は何度もの噴火活動を経て現在の美しい円錐形を形成しました。火口は山頂部にあり、外輪としてお鉢巡りが知られています。</p>
<h2 id="climb">登山と保全</h2>
<p>一般的な登山シーズンは夏。登山道の混雑やゴミ問題、低温・高山病などのリスク対策が重要です。</p>
<h2 id="culture">文化的意義</h2>
<p>葛飾北斎の『富嶽三十六景』をはじめ、絵画や和歌に頻繁に詠まれ、日本の象徴として国際的にも広く知られています。</p>
`
},
{
id: 2,
slug: 'internet-basics',
title: 'インターネットの基礎',
category: 'テクノロジー',
tags: ['ネットワーク','Web','通信'],
summary: '世界中のコンピュータを相互接続する情報ネットワークの総称。',
updated: '2025-07-01',
author: 'ミニ百科編集部',
content: `
<p>インターネットは標準化された<span>TCP/IP</span>により機器同士が通信する巨大なネットワークです。Web、メール、動画配信など多様なサービスの土台になっています。</p>
<h2 id="protocols">主要プロトコル</h2>
<p>HTTP/HTTPS、DNS、SMTP、FTPなどが代表的。セキュリティ確保には暗号化や認証が重要です。</p>
<h2 id="web">Webの仕組み</h2>
<p>ブラウザがURLを解決し、サーバからHTML/CSS/JS等のリソースを取得・表示します。</p>
<h2 id="safety">安全な利用</h2>
<p>二要素認証、ソフトウェア更新、フィッシング対策、強力なパスワード管理が基本です。</p>
`
},
{
id: 3,
slug: 'dinosaurs',
title: '恐竜',
category: '生物',
tags: ['古生物学','白亜紀','化石'],
summary: '中生代に栄えた爬虫類のグループ。鳥類は恐竜の系統に含まれると考えられている。',
updated: '2025-05-28',
author: 'ミニ百科編集部',
content: `
<p>恐竜は約2億3000万年前に出現し、中生代に多様化しました。<span>鳥類</span>は恐竜の一系統とみなされます。</p>
<h2 id="era">時代区分</h2>
<p>三畳紀・ジュラ紀・白亜紀に区分され、各時代で特徴的な種が繁栄しました。</p>
<h2 id="extinction">大量絶滅</h2>
<p>約6600万年前の大量絶滅で多くが消滅。隕石衝突や火山活動などが要因と考えられています。</p>
`
},
{
id: 4,
slug: 'ww2-overview',
title: '第二次世界大戦(概説)',
category: '歴史',
tags: ['20世紀','戦争','国際関係'],
summary: '1939年から1945年にかけて行われた世界規模の戦争。',
updated: '2025-03-10',
author: 'ミニ百科編集部',
content: `
<p>第二次世界大戦は多数の国が参戦した世界規模の戦争で、政治・経済・科学技術・社会に長期の影響を与えました。</p>
<h2 id="fronts">主要戦線</h2>
<p>ヨーロッパ、太平洋、北アフリカ、東部戦線など多くの戦域に分かれました。</p>
<h2 id="aftermath">戦後の世界</h2>
<p>国際連合の設立、冷戦構造の形成、国際秩序の再編などにつながりました。</p>
`
},
{
id: 5,
slug: 'ai-basics',
title: '人工知能の基礎',
category: 'テクノロジー',
tags: ['AI','機械学習','深層学習'],
summary: '知的な処理をコンピュータで実現する研究分野と技術群。',
updated: '2025-06-12',
author: 'ミニ百科編集部',
content: `
<p>人工知能は探索・推論から機械学習・深層学習まで多様な手法を含みます。現代では大量データと計算資源により実世界応用が拡大。</p>
<h2 id="ml">機械学習</h2>
<p>教師あり・教師なし・強化学習などの枠組みがあり、予測や分類に用いられます。</p>
<h2 id="dl">深層学習</h2>
<p>多層ニューラルネットワークにより画像・音声・自然言語処理で高精度を実現。</p>
`
},
{
id: 6,
slug: 'sakura',
title: 'サクラ(桜)',
category: '文化',
tags: ['日本文化','植物','季節'],
summary: '日本の春を象徴する花。花見は古くからの季節行事。',
updated: '2025-04-02',
author: 'ミニ百科編集部',
content: `
<p>桜はバラ科サクラ属の総称。品種が多く、花期は短いものの観賞価値が高いことで知られます。</p>
<h2 id="hanami">花見の歴史</h2>
<p>貴族文化から庶民に広がり、現在では地域の祭りや観光資源にもなっています。</p>
`
},
{
id: 7,
slug: 'japan-history-outline',
title: '日本史(概説)',
category: '歴史',
tags: ['古代','中世','近代'],
summary: '古代から現代までの日本の歴史を大まかに概観する。',
updated: '2025-01-20',
author: 'ミニ百科編集部',
content: `
<p>日本史は縄文・弥生・古墳などの古代から、中世・近世、明治以降の近代・現代に至るまで連続する多様な変化の歴史です。</p>
<h2 id="ancient">古代</h2>
<p>稲作の普及、古代国家の形成、律令制の確立など。</p>
<h2 id="modern">近代・現代</h2>
<p>近代化、戦後復興、高度経済成長、少子高齢化と新たな課題。</p>
`
},
{
id: 8,
slug: 'programming-intro',
title: 'プログラミング入門',
category: 'テクノロジー',
tags: ['コード','アルゴリズム','学習'],
summary: 'コンピュータに手順を伝えるための技術と考え方の総称。',
updated: '2025-07-22',
author: 'ミニ百科編集部',
content: `
<p>プログラミングは問題を分解し、再利用可能な手順として表現する作業です。変数、条件分岐、反復、関数などの基本を学ぶと応用が広がります。</p>
<h2 id="lang">主な言語</h2>
<p>Python、JavaScript、C#、C++ など用途に応じて選択されます。</p>
`
},
{
id: 9,
slug: 'tea-ceremony',
title: '茶道',
category: '文化',
tags: ['日本文化','礼法','芸道'],
summary: '湯を沸かし茶を点て、客をもてなす総合芸術。',
updated: '2025-05-03',
author: 'ミニ百科編集部',
content: `
<p>茶道は道具、作法、空間、季節感などが一体となる総合芸術です。<span>和敬清寂</span>の精神が重視されます。</p>
<h2 id="tools">道具</h2>
<p>茶碗、茶筅、茶杓、釜、柄杓など。取り扱いには所作と配慮が求められます。</p>
`
},
{
id: 10,
slug: 'solar-system',
title: '太陽系',
category: '天文学',
tags: ['惑星','衛星','宇宙'],
summary: '太陽とその周囲を公転する天体の集まり。',
updated: '2025-06-30',
author: 'ミニ百科編集部',
content: `
<p>太陽系は太陽を中心に、8つの惑星、準惑星、小惑星、彗星、塵やガスが重力で結びつくシステムです。</p>
<h2 id="planets">惑星</h2>
<p>水星・金星・地球・火星・木星・土星・天王星・海王星。各惑星は固有の特徴を持ちます。</p>
`
}
];
// ================
// ユーティリティ
// ================
const $ = (sel, root=document) => root.querySelector(sel);
const $$ = (sel, root=document) => Array.from(root.querySelectorAll(sel));
const fmtDate = iso => new Date(iso).toLocaleDateString('ja-JP', {year:'numeric', month:'short', day:'numeric'});
const unique = arr => [...new Set(arr)];
const slugToArticle = slug => ARTICLES.find(a=>a.slug===slug);
const STORAGE = {
bookmarks: 'mini_ency_bookmarks',
history: 'mini_ency_history'
};
function getBookmarks(){
try{return JSON.parse(localStorage.getItem(STORAGE.bookmarks)||'[]');}catch{ return []; }
}
function setBookmarks(list){ localStorage.setItem(STORAGE.bookmarks, JSON.stringify(unique(list))); }
function isBookmarked(slug){ return getBookmarks().includes(slug); }
function toggleBookmark(slug){
const list=getBookmarks();
if(list.includes(slug)) setBookmarks(list.filter(s=>s!==slug));
else setBookmarks([...list, slug]);
}
function pushHistory(slug){
try{
const now = Date.now();
const hist = JSON.parse(localStorage.getItem(STORAGE.history)||'[]');
const filtered = hist.filter(h=>h.slug!==slug);
filtered.unshift({slug, t: now});
localStorage.setItem(STORAGE.history, JSON.stringify(filtered.slice(0,50)));
}catch{}
}
// ================
// 検索・フィルタ
// ================
let state = {
q: '',
category: '',
tag: '',
sort: 'recent',
page: 1,
perPage: 9
};
function normalize(str){ return (str||'').toString().toLowerCase(); }
function filterArticles(){
let list = ARTICLES.slice();
if(state.q){
const q = normalize(state.q);
list = list.filter(a => normalize(a.title+" "+a.summary+" "+a.tags.join(' ')+" "+a.content.replace(/<[^>]+>/g,'')).includes(q));
}
if(state.category){ list = list.filter(a => a.category===state.category); }
if(state.tag){ list = list.filter(a => a.tags.includes(state.tag)); }
if(state.sort==='recent'){ list.sort((a,b)=> new Date(b.updated)-new Date(a.updated)); }
if(state.sort==='title'){ list.sort((a,b)=> a.title.localeCompare(b.title,'ja')); }
return list;
}
// =============
// 一覧描画
// =============
function renderCategories(){
const select = $('#category');
const cats = unique(ARTICLES.map(a=>a.category)).sort((a,b)=>a.localeCompare(b,'ja'));
select.innerHTML = '<option value="">すべて</option>' + cats.map(c=>`<option value="${c}">${c}</option>`).join('');
}
function renderTagsBar(){
const bar = $('#tagBar');
const tags = unique(ARTICLES.flatMap(a=>a.tags)).sort((a,b)=>a.localeCompare(b,'ja'));
bar.innerHTML = '<div class="flex flex-wrap gap-2">' + tags.map(t=>
`<button data-tag="${t}" class="tag-btn px-3 py-1 rounded-full border border-slate-300 dark:border-slate-700 text-sm hover:bg-slate-100 dark:hover:bg-slate-800 ${state.tag===t?'bg-indigo-600 text-white border-indigo-600':''}">#${t}</button>`
).join('') + '</div>';
$$('.tag-btn').forEach(b=> b.addEventListener('click',()=>{ state.tag = (state.tag===b.dataset.tag? '' : b.dataset.tag); state.page=1; syncList(); }));
}
function createCard(a){
const bookmarked = isBookmarked(a.slug);
return `
<article class="border border-slate-200 dark:border-slate-700 rounded-2xl p-4 bg-white/70 dark:bg-slate-800/70 hover:shadow transition">
<header class="flex items-start justify-between gap-3">
<h3 class="text-lg font-semibold leading-tight">${a.title}</h3>
<button class="bookmark inline-flex items-center justify-center w-9 h-9 rounded-full ${bookmarked?'text-amber-500':'text-slate-400'}" title="ブックマーク" data-slug="${a.slug}">
<i class="${bookmarked?'fa-solid':'fa-regular'} fa-star"></i>
</button>
</header>
<div class="mt-1 text-sm text-slate-500">${a.category}・更新 ${fmtDate(a.updated)}</div>
<p class="mt-2 text-sm line-clamp-3">${a.summary}</p>
<div class="mt-3 flex flex-wrap gap-2 text-xs">${a.tags.map(t=>`<span class='px-2 py-1 rounded-full bg-slate-100 dark:bg-slate-700'>#${t}</span>`).join('')}</div>
<div class="mt-4 flex gap-2">
<a href="#/a/${a.slug}" class="inline-flex items-center gap-2 px-3 py-2 rounded-xl bg-indigo-600 text-white text-sm hover:opacity-90"><i class="fa-solid fa-circle-info"></i> 詳細</a>
<button class="copy-link inline-flex items-center gap-2 px-3 py-2 rounded-xl border border-slate-300 dark:border-slate-700 text-sm hover:bg-slate-100 dark:hover:bg-slate-800" data-link="${location.origin+location.pathname}#/a/${a.slug}"><i class="fa-solid fa-link"></i>リンク</button>
</div>
</article>
`;
}
function renderPager(total){
const pager = $('#pager');
const pages = Math.max(1, Math.ceil(total/state.perPage));
state.page = Math.min(state.page, pages);
let html='';
for(let i=1;i<=pages;i++){
html += `<button class="px-3 py-1 rounded-lg border ${i===state.page?'bg-indigo-600 text-white border-indigo-600':'border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-800'}" data-page="${i}">${i}</button>`;
}
pager.innerHTML = html;
$$('#pager button').forEach(b=> b.addEventListener('click',()=>{ state.page=Number(b.dataset.page); syncList(false); }));
}
function syncList(scrollTop=true){
const list = filterArticles();
$('#resultCount').textContent = list.length;
renderPager(list.length);
const start=(state.page-1)*state.perPage;
const pageItems=list.slice(start, start+state.perPage);
$('#cards').innerHTML = pageItems.map(createCard).join('');
// events
$$('.bookmark').forEach(b=> b.addEventListener('click',()=>{ toggleBookmark(b.dataset.slug); syncList(false); }));
$$('.copy-link').forEach(b=> b.addEventListener('click',()=> copyText(b.dataset.link)));
if(scrollTop) window.scrollTo({top:0, behavior:'smooth'});
}
// =================
// 記事ページ描画
// =================
function renderArticle(slug){
const a = slugToArticle(slug);
if(!a){ location.hash = ''; return; }
// breadcrumb
$('#breadcrumb').innerHTML = `<a class="underline" href="#">ホーム</a> / <span class="text-slate-600">${a.category}</span>`;
// title & meta
$('#articleTitle').textContent = a.title;
$('#articleMeta').innerHTML = `
<span><i class="fa-regular fa-calendar"></i> 更新 ${fmtDate(a.updated)}</span>
<span class="mx-1">•</span>
<span><i class="fa-regular fa-user"></i> ${a.author}</span>
<span class="mx-1">•</span>
<span><i class="fa-solid fa-folder"></i> ${a.category}</span>
`;
// content
const container = $('#articleContent');
container.innerHTML = a.content;
// tags
$('#articleTags').innerHTML = a.tags.map(t=>`<a href="#" data-tag="${t}" class="px-3 py-1 rounded-full border border-slate-300 dark:border-slate-700 text-sm hover:bg-slate-100 dark:hover:bg-slate-800">#${t}</a>`).join('');
$$('#articleTags a').forEach(el=> el.addEventListener('click',(e)=>{ e.preventDefault(); state.tag=el.dataset.tag; location.hash=''; }));
// bookmark button
const btnBM = $('#btnToggleBookmark');
const setBM = ()=>{
const marked = isBookmarked(a.slug);
btnBM.innerHTML = `<i class="${marked?'fa-solid':'fa-regular'} fa-star me-1"></i>${marked?'保存済み':'ブックマーク'}`;
};
btnBM.onclick = ()=>{ toggleBookmark(a.slug); setBM(); };
setBM();
// copy link
$('#btnCopyLink').onclick = ()=> copyText(location.href);
// TOC
buildTOC();
// JSON-LD
updateLDJSON(a);
// history
pushHistory(a.slug);
}
function buildTOC(){
const toc = $('#toc');
const headings = $$('#articleContent h2, #articleContent h3');
if(headings.length===0){ toc.innerHTML = '<div class="text-slate-500 text-sm">見出しがありません</div>'; return; }
let html='';
headings.forEach(h=>{
if(!h.id) h.id = h.textContent.trim().toLowerCase().replace(/[^a-z0-9一-龥ぁ-んァ-ヶー]+/g,'-');
const indent = h.tagName==='H3' ? 'ms-4' : '';
html += `<a href="#${h.id}" class="${indent} hover:text-indigo-600">${h.textContent}</a>`;
});
toc.innerHTML = html;
const observer = new IntersectionObserver((entries)=>{
entries.forEach(e=>{
if(e.isIntersecting){
$$('#toc a').forEach(a=>a.classList.remove('active'));
const a = $(`#toc a[href="#${e.target.id}"]`);
if(a) a.classList.add('active');
}
});
}, {rootMargin: '0px 0px -70% 0px'});
headings.forEach(h=> observer.observe(h));
}
function updateLDJSON(a){
const obj = {
'@context':'https://schema.org',
'@type':'Article',
headline: a.title,
dateModified: a.updated,
author: { '@type':'Organization', name: a.author },
keywords: a.tags.join(','),
articleSection: a.category,
url: location.href
};
$('#ldjson').textContent = JSON.stringify(obj);
}
// ============
// ルーター
// ============
function route(){
const hash = location.hash.slice(1);
if(hash.startsWith('/a/')){
const slug = hash.split('/')[2];
$('#view-home').classList.add('hidden');
$('#view-article').classList.remove('hidden');
renderArticle(slug);
window.scrollTo({top:0, behavior:'instant'});
}else{
$('#view-article').classList.add('hidden');
$('#view-home').classList.remove('hidden');
syncList();
}
}
window.addEventListener('hashchange', route);
// ============
// 便利機能
// ============
function copyText(text){
navigator.clipboard.writeText(text).then(()=>{
toast('リンクをコピーしました');
}, ()=>{
prompt('コピーできない場合は手動で選択してコピーしてください:', text);
});
}
function toast(msg){
const t = document.createElement('div');
t.textContent = msg;
t.className = 'fixed left-1/2 -translate-x-1/2 bottom-6 z-50 bg-black/80 text-white px-4 py-2 rounded-xl text-sm';
document.body.appendChild(t);
setTimeout(()=>{ t.remove(); }, 1600);
}
function randomArticle(){
const a = ARTICLES[Math.floor(Math.random()*ARTICLES.length)];
location.hash = `#/a/${a.slug}`;
}
// ==============
// I/O (JSON)
// ==============
function exportJSON(){
const blob = new Blob([JSON.stringify(ARTICLES, null, 2)], {type:'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'mini-encyclopedia.json'; a.click();
URL.revokeObjectURL(url);
}
function importJSON(file){
const reader = new FileReader();
reader.onload = (e)=>{
try{
const data = JSON.parse(e.target.result);
if(Array.isArray(data)){
// 形式が正しければ差し替え
if(data.every(x=>x.slug && x.title && x.content)){
ARTICLES.length = 0; // 破壊的更新
data.forEach(x=> ARTICLES.push(x));
init();
toast('JSONを読み込みました');
}else{
alert('スキーマが不正です。slug/title/content は必須です。');
}
}else{
alert('配列形式のJSONが必要です');
}
}catch(err){
alert('JSONの解析に失敗しました: '+err.message);
}
};
reader.readAsText(file);
}
// ============
// 初期化
// ============
function init(){
// 年
$('#year').textContent = new Date().getFullYear();
// カテゴリ・タグバー
renderCategories();
renderTagsBar();
// イベント
$('#search').addEventListener('input', (e)=>{ state.q = e.target.value.trim(); state.page=1; syncList(); });
$('#category').addEventListener('change', (e)=>{ state.category = e.target.value; state.page=1; syncList(); });
$('#sort').addEventListener('change', (e)=>{ state.sort = e.target.value; state.page=1; syncList(); });
$('#btnRandom').addEventListener('click', randomArticle);
$('#btnBookmarks').addEventListener('click', ()=>{
const bms = getBookmarks();
if(bms.length===0){ toast('ブックマークはまだありません'); return; }
const first = slugToArticle(bms[0]);
if(first) location.hash = `#/a/${first.slug}`;
});
$('#btnHome').addEventListener('click', ()=>{ location.hash=''; });
// ダークモード切替
$('#btnDark').addEventListener('click', ()=>{
const root = document.documentElement;
const isDark = root.classList.toggle('dark');
localStorage.setItem('theme', isDark? 'dark':'light');
});
// JSON I/O
$('#btnExport').addEventListener('click', exportJSON);
$('#fileImport').addEventListener('change', (e)=>{ const f=e.target.files?.[0]; if(f) importJSON(f); e.target.value=''; });
// 初回描画
route();
}
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>
XLogpro.html(ツイートまとめサイト)
<!DOCTYPE html>
<html lang="ja" data-theme="light" style="--cols:3; --card-h:640px; --accent:#2563eb">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Xlog Pro — HTMLだけで動く自動ツイートまとめ</title>
<link rel="preconnect" href="https://platform.twitter.com" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"/>
<meta name="description" content="HTMLだけ/APIキー不要のX(Twitter)まとめボード。プロフィール・ハッシュタグ・検索・リストを好きな列で配置し、JSON/HTML書き出しや手動ランキング、ボード切替に対応。">
<style>
:root{
--bg: #0b0e14; --panel:#111827; --muted:#9aa4b2; --text:#e5e7eb; --border:#1f2937; --chip:#141a23; --card:#0f172a; --btn:#1f2937; --btn-text:#e5e7eb; --link:#60a5fa;
}
[data-theme="light"]{ --bg:#f8fafc; --panel:#ffffff; --muted:#64748b; --text:#0f172a; --border:#e2e8f0; --chip:#f1f5f9; --card:#ffffff; --btn:#0f172a; --btn-text:#ffffff; --link:#1d9bf0; }
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,"Apple Color Emoji","Segoe UI Emoji"}
header{position:sticky;top:0;z-index:10;background:var(--panel);border-bottom:1px solid var(--border)}
.wrap{max-width:1280px;margin:0 auto;padding:12px 16px}
.row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
.brand{display:flex;gap:10px;align-items:center;font-weight:800}
.brand i{color:var(--accent)}
.muted{color:var(--muted)}
.pill{display:inline-flex;gap:8px;align-items:center;background:var(--chip);border:1px solid var(--border);border-radius:999px;padding:6px 10px}
.input, select, textarea{background:transparent;border:1px solid var(--border);border-radius:10px;padding:8px 10px;color:var(--text)}
textarea{min-height:88px;width:100%;}
input[type="text"].input{min-width:220px}
button{cursor:pointer;border:none}
.btn{background:var(--btn);color:var(--btn-text);padding:9px 12px;border-radius:12px}
.btn.secondary{background:transparent;color:var(--text);border:1px solid var(--border)}
.btn.ghost{background:transparent;color:var(--text)}
.btn.badge{padding:6px 10px;border-radius:999px}
.grid{display:grid;grid-template-columns:320px 1fr;gap:16px}
@media (max-width:1080px){.grid{grid-template-columns:1fr}}
aside{background:var(--panel);border:1px solid var(--border);border-radius:16px;padding:14px;position:sticky;top:72px;height:max-content}
h2{margin:6px 0 12px 0;font-size:18px}
.list{display:flex;flex-direction:column;gap:12px}
.card{background:var(--card);border:1px solid var(--border);border-radius:16px;overflow:hidden}
.card .head{display:flex;justify-content:space-between;align-items:center;padding:12px 14px;border-bottom:1px solid var(--border)}
.card .head .title{display:flex;gap:8px;align-items:center;font-weight:700}
.card .body{padding:0;min-height:var(--card-h)}
.sources{display:flex;flex-wrap:wrap;gap:8px}
.chip{background:var(--chip);border:1px solid var(--border);border-radius:999px;padding:6px 10px;display:flex;gap:8px;align-items:center}
.chip b{color:var(--accent)}
.columns{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:16px}
@media (max-width:1200px){:root{--cols:2}}
@media (max-width:860px){:root{--cols:1}}
.drag{cursor:grab}
.toolbar{display:flex;gap:8px;flex-wrap:wrap}
.footer{padding:24px 16px;color:var(--muted);text-align:center}
.kbd{font-family:ui-monospace, Menlo, Monaco, Consolas; background:var(--chip); border:1px solid var(--border); padding:2px 6px; border-radius:6px}
.danger{color:#ef4444}
.accent{color:var(--accent)}
.section{background:var(--panel);border:1px solid var(--border);border-radius:16px;padding:14px}
.help{font-size:13px;color:var(--muted)}
.label{font-size:12px;color:var(--muted)}
.tiny{font-size:12px}
.row-wrap{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.w-100{width:100%}
.space{height:8px}
</style>
</head>
<body>
<header>
<div class="wrap row">
<div class="brand"><i class="fa-solid fa-wave-square"></i> Xlog <span class="muted">Pro</span></div>
<div class="pill">
<i class="fa-solid fa-diagram-project"></i>
<select id="boardSelect" title="ボード切替"></select>
<button id="boardNew" class="btn badge secondary" title="新規ボード"><i class="fa-solid fa-plus"></i></button>
<button id="boardRename" class="btn badge secondary" title="名前変更"><i class="fa-solid fa-pen"></i></button>
<button id="boardDelete" class="btn badge secondary danger" title="削除"><i class="fa-regular fa-trash-can"></i></button>
</div>
<div class="pill" title="テーマ切替"><i class="fa-solid fa-circle-half-stroke"></i>
<label class="row-wrap"><input id="themeToggle" type="checkbox" /> ダーク</label>
</div>
<div class="pill" title="アクセントカラー">
<i class="fa-solid fa-palette"></i>
<input id="accentPicker" type="color" value="#2563eb" />
</div>
<div class="pill" title="列数"><i class="fa-solid fa-table-columns"></i>
<input id="colsRange" type="range" min="1" max="4" step="1" value="3"/>
<span id="colsVal" class="tiny"></span>
</div>
<div class="pill" title="カード高さ"><i class="fa-solid fa-up-down"></i>
<input id="heightRange" type="range" min="360" max="1200" step="40" value="640"/>
<span id="heightVal" class="tiny"></span>
</div>
<div class="pill" title="自動再読み込み">
<label class="row-wrap"><input id="autoRefreshToggle" type="checkbox"/> 自動</label>
<select id="refreshMinutes">
<option value="3">3分</option>
<option value="5" selected>5分</option>
<option value="10">10分</option>
<option value="30">30分</option>
</select>
</div>
<div class="pill help tiny">ショートカット: <span class="kbd">N</span> 追加 / <span class="kbd">R</span> 再描画 / <span class="kbd">G</span> グリッド- / <span class="kbd">H</span> 高さ-</div>
</div>
</header>
<main class="wrap grid">
<aside>
<div class="list">
<div class="section">
<h2>ソースを追加</h2>
<div class="toolbar row-wrap">
<select id="sourceType">
<option value="profile">プロフィール</option>
<option value="hashtag">ハッシュタグ</option>
<option value="search">検索キーワード</option>
<option value="list">リストURL</option>
</select>
<input id="sourceValue" class="input" type="text" placeholder="@username / #tag / キーワード / リストURL" />
<input id="sourceLabel" class="input" type="text" placeholder="表示名(任意)" />
<button id="addBtn" class="btn"><i class="fa-solid fa-plus"></i> 追加</button>
</div>
<div class="space"></div>
<label class="label">まとめて追加(改行/カンマ区切りOK)</label>
<textarea id="bulkArea" placeholder="@OpenAI, #UnrealEngine, Unity URP, https://twitter.com/i/lists/123...\n@EpicGames"></textarea>
<div class="row-wrap">
<button id="bulkAdd" class="btn secondary"><i class="fa-solid fa-download"></i> 取り込み</button>
<button id="bulkClear" class="btn ghost"><i class="fa-solid fa-eraser"></i> クリア</button>
</div>
<p class="help" style="margin-top:8px">形式は自動判定:<span class="kbd">@id</span> → プロフィール、<span class="kbd">#tag</span> → ハッシュタグ、<span class="kbd">twitter.com/i/lists</span> → リスト、それ以外は検索。</p>
<div class="space"></div>
<div class="row-wrap help tiny">クイック追加:</div>
<div class="row-wrap">
<button class="btn badge secondary quick" data-type="hashtag" data-val="#UnrealEngine">#UnrealEngine</button>
<button class="btn badge secondary quick" data-type="hashtag" data-val="#Unity3D">#Unity3D</button>
<button class="btn badge secondary quick" data-type="search" data-val="VRM OR \"Meta Quest\"">VR/Quest</button>
<button class="btn badge secondary quick" data-type="profile" data-val="@OpenAI">@OpenAI</button>
</div>
</div>
<div class="section">
<h2>保存・書き出し</h2>
<div class="toolbar row-wrap">
<button id="exportBtn" class="btn secondary"><i class="fa-solid fa-file-export"></i> JSON</button>
<label class="btn secondary" for="importFile"><i class="fa-solid fa-file-import"></i> JSON読込</label>
<input id="importFile" type="file" accept="application/json" hidden />
<button id="exportHtmlBtn" class="btn"><i class="fa-regular fa-file-code"></i> 単一HTML</button>
<button id="clearBtn" class="btn ghost danger"><i class="fa-regular fa-trash-can"></i> すべて削除</button>
</div>
<p class="help">単一HTML: いまのレイアウトと設定を埋め込んだ自立HTMLを生成します。</p>
</div>
<div class="section">
<h2>手動ランキング</h2>
<div class="toolbar row-wrap">
<input id="tweetUrl" class="input" type="text" placeholder="ツイートURLを貼り付け" />
<button id="addTweetBtn" class="btn"><i class="fa-brands fa-x-twitter"></i> 追加</button>
</div>
<label class="label">メモ(任意・次回以降も保持)</label>
<textarea id="tweetNote" placeholder="このツイートの要点やタグ(例: #UE5 #VRM)"></textarea>
<p class="help">※HTMLのみの制約で自動集計は不可。URLをカード化して手動で順序を決められます。</p>
</div>
<div class="section">
<h2>RSS生成(ランキング→RSS)</h2>
<div class="toolbar row-wrap">
<input id="rssTitle" class="input" type="text" placeholder="RSSタイトル(例: Xlogランキング)"/>
<button id="rssExport" class="btn secondary"><i class="fa-solid fa-rss"></i> RSSを書き出し</button>
</div>
<p class="help">ランキングに登録したツイートURLから簡易RSS(XML)を生成し、ファイルとして保存します。</p>
</div>
<div class="section">
<h2>ヘルプ</h2>
<div class="help">
・列の並べ替えはカードの <span class="kbd">⋯</span> アイコンをドラッグ。<br>
・<span class="kbd">R</span> で全カラムを再描画。<br>
・URLハッシュ <span class="kbd">#data=</span> に設定をBase64で埋め込んで共有可能(メニューから自動生成予定)。
</div>
</div>
</div>
</aside>
<section>
<div class="card" style="margin-bottom:16px">
<div class="head">
<div class="title"><i class="fa-solid fa-layer-group drag"></i> マイまとめ <span class="muted tiny" id="boardInfo"></span></div>
<div class="sources" id="activeChips"></div>
</div>
<div class="body" style="padding:14px">
<div id="columns" class="columns"></div>
</div>
</div>
<div class="card">
<div class="head">
<div class="title"><i class="fa-regular fa-star"></i> 手動ランキング</div>
<div class="help">ドラッグで順序変更/🗑で削除/✎でメモ編集</div>
</div>
<div class="body" style="padding:14px">
<div id="ranking" class="columns"></div>
</div>
</div>
<div class="footer">Xlog Pro v2 — HTML Only / Embedded Timelines. No API keys. <span class="muted">Made for you.</span></div>
</section>
</main>
<script async src="https://platform.twitter.com/widgets.js"></script>
<script>
// ========== 基本ユーティリティ ==========
const $ = (s, d=document)=>d.querySelector(s);
const $$ = (s, d=document)=>Array.from(d.querySelectorAll(s));
const defaultBoard = ()=>({sources:[], tweets:[]});
const defaultState = ()=>({
version:2,
dark:false,
accent:'#2563eb',
autoRefresh:false,
minutes:5,
columns:3,
cardHeight:640,
boards:{'Default': defaultBoard()},
activeBoard:'Default'
});
const store = {
key: 'xlog-pro-v2',
load(){
try{ return JSON.parse(localStorage.getItem(this.key)) || defaultState(); }
catch(e){ return defaultState(); }
},
save(v){ localStorage.setItem(this.key, JSON.stringify(v)); }
};
function migrate(s){
const base = defaultState();
if (!s || typeof s !== 'object') return base;
// v1互換(sources/tweets直下 → boards.Default)
if (s.sources || s.tweets){
base.boards.Default.sources = s.sources||[];
base.boards.Default.tweets = s.tweets||[];
}
// 既存キー上書き
for (const k of ['dark','accent','autoRefresh','minutes','columns','cardHeight','boards','activeBoard']){
if (k in s) base[k]=s[k];
}
return base;
}
// ハッシュ (#data=BASE64) から読み込み
function loadFromHash(){
const h = location.hash || '';
if (!h.startsWith('#data=')) return null;
try{
const b64 = decodeURIComponent(h.slice(6));
const json = atob(b64);
return JSON.parse(json);
}catch(e){ return null; }
}
let embedded = (typeof window.__XLOG_INITIAL_STATE__!== 'undefined') ? window.__XLOG_INITIAL_STATE__ : null;
if (!embedded){
const el = document.getElementById('xlog-init');
if (el) { try{ embedded = JSON.parse(el.textContent); }catch(_e){} }
}
let state = migrate( embedded || loadFromHash() || store.load() );
// ========== テーマ/アクセント/レイアウト適用 ==========
function applySkin(){
document.documentElement.setAttribute('data-theme', state.dark ? 'dark' : 'light');
document.documentElement.style.setProperty('--cols', state.columns);
document.documentElement.style.setProperty('--card-h', state.cardHeight+'px');
document.documentElement.style.setProperty('--accent', state.accent || '#2563eb');
$('#themeToggle').checked = !!state.dark;
$('#colsRange').value = String(state.columns);
$('#colsVal').textContent = state.columns+'列';
$('#heightRange').value = String(state.cardHeight);
$('#heightVal').textContent = state.cardHeight+'px';
$('#accentPicker').value = state.accent || '#2563eb';
}
// ========== X埋め込み ==========
function waitTwttr(){
return new Promise(res=>{
if (window.twttr && twttr.widgets) return res();
const timer = setInterval(()=>{ if(window.twttr && twttr.widgets){ clearInterval(timer); res(); } }, 200);
});
}
function timelineOptions(){
return {
height: state.cardHeight,
theme: state.dark ? 'dark' : 'light',
chrome: 'nofooter noborders transparent',
linkColor: getComputedStyle(document.documentElement).getPropertyValue('--link').trim() || '#1d9bf0'
};
}
async function createTimeline(el, src){
await waitTwttr();
const opts = timelineOptions();
const t = (src.type||'profile');
if (t==='profile'){
const screenName = src.value.replace(/^@/,'');
return twttr.widgets.createTimeline({ sourceType:'profile', screenName }, el, opts);
}
if (t==='list'){
return twttr.widgets.createTimeline({ sourceType:'url', url: src.value }, el, opts);
}
if (t==='hashtag'){
const tag = src.value.replace(/^#/,'');
const url = `https://twitter.com/hashtag/${encodeURIComponent(tag)}?f=live`;
return twttr.widgets.createTimeline({ sourceType:'url', url }, el, opts);
}
if (t==='search'){
const url = `https://twitter.com/search?q=${encodeURIComponent(src.value)}&f=live`;
return twttr.widgets.createTimeline({ sourceType:'url', url }, el, opts);
}
}
// ========== 現在ボードの参照 ==========
function board(){ return state.boards[state.activeBoard] || (state.boards[state.activeBoard]=defaultBoard()); }
// ========== 描画 ==========
function chipNode(src, idx){
const chip = document.createElement('span');
chip.className='chip';
const kind = {profile:'@',hashtag:'#',search:'検索:',list:'リスト'}[src.type] || '';
chip.innerHTML = `<b>${kind}</b> ${src.label || src.value} <a class="muted" href="${openUrl(src)}" target="_blank" title="Xで開く"><i class="fa-solid fa-arrow-up-right-from-square"></i></a> <button title="削除" data-del="${idx}" class="muted"><i class="fa-solid fa-xmark"></i></button>`;
chip.querySelector('button').onclick = ()=>{ board().sources.splice(idx,1); store.save(state); renderAll(); };
return chip;
}
function openUrl(src){
if (src.type==='profile') return `https://twitter.com/${src.value.replace(/^@/,'')}`;
if (src.type==='hashtag') return `https://twitter.com/hashtag/${src.value.replace(/^#/,'')}`;
if (src.type==='list') return src.value;
return `https://twitter.com/search?q=${encodeURIComponent(src.value)}&f=live`;
}
function columnCard(src, idx){
const card = document.createElement('div');
card.className='card';
card.draggable=true; card.dataset.idx=idx;
card.innerHTML = `
<div class="head">
<div class="title"><i class="fa-solid fa-grip-vertical drag"></i> ${src.label || prettyLabel(src)}</div>
<div class="toolbar">
<a class="btn ghost" href="${openUrl(src)}" target="_blank" title="Xで開く"><i class="fa-solid fa-arrow-up-right-from-square"></i></a>
<button class="btn ghost" title="再読み込み" data-refresh="${idx}"><i class="fa-solid fa-rotate"></i></button>
<button class="btn ghost danger" title="削除" data-remove="${idx}"><i class="fa-regular fa-trash-can"></i></button>
</div>
</div>
<div class="body"><div class="embed" style="min-height:120px"></div></div>`;
// DnD 並べ替え
card.addEventListener('dragstart', e=>{ e.dataTransfer.setData('text/plain', idx); card.style.opacity='0.6'; });
card.addEventListener('dragend', ()=>{ card.style.opacity='1'; });
card.addEventListener('dragover', e=>{ e.preventDefault(); card.style.outline='2px dashed var(--accent)'; });
card.addEventListener('dragleave', ()=>{ card.style.outline='none'; });
card.addEventListener('drop', e=>{
e.preventDefault(); card.style.outline='none';
const from = +e.dataTransfer.getData('text/plain');
const to = +card.dataset.idx;
if (from===to) return;
const arr = board().sources;
const [moved] = arr.splice(from,1);
arr.splice(to,0,moved);
store.save(state); renderAll();
});
// 操作
card.querySelector('[data-remove]')?.addEventListener('click', ()=>{ board().sources.splice(idx,1); store.save(state); renderAll(); });
card.querySelector('[data-refresh]')?.addEventListener('click', ()=>{ mountTimeline(card, src); });
// 初回描画
mountTimeline(card, src);
return card;
}
function mountTimeline(card, src){
const holder = card.querySelector('.embed');
holder.innerHTML = '<div style="padding:14px" class="muted">読み込み中…</div>';
createTimeline(holder, src).catch(()=>{
holder.innerHTML = '<div style="padding:14px" class="danger">読み込みに失敗しました。値を確認してください。</div>';
});
}
function prettyLabel(src){
if (src.type==='profile') return '@'+src.value.replace(/^@/,'');
if (src.type==='hashtag') return '#'+src.value.replace(/^#/,'');
if (src.type==='search') return '検索: '+src.value;
if (src.type==='list') return 'リスト';
return src.value;
}
function renderAll(){
// ボード情報
const info = $('#boardInfo');
const b = board();
info.textContent = `(${state.activeBoard}|${b.sources.length}列 / ${b.tweets.length}件)`;
// チップ
const chips = $('#activeChips'); chips.innerHTML='';
b.sources.forEach((s,i)=> chips.appendChild(chipNode(s,i)) );
// カラム
const col = $('#columns'); col.innerHTML='';
b.sources.forEach((s,i)=> col.appendChild(columnCard(s,i)) );
// ランキング
renderRanking();
}
// ========== ランキング ==========
function parseTweetId(url){ const m = (url||'').match(/status\/(\d{5,})/); return m? m[1] : null; }
function tweetUrlFromId(id){ return `https://twitter.com/i/web/status/${id}`; }
async function addTweet(url, note){
const id = parseTweetId(url);
if (!id) return alert('ツイートURLが正しくありません');
board().tweets.push({id, note: (note||'')});
store.save(state); renderRanking();
}
async function renderRanking(){
await waitTwttr();
const root = $('#ranking'); root.innerHTML='';
const arr = board().tweets;
arr.forEach((t, idx)=>{
const card = document.createElement('div'); card.className='card'; card.draggable=true; card.dataset.idx=idx;
card.innerHTML = `
<div class="head">
<div class="title"><i class="fa-solid fa-grip-vertical drag"></i> エントリ #${idx+1}</div>
<div class="toolbar">
<button class="btn ghost" data-edit="${idx}" title="メモ編集"><i class="fa-regular fa-pen-to-square"></i></button>
<a class="btn ghost" href="${tweetUrlFromId(t.id)}" target="_blank" title="Xで開く"><i class="fa-solid fa-arrow-up-right-from-square"></i></a>
<button class="btn ghost danger" title="削除" data-del-rank="${idx}"><i class="fa-regular fa-trash-can"></i></button>
</div>
</div>
<div class="body">
<div class="embed"></div>
<div style="padding:10px 14px;border-top:1px solid var(--border)" class="tiny"><span class="muted">メモ:</span> <span class="note">${escapeHtml(t.note||'')}</span></div>
</div>`;
// イベント
card.querySelector('[data-del-rank]')?.addEventListener('click', ()=>{ arr.splice(idx,1); store.save(state); renderRanking(); });
card.querySelector('[data-edit]')?.addEventListener('click', ()=>{
const newNote = prompt('メモを編集', t.note||'');
if (newNote!==null){ t.note = newNote; store.save(state); renderRanking(); }
});
// DnD 並べ替え
card.addEventListener('dragstart', e=>{ e.dataTransfer.setData('text/plain', 'rank:'+idx); card.style.opacity='0.6'; });
card.addEventListener('dragend', ()=>{ card.style.opacity='1'; });
card.addEventListener('dragover', e=>{ e.preventDefault(); card.style.outline='2px dashed var(--accent)'; });
card.addEventListener('dragleave', ()=>{ card.style.outline='none'; });
card.addEventListener('drop', e=>{
e.preventDefault(); card.style.outline='none';
const data = e.dataTransfer.getData('text/plain'); if (!data.startsWith('rank:')) return;
const from = +data.split(':')[1]; const to = +card.dataset.idx;
const [moved] = arr.splice(from,1); arr.splice(to,0,moved);
store.save(state); renderRanking();
});
const holder = card.querySelector('.embed');
twttr.widgets.createTweet(t.id, holder, { theme: state.dark ? 'dark' : 'light' });
root.appendChild(card);
});
}
function escapeHtml(s){ return (s||'').replace(/[&<>"']/g, m=> ({'&':'&','<':'<','>':'>','"':'"','\'':'''}[m])); }
// ========== 自動再描画 ==========
let refreshTimer = null;
function applyAutoRefresh(){
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer=null; }
if (state.autoRefresh){
const ms = Math.max(1, +state.minutes) * 60 * 1000;
refreshTimer = setInterval(()=>{
$$('#columns .card').forEach((card, i)=>{
const src = board().sources[i]; if (src) mountTimeline(card, src);
});
}, ms);
}
}
// ========== ボード管理 ==========
function refreshBoardSelect(){
const sel = $('#boardSelect'); sel.innerHTML='';
Object.keys(state.boards).forEach(name=>{
const opt = document.createElement('option'); opt.value=name; opt.textContent=name; sel.appendChild(opt);
});
sel.value = state.activeBoard;
}
function addBoard(name){
if (!name) return;
if (state.boards[name]) return alert('同名のボードが存在します');
state.boards[name] = defaultBoard(); state.activeBoard = name; store.save(state);
refreshBoardSelect(); renderAll();
}
function renameBoard(newName){
if (!newName) return;
if (state.boards[newName]) return alert('同名のボードが存在します');
const old = state.activeBoard;
state.boards[newName] = state.boards[old];
delete state.boards[old];
state.activeBoard = newName; store.save(state);
refreshBoardSelect(); renderAll();
}
function deleteBoard(){
const names = Object.keys(state.boards);
if (names.length<=1) return alert('最後のボードは削除できません');
if (!confirm(`ボード「${state.activeBoard}」を削除しますか?`)) return;
delete state.boards[state.activeBoard];
state.activeBoard = Object.keys(state.boards)[0];
store.save(state); refreshBoardSelect(); renderAll();
}
// ========== 共有(URLハッシュ生成) ==========
function exportHashUrl(){
const cloned = JSON.parse(JSON.stringify(state));
const json = JSON.stringify(cloned);
const b64 = btoa(json);
const url = location.origin + location.pathname + '#data=' + encodeURIComponent(b64);
return url;
}
// ========== 単一HTML出力 ==========
function download(filename, text){
const blob = new Blob([text], {type:'text/html'});
const a = document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=filename; a.click(); URL.revokeObjectURL(a.href);
}
function exportSingleHtml(){
// 現在のHTMLに初期状態スクリプトを差し込む
let html = document.documentElement.outerHTML;
const idx = html.indexOf('<head>');
const inject = `<head>\n <script>window.__XLOG_INITIAL_STATE__=${JSON.stringify(state)}<\/script>`;
if (idx>=0){ html = html.replace('<head>', inject); }
download('xlog-pro.html', '<!DOCTYPE html>\n' + html);
}
// ========== RSS生成 ==========
function exportRss(){
const title = $('#rssTitle').value.trim() || 'Xlog Ranking';
const items = board().tweets.map(t=>({
title: (t.note||'Tweet '+t.id).replace(/[\r\n]+/g,' ').slice(0,120),
link: tweetUrlFromId(t.id),
guid: t.id,
description: escapeHtml(t.note||''),
pubDate: new Date().toUTCString()
}));
const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0"><channel>\n<title>${escapeXml(title)}</title>\n<link>${escapeXml(location.href)}</link>\n<description>Generated by Xlog Pro</description>\n${items.map(i=>`<item><title>${escapeXml(i.title)}</title><link>${escapeXml(i.link)}</link><guid isPermaLink=\"false\">${escapeXml(i.guid)}</guid><description>${escapeXml(i.description)}</description><pubDate>${i.pubDate}</pubDate></item>`).join('')}\n</channel></rss>`;
const blob = new Blob([xml], {type:'application/rss+xml'});
const a = document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='xlog-ranking.xml'; a.click(); URL.revokeObjectURL(a.href);
}
function escapeXml(s){ return (s||'').replace(/[<>&\"']/g, m=> ({'<':'<','>':'>','&':'&','\"':'"','\'':'''}[m])); }
// ========== 入力ヘルパ ==========
function detectType(v){
if (/^@/.test(v)) return 'profile';
if (/^#/.test(v)) return 'hashtag';
if (/twitter\.com\/i\/lists\//.test(v)) return 'list';
return 'search';
}
function addSource(type, value, label){
const src = {type, value:value.trim(), label:(label||'').trim()};
board().sources.push(src); store.save(state); renderAll();
}
function bulkAddFromText(txt){
const parts = txt.split(/[\n,]+/).map(s=>s.trim()).filter(Boolean);
let count = 0;
for (const p of parts){ addSource(detectType(p), p, ''); count++; }
return count;
}
// ========== キーイベント ==========
function setupShortcuts(){
window.addEventListener('keydown', (e)=>{
if (['INPUT','TEXTAREA','SELECT'].includes(document.activeElement.tagName)) return;
if (e.key==='n' || e.key==='N'){ $('#sourceValue').focus(); }
if (e.key==='r' || e.key==='R'){ renderAll(); }
if (e.key==='g' || e.key==='G'){ state.columns=Math.max(1,state.columns-1); applySkin(); store.save(state); }
if (e.key==='h' || e.key==='H'){ state.cardHeight=Math.max(360,state.cardHeight-40); applySkin(); store.save(state); renderAll(); }
});
}
// ========== 設定と起動 ==========
window.addEventListener('DOMContentLoaded', ()=>{
// スキン
applySkin();
// ボード選択
refreshBoardSelect();
$('#boardSelect').addEventListener('change', (e)=>{ state.activeBoard = e.target.value; store.save(state); renderAll(); });
$('#boardNew').addEventListener('click', ()=>{ const name = prompt('新しいボード名','Board '+(Object.keys(state.boards).length+1)); addBoard(name); });
$('#boardRename').addEventListener('click', ()=>{ const name = prompt('新しい名前', state.activeBoard); if (name) renameBoard(name); });
$('#boardDelete').addEventListener('click', deleteBoard);
// テーマ/アクセント/レイアウト
$('#themeToggle').addEventListener('change', e=>{ state.dark = e.target.checked; store.save(state); applySkin(); renderAll(); });
$('#accentPicker').addEventListener('input', e=>{ state.accent = e.target.value; store.save(state); applySkin(); });
$('#colsRange').addEventListener('input', e=>{ state.columns = +e.target.value; store.save(state); applySkin(); });
$('#heightRange').addEventListener('input', e=>{ state.cardHeight = +e.target.value; store.save(state); applySkin(); renderAll(); });
// ソース追加
$('#addBtn').addEventListener('click', ()=>{
const type = $('#sourceType').value;
const val = $('#sourceValue').value.trim();
const label = $('#sourceLabel').value.trim();
if (!val) return alert('値を入力してください');
addSource(type, val, label);
$('#sourceValue').value=''; $('#sourceLabel').value='';
});
$$('.quick').forEach(btn=> btn.addEventListener('click', ()=> addSource(btn.dataset.type, btn.dataset.val, '')) );
// まとめて追加
$('#bulkAdd').addEventListener('click', ()=>{ const n = bulkAddFromText($('#bulkArea').value); alert(n+'件追加しました'); $('#bulkArea').value=''; });
$('#bulkClear').addEventListener('click', ()=> $('#bulkArea').value='' );
// ランキング
$('#addTweetBtn').addEventListener('click', ()=>{
const url = $('#tweetUrl').value.trim();
const note = $('#tweetNote').value.trim();
if (!url) return;
addTweet(url, note); $('#tweetUrl').value=''; $('#tweetNote').value='';
});
// 設定
$('#autoRefreshToggle').checked = !!state.autoRefresh;
$('#refreshMinutes').value = String(state.minutes||5);
$('#autoRefreshToggle').addEventListener('change', e=>{ state.autoRefresh = e.target.checked; store.save(state); applyAutoRefresh(); });
$('#refreshMinutes').addEventListener('change', e=>{ state.minutes = +e.target.value; store.save(state); applyAutoRefresh(); });
// 書き出し/読み込み
$('#exportBtn').addEventListener('click', ()=>{
const blob = new Blob([JSON.stringify(state,null,2)], {type:'application/json'});
const a = document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='xlog-pro-config.json'; a.click(); URL.revokeObjectURL(a.href);
});
$('#importFile').addEventListener('change', e=>{
const file = e.target.files?.[0]; if (!file) return;
const fr = new FileReader();
fr.onload = () => {
try{ const obj = JSON.parse(fr.result); state = migrate(obj); store.save(state); applySkin(); refreshBoardSelect(); renderAll(); applyAutoRefresh(); }
catch(err){ alert('JSONの読み込みに失敗しました'); }
};
fr.readAsText(file);
});
$('#exportHtmlBtn').addEventListener('click', exportSingleHtml);
$('#clearBtn').addEventListener('click', ()=>{
if (!confirm('現在のボードのソースとランキングを削除しますか?')) return;
const b = board(); b.sources = []; b.tweets=[]; store.save(state); renderAll();
});
// RSS
$('#rssExport').addEventListener('click', exportRss);
// 初期描画
renderAll();
applyAutoRefresh();
setupShortcuts();
});
</script>
</body>
</html>
BranchBoard.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>GBranchBoard</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Bootstrap & FontAwesome -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" rel="stylesheet">
<style>
body {
background: #f6f8fa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.repo-header { background: #fff; padding: 20px; border: 1px solid #ddd; margin-bottom: 10px; }
.nav-tabs .nav-link.active { border-color: #ddd #ddd #fff; background: #fff; }
.file-list li { border-bottom: 1px solid #eee; padding: 8px 0; display: flex; align-items: center; }
.file-list i { margin-right: 10px; }
.readme-box, .issues-box, .commits-box { background: #fff; padding: 20px; border: 1px solid #ddd; margin-top: 10px; }
#editor { height: 400px; width: 100%; border: 1px solid #ccc; }
</style>
</head>
<body>
<!-- ヘッダー -->
<nav class="navbar navbar-dark bg-dark px-3">
<span class="text-white"><i class="fas fa-user-circle"></i> owner</span>
</nav>
<div class="container mt-3">
<!-- リポジトリヘッダー -->
<div class="repo-header">
<h3><i class="fas fa-book"></i> owner / <strong>SampleRepo</strong></h3>
<p class="text-muted">最終更新: 2025年5月26日</p>
<button class="btn btn-sm btn-outline-secondary"><i class="fas fa-star"></i> Star</button>
<button class="btn btn-sm btn-outline-secondary"><i class="fas fa-code-branch"></i> Fork</button>
</div>
<!-- タブ -->
<ul class="nav nav-tabs" id="repoTabs">
<li class="nav-item"><a class="nav-link active" href="#" onclick="switchTab('code')"><i class="fas fa-code"></i> Code</a></li>
<li class="nav-item"><a class="nav-link" href="#" onclick="switchTab('issues')"><i class="fas fa-exclamation-circle"></i> Issues</a></li>
<li class="nav-item"><a class="nav-link" href="#" onclick="switchTab('commits')"><i class="fas fa-history"></i> Commits</a></li>
</ul>
<!-- Codeタブ -->
<div id="tab-code" class="tab-content">
<ul class="file-list list-unstyled bg-white p-3 border">
<li><i class="fas fa-folder"></i> src/</li>
<li><i class="fas fa-file-code"></i> index.js</li>
<li><i class="fas fa-file-alt"></i> README.md</li>
<li><i class="fas fa-file-alt"></i> LICENSE</li>
</ul>
<div class="readme-box">
<h4><i class="fas fa-book-open"></i> README.md</h4>
<hr>
<div id="readme-content"></div>
</div>
<div class="mt-4">
<h5><i class="fas fa-code"></i> コード編集 (Monaco Editor)</h5>
<div id="editor"></div>
<button id="saveCode" class="btn btn-success mt-2"><i class="fas fa-save"></i> 保存</button>
<button id="themeToggle" class="btn btn-secondary mt-2"><i class="fas fa-adjust"></i> テーマ切替</button>
</div>
</div>
<!-- Issuesタブ -->
<div id="tab-issues" class="tab-content" style="display:none;">
<div class="issues-box">
<h4><i class="fas fa-exclamation-circle"></i> Open Issues</h4>
<ul class="list-group">
<li class="list-group-item">
<strong>#1</strong> READMEの翻訳が必要です<br>
<small class="text-muted">posted by owner - 1日前</small>
</li>
<li class="list-group-item">
<strong>#2</strong> index.jsにテストコードを追加してください<br>
<small class="text-muted">posted by owner - 2日前</small>
</li>
</ul>
</div>
</div>
<!-- Commitsタブ -->
<div id="tab-commits" class="tab-content" style="display:none;">
<div class="commits-box">
<h4><i class="fas fa-history"></i> Commits</h4>
<ul class="list-group">
<li class="list-group-item">
<strong>Initial commit</strong> - 2025-05-25<br>
<small class="text-muted">by owner</small>
</li>
<li class="list-group-item">
<strong>README updated</strong> - 2025-05-26<br>
<small class="text-muted">by owner</small>
</li>
</ul>
</div>
</div>
</div>
<!-- ライブラリ -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs/loader.min.js"></script>
<!-- タブ切替・Markdown表示・Monaco起動 -->
<script>
const markdown = `
# SampleRepo
## 特徴
- Monaco Editorでコード編集
- Markdown表示(README)
- ダークモード対応
- Issue・コミットのUI
## 技術
- HTML/CSS/JS
- Bootstrap5
- Monaco Editor
- Marked.js
`;
document.getElementById('readme-content').innerHTML = marked.parse(markdown);
function switchTab(tab) {
['code', 'issues', 'commits'].forEach(id => {
document.getElementById('tab-' + id).style.display = (id === tab) ? 'block' : 'none';
});
document.querySelectorAll('#repoTabs .nav-link').forEach(link => link.classList.remove('active'));
document.querySelector(`#repoTabs .nav-link[onclick*="${tab}"]`).classList.add('active');
}
// Monaco起動
require.config({ paths: { 'vs': 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs' }});
require(['vs/editor/editor.main'], function () {
window.editor = monaco.editor.create(document.getElementById('editor'), {
value: localStorage.getItem('savedCode') || `function hello() {\n console.log("Hello from Monaco Editor!");\n}`,
language: 'javascript',
theme: 'vs-light',
automaticLayout: true
});
document.getElementById('saveCode').onclick = function () {
const code = editor.getValue();
localStorage.setItem('savedCode', code);
alert('保存しました!');
};
document.getElementById('themeToggle').onclick = function () {
const theme = monaco.editor.getTheme() === 'vs-dark' ? 'vs-light' : 'vs-dark';
monaco.editor.setTheme(theme);
};
});
</script>
</body>
</html>
Tsumugi.html(SNS)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Tsumugi</title>
<!-- Favicon -->
<link rel="shortcut icon"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path fill='%23667eea' d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z'/></svg>"/>
<!-- 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"/>
<style>
:root {
--grad-a: #667eea;
--grad-b: #764ba2;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, var(--grad-a) 0%, var(--grad-b) 100%);
min-height: 100vh;
}
.glass-effect {
background: rgba(255,255,255,0.25);
backdrop-filter: blur(10px);
border-radius: 16px;
border: 1px solid rgba(255,255,255,0.18);
}
.card-hover { transition: all 0.25s ease; }
.card-hover:hover { transform: translateY(-3px); box-shadow: 0 20px 30px -12px rgba(0,0,0,0.25); }
.gradient-text {
background: linear-gradient(45deg, var(--grad-a), var(--grad-b));
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.timeline-post {
background: white;
border-radius: 16px;
box-shadow: 0 10px 20px -12px rgba(0,0,0,0.2);
transition: all 0.25s ease;
border-left: 4px solid var(--grad-a);
}
.timeline-post:hover { transform: translateX(4px); }
.profile-avatar {
width: 100px; height: 100px; border-radius: 50%;
object-fit: cover; border: 4px solid white;
box-shadow: 0 10px 24px rgba(0,0,0,0.15);
}
.btn-primary {
background: linear-gradient(45deg, var(--grad-a), var(--grad-b));
border: none; transition: all 0.2s ease;
}
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 10px 18px rgba(0,0,0,0.18); }
.section-divider {
height: 3px; background: linear-gradient(90deg, var(--grad-a), var(--grad-b));
border-radius: 2px; margin: 2rem 0;
}
.username-badge {
background: linear-gradient(45deg, var(--grad-a), var(--grad-b));
color: white; padding: 0.2rem 0.5rem; border-radius: 1rem;
font-size: 0.75rem; font-weight: 600; display: inline-block; margin-left: 0.5rem;
}
.share-menu { position: absolute; z-index: 50; min-width: 180px; right: 0; top: 110%; background: white; border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,0.13); }
.share-menu button { width: 100%; text-align: left; padding: 10px 20px; border: none; background: none; cursor: pointer; font-size: 0.95rem; }
.share-menu button:hover { background: #f0f4ff; }
.status-indicator { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
.status-active { background-color: #10b981; animation: pulse 2s infinite; }
.status-inactive { background-color: #6b7280; }
.log-container {
max-height: 180px; overflow-y: auto; background: rgba(255,255,255,0.12);
border-radius: 10px; padding: 10px; margin-top: 10px; font-size: 0.8rem; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.error-message { color: #ef4444; background: rgba(239, 68, 68, 0.08); padding: 8px; border-radius: 8px; margin: 5px 0; }
.success-message { color: #10b981; background: rgba(16, 185, 129, 0.08); padding: 8px; border-radius: 8px; margin: 5px 0; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
.dark .glass-effect { background: rgba(0,0,0,0.30); border: 1px solid rgba(255,255,255,0.12); }
.dark .timeline-post { background: #1f2937; color: #f9fafb; border-left-color: #4f46e5; }
.dark .share-menu { background: #111827; color: #e5e7eb; }
.dark .success-message { background: rgba(16,185,129,0.12); }
.dark .error-message { background: rgba(239,68,68,0.12); }
@media print {
body { background: white !important; -webkit-print-color-adjust: exact; }
.glass-effect { background: white !important; backdrop-filter: none !important; border: 1px solid #e5e7eb !important; }
}
</style>
</head>
<body>
<!-- ログイン/登録モーダル -->
<div id="auth-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" style="display:none">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-sm p-6">
<h2 class="text-2xl font-bold mb-4 text-center" id="auth-title">ログイン</h2>
<div id="auth-error" class="error-message mb-2" style="display:none"></div>
<form id="auth-form" autocomplete="off">
<div class="mb-3">
<label class="block mb-1 text-sm font-semibold">メールアドレス</label>
<input type="email" id="auth-email" class="w-full border rounded px-3 py-2" required>
</div>
<div class="mb-3">
<label class="block mb-1 text-sm font-semibold">パスワード</label>
<input type="password" id="auth-password" class="w-full border rounded px-3 py-2" required>
</div>
<button type="submit" class="btn-primary w-full py-2 rounded-lg text-white font-semibold">ログイン</button>
</form>
<div class="mt-4 text-center">
<button id="toggle-auth-mode" class="text-blue-600 underline text-sm">新規登録はこちら</button>
</div>
</div>
</div>
<!-- ヘッダー -->
<header class="glass-effect mx-4 mt-4 p-6">
<div class="text-center">
<h1 class="text-4xl font-bold text-white mb-2">
<i class="fas fa-comments mr-3"></i>Tsumugi
<span class="text-sm opacity-80 ml-2"></span>
</h1>
<p class="text-white text-lg opacity-90">次世代ソーシャルネットワーク</p>
<div class="mt-4 flex justify-center items-center space-x-4">
<div class="flex items-center">
<img id="header-profile-icon" class="profile-avatar" src="https://via.placeholder.com/100" alt="プロフィール">
<div class="ml-3 text-white text-left">
<div class="font-semibold text-lg" id="header-username">未設定</div>
<div class="text-sm opacity-75" id="header-user-email"></div>
</div>
</div>
<button onclick="toggleDarkMode()" class="btn-primary px-4 py-2 rounded-full text-white">
<i class="fas fa-moon mr-2"></i>ダークモード
</button>
<button onclick="showSystemStatus()" class="btn-primary px-4 py-2 rounded-full text-white">
<i class="fas fa-info-circle mr-2"></i>ステータス
</button>
<button id="logout-btn" onclick="logout()" class="btn-primary px-4 py-2 rounded-full text-white" style="display:none">
<i class="fas fa-sign-out-alt mr-2"></i>ログアウト
</button>
</div>
</div>
</header>
<!-- メイン -->
<div class="max-w-6xl mx-auto px-4 py-6" id="main-content" style="display:none">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 左カラム:プロフィール/BOT/RSS -->
<div class="lg:col-span-1 space-y-6">
<!-- プロフィール -->
<div class="glass-effect p-6 card-hover">
<h3 class="text-2xl font-bold gradient-text mb-4"><i class="fas fa-user-circle mr-2"></i>プロフィール</h3>
<div class="text-center mb-6">
<img id="profile-icon" class="profile-avatar mx-auto mb-4" src="https://via.placeholder.com/100" alt="プロフィール">
<input type="file" id="profile-upload" accept="image/*" onchange="uploadProfileIcon(event)" class="hidden">
<button onclick="document.getElementById('profile-upload').click()" class="btn-primary px-4 py-2 rounded-full text-white">
<i class="fas fa-camera mr-2"></i>画像変更
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-white font-semibold mb-2">ユーザー名</label>
<input type="text" id="username" class="w-full p-3 border rounded-lg bg-white bg-opacity-90" placeholder="ユーザー名を入力してください" maxlength="20">
</div>
<div>
<label class="block text-white font-semibold mb-2">自己紹介</label>
<textarea id="self-intro" class="w-full p-3 border rounded-lg bg-white bg-opacity-90" rows="4" placeholder="自己紹介を入力してください"></textarea>
</div>
<button onclick="saveProfile()" class="btn-primary w-full py-2 rounded-lg text-white">
<i class="fas fa-save mr-2"></i>プロフィール保存
</button>
<div class="p-3 bg-white bg-opacity-80 rounded-lg">
<h5 class="font-semibold text-gray-800 mb-2">プレビュー:</h5>
<div class="text-gray-700">
<div class="font-semibold mb-1" id="username-preview">未設定</div>
<div id="self-intro-preview" class="text-sm whitespace-pre-line min-h-8">まだ自己紹介がありません</div>
</div>
</div>
</div>
</div>
<!-- RSS自動投稿機能(全体共有 + 個別ON/OFF) -->
<div class="glass-effect p-6 card-hover">
<h3 class="text-xl font-bold text-white mb-4">
<i class="fas fa-rss mr-2"></i>RSS自動投稿(全体共有)
<span class="status-indicator" id="rss-status"></span>
<span id="rss-status-text" class="text-xs opacity-75">停止中</span>
</h3>
<div>
<input id="rss-url" type="text" class="w-full p-2 border rounded-lg bg-white bg-opacity-90 mb-2" placeholder="RSSフィードURLを入力">
<button onclick="addRssFeed()" class="btn-primary w-full py-2 rounded-lg text-white mb-2">
<i class="fas fa-plus mr-2"></i>追加
</button>
<div id="rss-list" class="mb-3"></div>
<div class="flex items-center space-x-2 mb-2">
<input type="number" id="rss-interval" class="w-1/2 p-2 border rounded-lg bg-white bg-opacity-90" min="10" max="3600" value="300" placeholder="間隔(秒)">
<button onclick="setRssInterval()" class="btn-primary flex-1 py-2 rounded-lg text-white">
<i class="fas fa-clock mr-2"></i>間隔設定
</button>
</div>
<div class="flex items-center space-x-2 mb-2">
<button onclick="fetchRssNow()" class="btn-primary flex-1 py-2 rounded-lg text-white">
<i class="fas fa-sync mr-2"></i>今すぐ取得
</button>
<button onclick="stopRssAuto()" class="bg-red-500 hover:bg-red-600 flex-1 py-2 rounded-lg text-white">
<i class="fas fa-stop mr-2"></i>自動停止
</button>
</div>
<div class="flex items-center space-x-2">
<button onclick="setAllRssEnabled(true)" class="btn-primary flex-1 py-2 rounded-lg text-white text-sm">
<i class="fas fa-toggle-on mr-1"></i>すべてON
</button>
<button onclick="setAllRssEnabled(false)" class="bg-gray-500 hover:bg-gray-600 flex-1 py-2 rounded-lg text-white text-sm">
<i class="fas fa-toggle-off mr-1"></i>すべてOFF
</button>
</div>
<div id="rss-log" class="log-container text-white text-xs mt-3"></div>
</div>
</div>
<!-- BOT機能 -->
<div class="glass-effect p-6 card-hover">
<h3 class="text-xl font-bold text-white mb-4">
<i class="fas fa-robot mr-2"></i>BOT機能
<span class="status-indicator" id="bot-status"></span>
<span id="bot-status-text" class="text-xs opacity-75">停止中</span>
</h3>
<div class="space-y-4">
<div>
<textarea id="botContent" class="w-full p-3 border rounded-lg bg-white bg-opacity-90" rows="3" placeholder="BOT投稿内容"></textarea>
<button onclick="postBotMessage()" class="btn-primary w-full mt-2 py-2 rounded-lg text-white">
<i class="fas fa-robot mr-2"></i>BOT投稿
</button>
</div>
<div>
<input type="number" id="botIntervalSec" class="w-full p-2 border rounded-lg bg-white bg-opacity-90" placeholder="マルコフ自動投稿間隔(秒)" min="10" max="3600" value="60">
<div class="flex space-x-2 mt-2">
<button onclick="postMarkovBot()" class="btn-primary flex-1 py-2 rounded-lg text-white">
<i class="fas fa-dice mr-2"></i>マルコフ生成
</button>
<button onclick="startBotAutoPost()" class="btn-primary flex-1 py-2 rounded-lg text-white">
<i class="fas fa-play mr-2"></i>自動開始
</button>
<button onclick="stopBotAutoPost()" class="bg-red-500 hover:bg-red-600 flex-1 py-2 rounded-lg text-white">
<i class="fas fa-stop mr-2"></i>停止
</button>
</div>
</div>
<div class="text-white text-xs opacity-75">
<i class="fas fa-info-circle mr-1"></i>
マルコフ連鎖では過去の投稿からランダムな文章を生成します(FEED本文は省略し、タイトルのみを学習対象にしません)。
</div>
<div id="bot-log" class="log-container text-white text-xs"></div>
</div>
</div>
</div>
<!-- 右カラム:投稿&タイムライン -->
<div class="lg:col-span-2 space-y-6">
<!-- 新規投稿 -->
<div class="glass-effect p-6 card-hover">
<h3 class="text-2xl font-bold gradient-text mb-4"><i class="fas fa-edit mr-2"></i>新規投稿</h3>
<div>
<textarea id="postContent" class="w-full p-4 border rounded-lg bg-white bg-opacity-90" rows="4" placeholder="今何を考えていますか?" maxlength="500"></textarea>
<div class="mt-4 flex justify-between items-center">
<div class="text-white text-sm opacity-90">
<i class="fas fa-info-circle mr-1"></i>
あなたの思いを共有しましょう
<span id="char-count" class="ml-2">(0/500)</span>
</div>
<button onclick="createUserPost()" class="btn-primary px-6 py-3 rounded-lg text-white font-semibold">
<i class="fas fa-paper-plane mr-2"></i>投稿する
</button>
</div>
</div>
</div>
<div class="section-divider"></div>
<!-- タイムライン -->
<div class="glass-effect p-6">
<div class="flex justify-between items-center mb-6">
<h3 class="text-2xl font-bold text-white">
<i class="fas fa-stream mr-2"></i>タイムライン
<span id="post-count" class="text-sm font-normal opacity-75 ml-2">(0件の投稿)</span>
</h3>
<div class="flex space-x-2">
<button onclick="clearAllPosts()" class="bg-red-500 hover:bg-red-600 px-3 py-1 rounded text-white text-sm">
<i class="fas fa-trash mr-1"></i>全削除
</button>
<button onclick="exportData()" class="bg-green-500 hover:bg-green-600 px-3 py-1 rounded text-white text-sm">
<i class="fas fa-download mr-1"></i>エクスポート
</button>
</div>
</div>
<div id="timeline" class="space-y-4"></div>
<div id="empty-timeline" class="text-center py-12 text-white opacity-80">
<i class="fas fa-comments text-4xl mb-4"></i>
<p class="text-lg">まだ投稿がありません</p>
<p class="text-sm">最初の投稿をして、タイムラインを始めましょう!</p>
</div>
</div>
</div>
</div>
</div>
<footer class="glass-effect mx-4 mb-4 p-4 text-center">
<p class="text-white opacity-80">
<i class="fas fa-copyright mr-2"></i>2025 Verse – 次世代ソーシャルネットワーク v2.3
<span class="ml-4"><i class="fas fa-rss mr-1"></i>共有RSS / 個別ON/OFF対応(安全なフィード例)</span>
</p>
</footer>
<script>
// ==== 初期RSS(安全な例のみ。成人向け/不適切なサイトは除外) ====
const PRESET_RSS = [
"http://2ch-2.net/rss/all.xml",
"http://2ch-ranking.net/rss/livemarket1.rdf",
"http://2ch-ranking.net/rss/livemarket2.rdf",
"http://kabumatome.doorblog.jp/index.rdf",
"http://momoniji.com/feed",
"http://oekakigakusyuu.blog97.fc2.com/?xml",
"http://otanews.livedoor.biz/atom.xml",
"http://otanews.livedoor.biz/index.rdf",
"http://news4vip.livedoor.biz/index.rdf",
"http://news.kakaku.com/prdnews/rss.asp",
"http://www.jma-net.go.jp/rss/jma.rss",
"http://rss.asahi.com/rss/asahi/newsheadlines.rdf",
"https://uploadvr.com/feed/",
"http://www.atmarkit.co.jp/rss/rss2dc.xml",
"http://liginc.co.jp/feed",
"http://liginc.co.jp/feed/",
"http://blog.livedoor.jp/shachiani/index.rdf",
"http://manga.lemon-s.com/atom.xml",
"http://b.hatena.ne.jp/search/text?safe=on&q=%E3%82%BB%E3%82%AD%E3%83%A5%E3%83%AA%E3%83%86%E3%82%A3&users=500&mode=rss",
"http://creator-life.net/feed/",
"http://feedblog.ameba.jp/rss/ameblo/ca-1pixel/rss20.xml",
"http://rssblog.ameba.jp/ca-1pixel/rss20.xml",
"http://weekly.ascii.jp/cate/1/rss.xml",
"http://blog.livedoor.jp/coleblog/atom.xml",
"http://2chantena.antenam.biz/rss1s.rss",
"http://www.4gamer.net/rss/index.xml",
"http://www.4gamer.net/rss/news_topics.xml",
"http://nyan.eggtree.net/feed.xml",
"http://nunnnunn.hatenablog.com/rss",
"http://www.nikkansports.com/general/atom.xml",
"http://feeds.afpbb.com/rss/afpbb/access_ranking",
"http://akiba-pc.watch.impress.co.jp/cda/rss/akiba-pc.rdf",
"https://area.autodesk.jp/rss.xml",
"http://av.watch.impress.co.jp/sublink/av.rdf",
"http://rss.allabout.co.jp/aa/latest/ch/netpc/",
"http://www.ar-ch.org/atom.xml",
"http://feeds.arstechnica.com/arstechnica/BAaf",
"https://feeds.feedburner.com/awwwards-sites-of-the-day",
"http://news.bbc.co.uk/rss/newsonline_uk_edition/front_page/rss091.xml",
"http://www.criteo.com/blog/rss/",
"https://blueskyweb.xyz/rss.xml",
"http://boingboing.net/rss.xml",
"http://www.cc2.co.jp/blog/?feed=rss2",
"http://cgarena.com/cgarena.xml",
"http://cgtracking.net/feed",
"http://japan.cnet.com/rss/index.rdf",
"http://newclassic.jp/feed",
"https://www.cssmania.com/feed/",
"http://ceron.jp/top/?type=rss",
"http://blog.btrax.com/jp/comments/feed/",
"http://2ch-ranking.net/rss/wildplus.rdf",
"http://design-spice.com/feed/",
"http://dev.classmethod.jp/feed/",
"http://ishida-a-coicoi.blog.so-net.ne.jp/atom.xml",
"http://feeds.feedburner.com/ura-akiba?format=xml",
"http://game.watch.impress.co.jp/sublink/game.rdf",
"http://gigazine.co.jp/feed/",
"http://labs.gree.jp/blog/comments/feed/",
"http://www.gamespark.jp/rss/index.rdf",
"http://www.gamebusiness.jp/rss/index.rdf",
"http://www.gamebusiness.jp/rss/rss.php",
"http://hackread.com/feed/",
"https://io3000.com/feed/",
"http://www.inside-games.jp/rss/index.rdf",
"http://blog.livedoor.jp/itsoku/index.rdf",
"http://rss.itmedia.co.jp/rss/1.0/news_bursts.xml",
"http://octoba.net/feed",
"http://www.ota-suke.jp/index.xml",
"http://blog.livedoor.jp/kaigai_no/index.rdf",
"https://land-book.com/feed/",
"http://2ch.logpo.jp/1hour.xml",
"http://menthas.com/javascript/rss",
"http://www.nhk.or.jp/rss/news/cat0.xml",
"http://ozpa-h4.com/feed/",
"https://www.youtube.com/feeds/videos.xml?channel_id=UC1DCedRgGHBdm81E1llLhOQ",
"http://rass.blog43.fc2.com/?xml",
"http://stackoverflow.com/feeds",
"http://www.slideshare.net/rss/latest",
"http://www.jp.square-enix.com/whatsnew2/whatsnew.rdf",
"http://www.ituore.com/feed",
"http://synodos.jp/comments/feed",
"http://www.shinkigensha.co.jp/feed/",
"http://e-shuushuu.net/index.rss",
"http://slashdot.org/index.rss",
"http://feeds.feedburner.com/TheHackersNews?format=xml",
"http://googleblog.blogspot.com/atom.xml",
"http://www.theregister.co.uk/tonys/slashdot.rdf",
"http://thinkit.co.jp/rss.xml",
"http://blog.livedoor.jp/news23vip/atom.xml",
"http://blog.livedoor.jp/news23vip/index.rdf",
"http://www.webcreatorbox.com/feed/",
"http://web-d.navigater.info/atom.xml",
"http://2ch-c.net/?xml_all",
"http://smhn.info/feed",
"http://feeds.japan.zdnet.com/rss/zdnet/all.rdf",
"http://20kaido.com/index.rdf",
"http://2chnode.com/rss/feed/all",
"http://akiba-souken.com/feed/all/",
"http://amaebi.net/index.rdf",
"http://amakakeru.blog59.fc2.com/?xml",
"http://artskype.com/rss/feed.xml",
"http://asitagamienai.blog118.fc2.com/?xml",
"http://beta.egmnow.com/feed/",
"http://blog.livedoor.jp/ogenre/index.rdf",
"http://blog.nicovideo.jp/atom.xml",
"http://blog.tsubuani.com/feed",
"http://blogs.adobe.com/flex/atom.xml",
"http://blogs.adobe.com/index.xml",
"http://bm.s5-style.com/feed",
"http://business.nikkeibp.co.jp/rss/all_nbo.rdf",
"http://createlier.sitemix.jp/feed/",
"http://crocro.com/news/nc.cgi?action=search&skin=rdf_srch_xml",
"http://d.hatena.ne.jp/thk/rss",
"http://damage0.blomaga.jp/index.rdf",
"http://danbooru.donmai.us/posts.atom",
"http://danbooru.donmai.us/posts.atom?tags=rss",
"http://dengekionline.com/cate/11/rss.xml",
"http://dictionary.reference.com/wordoftheday/wotd.rss",
"http://doujin-games88.net/feed",
"http://doujin.sekurosu.com/rss",
"http://dousyoko.blog.fc2.com/?xml",
"http://eroaniblog.blog.fc2.com/?xml",
"http://eroanimedougakan.blog.fc2.com/?xml",
"http://erogetrailers.com/api?md=latest",
"http://eronizimage.blog.fc2.com/?xml",
"http://erosanime.blog121.fc2.com/?xml",
"http://erotaganime.blog.fc2.com/?xml",
"http://feed.nikkeibp.co.jp/rss/nikkeibp/index.rdf",
"http://feed.rssad.jp/rss/gigazine/rss_2.0",
"http://feed.rssad.jp/rss/jcast/index.xml",
"http://feed.rssad.jp/rss/klug/fxnews/rss5.xml",
"http://feedblog.ameba.jp/rss/ameblo/yusayusa0211/rss20.xml",
"http://feeds.adobe.com/xml/rss.cfm?query=byMostRecent&languages=1",
"http://feeds.builder.japan.zdnet.com/rss/builder/all.rdf",
"http://feeds.fc2.com/fc2/xml?host=anrism.blog&format=xml",
"http://feeds.fc2.com/fc2/xml?host=kahouha2jigen.blog&format=xml",
"http://feeds.feedburner.com/gekiura",
"http://feeds.journal.mycom.co.jp/rss/mycom/index",
"http://feeds.reuters.com/reuters/JPTopNews?format=xml",
"http://galten705.blog.fc2.com/?xml",
"http://gamanjiru.net/feed",
"http://gamanjiru.net/feed/atom",
"http://gamebiz.jp/?feed=rss",
"http://gamenode.jp/rss/feed/all",
"http://ggsoku.com/feed/atom/",
"http://girlcelly.blog.fc2.com/?xml&trackback",
"http://hairana.blog.fc2.com/?xml",
"http://haruka-yumenoato.net/static/rss/index.rss",
"http://headline.harikonotora.net/rss2.xml",
"http://hentaidoujinanime.com/?xml",
"http://homepage1.nifty.com/maname/index.rdf",
"http://horiemon.com/feed/",
"http://ideahacker.net/feed/",
"http://itpro.nikkeibp.co.jp/rss/develop.rdf",
"http://itpro.nikkeibp.co.jp/rss/news.rss",
"http://itpro.nikkeibp.co.jp/rss/oss.rdf",
"http://itpro.nikkeibp.co.jp/rss/win.rdf",
"http://japan.internet.com/rss/rdf/index.rdf",
"http://jp.leopard-raws.org/rss.php",
"http://jp.techcrunch.com/feed/",
"http://kakaku.com/trendnews/rss.xml",
"http://kamisoku.blog47.fc2.com/?xml",
"http://kanesoku.com/index.rdf",
"http://kibougamotenai.blog.fc2.com/?xml",
"http://kiisu.jpn.org/rss/now.xml",
"http://konachan.com/post/piclens?page=1&tags=loli",
"http://labo.tv/2chnews/index.xml",
"http://lineblog.me/yamamotoichiro/atom.xml",
"http://majimougen.blog.fc2.com/?xml",
"http://mantan-web.jp/rss/mantan.xml",
"http://matome.naver.jp/feed/hot",
"http://matome.naver.jp/feed/tech",
"http://matome.sekurosu.com/rss",
"http://mizuhonokuni2ch.com/?xml",
"http://momoiroanime.blog.fc2.com/?xml",
"http://moroahedoujin.com/?xml",
"http://nesingazou.blog.fc2.com/?xml",
"http://newnews-moe.com/index.rdf",
"http://news.ameba.jp/index.xml",
"http://news.com.com/2547-1_3-0-5.xml",
"http://news.nicovideo.jp/?rss=2.0",
"http://news.nicovideo.jp/ranking/hot?rss=2.0",
"http://newsbiz.yahoo.co.jp/topnews.rss",
"http://nijitora.blog.fc2.com/?xml",
"http://nodvd21ver2.blog.fc2.com/?xml",
"http://orebibou.com/feed/",
"http://osu.ppy.sh/feed/ranked/",
"http://otakomu.jp/feed",
"http://pcgameconquest.blog.fc2.com/?xml",
"http://picks.dir.yahoo.co.jp/dailypicks/rss/",
"http://piknik2ch.blog76.fc2.com/?xml",
"http://plus.appgiga.jp/feed/user",
"http://purisoku.com/index.rdf",
"http://rdsig.yahoo.co.jp/RV=1/RU=aHR0cDovL3NlYXJjaHJhbmtpbmcueWFob28uY28uanAvcnNzL2J1cnN0X3JhbmtpbmctcnNzLnhtbA--;_ylt=A2RhjFhfAi9XEi0A6Glhdu57",
"http://read2ch.net/rss/",
"http://rss.dailynews.yahoo.co.jp/fc/computer/rss.xml",
"http://rss.rssad.jp/rss/akibapc/akiba-pc.rdf",
"http://rss.rssad.jp/rss/ascii/biz/rss.xml",
"http://rss.rssad.jp/rss/ascii/hobby/rss.xml",
"http://rss.rssad.jp/rss/ascii/it/rss.xml",
"http://rss.rssad.jp/rss/ascii/mac/rss.xml",
"http://rss.rssad.jp/rss/ascii/pc/rss.xml",
"http://rss.rssad.jp/rss/ascii/rss.xml",
"http://rss.rssad.jp/rss/codezine/new/20/index.xml",
"http://rss.rssad.jp/rss/forest/rss.xml",
"http://rss.rssad.jp/rss/gihyo/feed/atom",
"http://rss.rssad.jp/rss/headline/headline.rdf",
"http://rss.rssad.jp/rss/impresswatch/pcwatch.rdf",
"http://rss.rssad.jp/rss/itm/1.0/makoto.xml",
"http://rss.rssad.jp/rss/itm/1.0/netlab.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_akiba.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_android_appli.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_apple.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_facebook.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_google.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_ipad.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_iphone.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_iphone_appli.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_mixi.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_smartphone.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_twitter.xml",
"http://rss.rssad.jp/rss/itm/2.0/kw_ustream.xml",
"http://rss.rssad.jp/rss/itmbizid/1.0/bizid.xml",
"http://rss.rssad.jp/rss/itmnews/2.0/news_bursts.xml",
"http://rss.rssad.jp/rss/japaninternetcom/index.rdf",
"http://rss.rssad.jp/rss/oshietekun/atom.xml",
"http://rss.rssad.jp/rss/slashdot/slashdot.rss",
"http://rss.rssad.jp/rss/zaikeishimbun/main.xml",
"http://rssc.dokoda.jp/r/8a1dd8f128047929ba4390dab3c8065e/http/searchranking.yahoo.co.jp/realtime_buzz/",
"http://sakurabaryo.com/feed/",
"http://sankei.jp.msn.com/rss/news/points.xml",
"http://sankei.jp.msn.com/rss/news/west_points.xml",
"http://search.goo.ne.jp/rss/newkw.rdf",
"http://sekurosu.com/rss",
"http://streaming.yahoo.co.jp/rss/newly/anime/",
"http://sub0000528116.hmk-temp.com/wordpress/?feed=rss2",
"http://sukebei.nyaa.se/?page=rss&sort=2",
"http://tenshoku.mynavi.jp/knowhow/rss.xml",
"http://tensinyakimeshi.blog98.fc2.com/?xml",
"http://thefreedom12.blog41.fc2.com/?xml",
"http://togetter.com/rss/hot/culture/62",
"http://togetter.com/rss/hot/culture/63",
"http://torimatome.main.jp/blogs/comments/feed",
"http://torimatome.main.jp/blogs/feed",
"http://toshinokyouko.com/rss.php",
"http://tvanimedouga.blog93.fc2.com/?xml",
"http://uranourainformation.blog21.fc2.com/?xml",
"http://video.fc2.com/a/feed_popular.php?m=week",
"http://weather.livedoor.com/forecast/rss/area/400010.xml",
"http://wotopi.jp/feed",
"http://www.100shiki.com/feed",
"http://www.alistapart.com/rss.xml",
"http://www.anime-sharing.com/forum/external.php?type=RSS2&forumids=36",
"http://www.anime-sharing.com/forum/external.php?type=RSS2&forumids=38",
"http://www.anime-sharing.com/forum/external.php?type=RSS2&forumids=47",
"http://www.blosxom.com/?feed=rss2",
"http://www.britannica.com/eb/dailycontent/rss",
"http://www.csmonitor.com/rss/top.rss",
"http://www.ehackingnews.com/feeds/posts/default",
"http://www.falcom.co.jp/new.xml",
"http://www.famitsu.com/rss/category/fcom_game.rdf",
"http://www.famitsu.com/rss/fcom_all.rdf",
"http://www.ganganonline.com/rss/index.xml",
"http://www.ideaxidea.com/feed",
"http://www.itnews711.com/index.rdf",
"http://www.jp.playstation.com/whatsnew/whatsnew.rdf",
"http://www.keyman.or.jp/rss/v1/?rss_type=all",
"http://www.koubo.co.jp/rss.xml",
"http://www.nyaa.se/?page=rss&sort=2",
"http://www.nyaa.se/?page=rss&user=118009",
"http://www.nytimes.com/services/xml/rss/userland/HomePage.xml",
"http://www.phianime.tv/feed/",
"http://www.rebootdevelop.hr/feed/",
"http://www.rictus.com/muchado/feed/",
"http://www.sbcr.jp/atom.xml",
"http://www.slashgear.com/comments/feed/",
"http://www.torrent-anime.com/feed",
"http://www.torrent-anime.com/feed/",
"http://www.webimemo.com/feed/",
"http://www.wired.com/news_drop/netcenter/netcenter.rdf",
"http://www.xvideos.com/rss/rss.xml",
"http://www.youtube.com/rss/user/KADOKAWAanime/videos.rss",
"http://www.youtube.com/rss/user/demosouko/videos.rss",
"http://www.yukawanet.com/index.rdf",
"http://www.zou3.net/php/rss/nikkei2rss.php?head=main",
"http://xml.ehgt.org/ehtracker.xml",
"http://xml.metafilter.com/rss.xml",
"http://xvideos.2jiero.info/feed",
"http://yaraon.blog109.fc2.com/?xml",
"http://yusaani.com/home/feed/",
"http://zipdeyaruo.blog42.fc2.com/?xml",
"http://www.portalgraphics.net/rss/latest_image_list.xml",
"http://api.syosetu.com/writernovel/430380.Atom",
"http://creive.me/feed/",
"http://gihyo.jp/dev/feed/atom",
"http://gihyo.jp/feed/rss1",
"http://hakase255.blog135.fc2.com/?xml",
"http://2ch-ranking.net/rss/zenban.rdf",
"http://www.isus.jp/feed/",
"http://www.jiji.com/rss/ranking.rdf",
"http://jp.gamesindustry.biz/rss/index.xml",
"https://www.youtube.com/feeds/videos.xml?channel_id=UCx1nAvtVDIsaGmCMSe8ofsQ",
"http://zakuzaku911.com/index.rdf",
"http://ke-tai.org/blog/feed/",
"http://data.newantenna.net/ero/rss/all.xml",
"http://developer.mixi.co.jp/feed/atom",
"http://neoneetch.blog.fc2.com/?xml",
"http://rss.itmedia.co.jp/rss/1.0/netlab.xml",
"http://netgeek.biz/feed",
"http://blog.esuteru.com/index.rdf",
"http://b.hatena.ne.jp/hotentry/game.rss",
"http://b.hatena.ne.jp/hotentry.rss",
"http://mobile.seisyun.net/rss/hot.rdf",
"http://yomi.mobi/rss/hot.rdf",
"http://saymygame.com/feed/",
"http://blog.webcreativepark.net/atom.xml",
"http://buhidoh.net/?xml",
"http://www.webcyou.com/?feed=rss2",
"http://withnews.jp/rss/consumer/new.rdf",
"https://yande.re/post/atom?tags=loli",
"http://blog.livedoor.jp/nizigami/atom.xml",
"http://nvmzaq.blog.fc2.com/?xml",
"http://keieimanga.net/index.rdf",
"http://megumi.ldblog.jp/atom.xml",
"http://kirik.tea-nifty.com/diary/index.rdf",
"http://sinri.net/comments/feed",
"http://himasoku.com/atom.xml",
"http://himasoku.com/index.rdf",
"http://20kaido.com/index.rdf",
"http://h723.blog.fc2.com/?xml",
"http://onecall2ch.com/index.rdf",
"http://www.forest.impress.co.jp/rss.xml",
"http://www.zaikei.co.jp/rss/sections/it.xml",
"http://akiba.keizai.biz/rss.xml",
"http://agag.tw/feed/2d-popular.rss",
"http://adult-vr.jp/feed/",
"http://www.anige-sokuhouvip.com/?xml",
"http://animeanime.jp/rss/index.rdf",
"http://alfalfalfa.com/index.rdf",
"http://feeds.feedburner.com/fc2/GhfA",
"http://erogetaiken072.blog.fc2.com/?xml",
"http://otanew.jp/atom.xml",
"http://jin115.com/index.rdf",
"http://www.onlinegamer.jp/rss/news.rdf",
"http://karapaia.livedoor.biz/index.rdf",
"http://getnews.jp/feed/ext/orig",
"http://www.gungho.co.jp/news/xml/rss.xml",
"http://blog.livedoor.jp/kinisoku/index.rdf",
"http://feeds.gizmodo.jp/rss/gizmodo/index.xml",
"http://himado.in/?sort=movie_id&rss=1",
"http://k-tai.impress.co.jp/cda/rss/ktai.rdf",
"http://gehasoku.com/atom.xml",
"http://feedblog.ameba.jp/rss/ameblo/principia-ca/rss20.xml",
"http://zai.diamond.jp/list/feed/rssfxnews",
"http://capacitor.blog.fc2.com/?xml",
"http://blog.livedoor.jp/vipsister23/index.rdf",
"http://vipsister23.com/atom.xml",
"http://b.hatena.ne.jp/search/tag?safe=on&q=2ch&users=500&mode=rss",
"http://b.hatena.ne.jp/search/tag?safe=on&q=%E3%83%8D%E3%83%83%E3%83%88%E3%83%AF%E3%83%BC%E3%82%AF&users=500&mode=rss",
"http://b.hatena.ne.jp/search/tag?safe=off&q=%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B0&users=500&mode=rss",
"http://shikaku2ch.doorblog.jp/atom.xml",
"http://dotinstall.com/lessons.rss",
"http://2ch-ranking.net/rss/news4vip.rdf",
"http://blog.livedoor.jp/insidears/index.rdf",
"http://2ch-ranking.net/rss/newsplus.rdf",
"http://2ch-ranking.net/rss/news.rdf",
"http://nullpoantenna.com/game.rdf",
"http://workingnews.blog117.fc2.com/?xml",
"http://bm.s5-style.com/feed",
"http://2ch-ranking.net/rss/ghard.rdf",
"http://www.724685.com/blog/rss.xml",
"http://www.yukawanet.com/index.rdf",
"http://2ch-ranking.net/rss/bizplus.rdf",
"http://www.nicovideo.jp/ranking/fav/daily/all?rss=2.0&lang=ja-jp",
"http://www.tarikin.net/rss0.rdf",
"http://blog.livedoor.jp/dqnplus/index.rdf",
"http://www.seojapan.com/blog/feed",
"http://2ch-ranking.net/rss/morningcoffee.rdf",
"http://2ch-ranking.net/mt50k.rdf",
"http://rssblog.ameba.jp/yandereotto/rss20.xml",
"https://business.nikkei.com/rss/sns/nb.rdf",
"http://daredemopc.blog51.fc2.com/?xml",
"http://erogetaikenban.blog65.fc2.com/?xml",
"http://news.goo.ne.jp/rss/topstories/gootop/index.rdf",
"http://lanovelien.blog121.fc2.com/?xml",
"http://news.livedoor.com/topics/rss/eco.xml",
"http://ragnarokonline.gungho.jp/index.rdf",
"http://rocketnews24.com/feed/",
"https://news.denfaminicogamer.jp/feed",
"http://www.igda.jp/?feed=rss2",
"http://feeds.cnn.co.jp/cnn/rss"
];
// ==== 既存データのロード ====
if (!localStorage.getItem('verse_shared_rssFeeds')) {
localStorage.setItem('verse_shared_rssFeeds', JSON.stringify(PRESET_RSS));
}
// アプリ状態
let users = JSON.parse(localStorage.getItem('verse_users') || '[]');
let currentUser = JSON.parse(localStorage.getItem('verse_currentUser') || 'null');
let posts = JSON.parse(localStorage.getItem('verse_posts') || '[]');
let isDarkMode = localStorage.getItem('verse_darkMode') === 'true';
let isInitialized = false;
// RSS/BOT状態
let botInterval = null;
let rssInterval = null;
// 共有RSS設定
let sharedRssFeeds = JSON.parse(localStorage.getItem('verse_shared_rssFeeds') || '[]');
let sharedRssInterval = Number(localStorage.getItem('verse_shared_rssInterval')) || 300;
let sharedRssLastIds = JSON.parse(localStorage.getItem('verse_shared_rssLastIds') || '{}');
// 各フィードのON/OFF(true=ON)。未設定はデフォルトON。
let sharedRssEnabled = JSON.parse(localStorage.getItem('verse_shared_rssEnabled') || '{}');
// ===== 認証UI =====
function showAuthModal(mode = 'login', errorMsg = '') {
document.getElementById('auth-title').textContent = (mode === 'register') ? '新規登録' : 'ログイン';
document.getElementById('auth-form').authMode = mode;
document.getElementById('auth-email').value = '';
document.getElementById('auth-password').value = '';
document.getElementById('auth-modal').style.display = '';
document.getElementById('main-content').style.display = 'none';
document.getElementById('auth-error').textContent = errorMsg || '';
document.getElementById('auth-error').style.display = errorMsg ? '' : 'none';
document.getElementById('toggle-auth-mode').textContent = (mode === 'register') ? 'ログインはこちら' : '新規登録はこちら';
}
function hideAuthModal() {
document.getElementById('auth-modal').style.display = 'none';
document.getElementById('main-content').style.display = '';
}
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('auth-form').onsubmit = function(e) {
e.preventDefault();
const email = document.getElementById('auth-email').value.trim().toLowerCase();
const password = document.getElementById('auth-password').value;
if (!email || !password) {
showAuthModal(this.authMode, 'メールアドレスとパスワードを入力してください');
return;
}
if (this.authMode === 'register') {
if (users.find(u => u.email === email)) {
showAuthModal('register', 'このメールアドレスは既に登録されています');
return;
}
const newUser = {
email,
password,
profile: { icon: 'https://via.placeholder.com/100', username: email.split('@')[0], selfIntro: '' }
};
users.push(newUser);
localStorage.setItem('verse_users', JSON.stringify(users));
currentUser = { email };
localStorage.setItem('verse_currentUser', JSON.stringify(currentUser));
showAuthModal('login', '登録完了!ログインしてください');
} else {
const user = users.find(u => u.email === email && u.password === password);
if (!user) { showAuthModal('login', 'メールアドレスまたはパスワードが違います'); return; }
currentUser = { email };
localStorage.setItem('verse_currentUser', JSON.stringify(currentUser));
hideAuthModal();
initializeApp();
}
};
document.getElementById('toggle-auth-mode').onclick = function() {
const mode = (document.getElementById('auth-title').textContent === '新規登録') ? 'login' : 'register';
showAuthModal(mode);
};
if (!currentUser) showAuthModal('login'); else { hideAuthModal(); initializeApp(); }
});
function logout() {
localStorage.removeItem('verse_currentUser');
currentUser = null;
stopRssAuto();
showAuthModal('login');
}
// ===== 初期化 =====
function initializeApp() {
if (isInitialized) return;
if (!currentUser) { showAuthModal('login'); return; }
users = JSON.parse(localStorage.getItem('verse_users') || '[]');
posts = JSON.parse(localStorage.getItem('verse_posts') || '[]');
isDarkMode = localStorage.getItem('verse_darkMode') === 'true';
const user = users.find(u => u.email === currentUser.email);
window.profile = user ? user.profile : { icon: 'https://via.placeholder.com/100', username: 'ゲストユーザー', selfIntro: '' };
updateAllUI();
updateStatusIndicators();
updateRssUI();
if (isDarkMode) {
document.body.classList.add('dark');
document.body.style.background = 'linear-gradient(135deg, #1a202c 0%, #2d3748 100%)';
} else {
document.body.classList.remove('dark');
document.body.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
}
document.getElementById('main-content').style.display = '';
document.getElementById('logout-btn').style.display = '';
isInitialized = true;
startRssAuto();
addLog('bot-log', 'BOT機能初期化完了', 'success');
addLog('rss-log', 'RSS自動投稿(全体共有)初期化完了', 'success');
}
// ===== 投稿(ユーザー/BOT) =====
function createUserPost() {
const ta = document.getElementById('postContent');
const txt = ta.value.trim();
if (!txt) return alert('投稿内容を入力してください。');
if (!currentUser) return alert('ログインが必要です。');
createPost(txt, 'user', profile.username, profile.icon);
ta.value = '';
updateCharCount();
}
function createPost(content, type = 'user', username = null, icon = null, extra = {}) {
if (!content || !content.trim()) return false;
const post = {
id: Date.now() + Math.random(),
content: content.trim(),
likes: 0,
timestamp: new Date().toLocaleString('ja-JP'),
type,
username: username || profile.username,
icon: icon || profile.icon,
userEmail: currentUser ? currentUser.email : '',
...extra
};
posts.unshift(post);
saveData();
renderTimeline();
return true;
}
function likePost(id) {
const idx = posts.findIndex(p => p.id === id);
if (idx >= 0) {
posts[idx].likes++;
saveData();
renderTimeline();
}
}
function deletePost(id) {
if (!confirm('この投稿を削除しますか?')) return;
posts = posts.filter(p => p.id !== id);
saveData();
renderTimeline();
}
function clearAllPosts() {
if (!confirm('全ての投稿を削除しますか?')) return;
posts = [];
saveData();
renderTimeline();
}
function exportData() {
const blob = new Blob([JSON.stringify(posts, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'verse_posts.json';
a.click();
URL.revokeObjectURL(url);
}
// ===== タイムライン描画(JSX混入の修正・無害化) =====
function escapeHtml(s) {
return (s || '').replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"','\'':'''}[m]));
}
function renderTimeline() {
const tl = document.getElementById('timeline');
const emp = document.getElementById('empty-timeline');
const cnt = document.getElementById('post-count');
if (!tl || !emp || !cnt) return;
const allPosts = posts;
if (allPosts.length === 0) {
tl.innerHTML = '';
emp.style.display = 'block';
cnt.textContent = '(0件の投稿)';
return;
}
emp.style.display = 'none';
cnt.textContent = `(${allPosts.length}件の投稿)`;
tl.innerHTML = allPosts.map(p => {
const info = { bot: '🤖 BOT', markov: '🎲 MarkovBOT', user: '👤 ユーザー', feed: '📰 FEEDBOT' }[p.type] || '👤';
const main = p.link
? `<a href="${p.link}" target="_blank" class="text-blue-600 underline">${escapeHtml(p.content)}</a>`
: `${escapeHtml(p.content)}`;
return `
<div class="timeline-post p-6">
<div class="flex justify-between items-start mb-4">
<div class="flex items-center space-x-3">
<img src="${p.icon}" class="w-10 h-10 rounded-full object-cover" onerror="this.src='https://via.placeholder.com/40'">
<div>
<div class="flex items-center">
<span class="font-semibold text-gray-800 dark:text-white">${escapeHtml(p.username)}</span>
<span class="username-badge">${info}</span>
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">${p.timestamp}</div>
</div>
</div>
</div>
<div class="text-gray-800 dark:text-gray-200 mb-4 leading-relaxed">${main}</div>
<div class="flex items-center space-x-4 pt-4 border-t border-gray-100 dark:border-gray-600">
<button onclick="likePost(${p.id})" class="flex items-center space-x-2 text-gray-600 dark:text-gray-400 hover:text-red-500">
<i class="fas fa-heart"></i><span>${p.likes}</span>
</button>
<div class="relative">
<button onclick="toggleShareMenu(${p.id})" class="flex items-center space-x-2 text-gray-600 dark:text-gray-400 hover:text-blue-500">
<i class="fas fa-share"></i><span>シェア</span>
</button>
<div id="share-menu-${p.id}" class="share-menu hidden">
<button onclick="shareToX(${p.id})"><i class="fab fa-x-twitter text-blue-400 mr-2"></i>Xでシェア</button>
<button onclick="shareToLine(${p.id})"><i class="fab fa-line text-green-400 mr-2"></i>LINEでシェア</button>
<button onclick="copyPost(${p.id})"><i class="fas fa-copy mr-2"></i>コピー</button>
</div>
</div>
<button onclick="deletePost(${p.id})" class="flex items-center space-x-2 text-gray-600 dark:text-gray-400 hover:text-red-500 ml-auto">
<i class="fas fa-trash"></i><span>削除</span>
</button>
</div>
</div>
`;
}).join('');
}
function getPostContentText(id) {
const p = posts.find(x => x.id === id);
if (!p) return '';
const tmp = document.createElement('div');
tmp.innerHTML = p.content;
return tmp.textContent || tmp.innerText || '';
}
function toggleShareMenu(id) {
document.querySelectorAll('[id^="share-menu-"]')
.forEach(el => el.classList.add('hidden'));
const m = document.getElementById('share-menu-' + id);
if (m) m.classList.toggle('hidden');
}
function shareToX(id) {
const t = encodeURIComponent(getPostContentText(id));
const u = encodeURIComponent(location.href);
window.open(`https://twitter.com/intent/tweet?text=${t}&url=${u}`, '_blank');
}
function shareToLine(id) {
const u = encodeURIComponent(location.href);
window.open(`https://social-plugins.line.me/lineit/share?url=${u}`, '_blank');
}
function copyPost(id) {
const t = getPostContentText(id);
if (navigator.clipboard) {
navigator.clipboard.writeText(t).then(() => alert('コピーしました')).catch(() => fallbackCopy(t));
} else fallbackCopy(t);
}
function fallbackCopy(t) {
const ta = document.createElement('textarea');
ta.value = t; document.body.appendChild(ta);
ta.select(); document.execCommand('copy');
document.body.removeChild(ta);
alert('コピーしました');
}
// ===== RSS UI(個別ON/OFF + 一括ON/OFF) =====
function updateRssUI() {
const listDiv = document.getElementById('rss-list');
if (!listDiv) return;
if (!sharedRssFeeds || sharedRssFeeds.length === 0) {
listDiv.innerHTML = '<div class="text-white text-xs opacity-80">RSSフィード未登録</div>';
} else {
listDiv.innerHTML = sharedRssFeeds.map((url, i) => {
const enabled = sharedRssEnabled[url] !== false; // 既定ON
const enc = encodeURIComponent(url);
return `
<div class="flex items-center space-x-2 bg-white bg-opacity-90 rounded px-2 py-2 mb-1">
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="toggleRssEnabled('${enc}', this.checked)" title="ON/OFF">
<div class="truncate flex-1 text-xs" title="${escapeHtml(url)}">${escapeHtml(url)}</div>
<button onclick="delRssFeed(${i})" class="text-red-500 hover:text-red-700" title="削除"><i class="fas fa-trash"></i></button>
</div>
`;
}).join('');
}
const iv = document.getElementById('rss-interval');
if (iv) iv.value = sharedRssInterval || 300;
}
function toggleRssEnabled(encUrl, on) {
const url = decodeURIComponent(encUrl);
sharedRssEnabled[url] = !!on;
saveSharedRss();
addLog('rss-log', `FEED ${on ? 'ON' : 'OFF'}: ${url}`, 'info');
}
function setAllRssEnabled(on) {
(sharedRssFeeds || []).forEach(u => sharedRssEnabled[u] = !!on);
saveSharedRss();
updateRssUI();
addLog('rss-log', `全フィードを${on ? 'ON' : 'OFF'}にしました`, 'success');
}
function saveSharedRss() {
localStorage.setItem('verse_shared_rssFeeds', JSON.stringify(sharedRssFeeds));
localStorage.setItem('verse_shared_rssInterval', String(sharedRssInterval));
localStorage.setItem('verse_shared_rssLastIds', JSON.stringify(sharedRssLastIds));
localStorage.setItem('verse_shared_rssEnabled', JSON.stringify(sharedRssEnabled));
}
function addRssFeed() {
const url = document.getElementById('rss-url').value.trim();
if (!/^https?:\/\/.+/.test(url)) { addLog('rss-log', '正しいRSSフィードURLを入力してください', 'error'); return; }
if (!sharedRssFeeds) sharedRssFeeds = [];
if (sharedRssFeeds.includes(url)) { addLog('rss-log', 'すでに登録済みです', 'error'); return; }
sharedRssFeeds.push(url);
sharedRssEnabled[url] = true; // 既定ON
saveSharedRss();
updateRssUI();
addLog('rss-log', `RSS追加: ${url}`, 'success');
document.getElementById('rss-url').value = '';
}
function delRssFeed(i) {
if (!sharedRssFeeds[i]) return;
if (!confirm('このフィードを削除しますか?')) return;
const url = sharedRssFeeds[i];
sharedRssFeeds.splice(i, 1);
delete sharedRssEnabled[url];
delete sharedRssLastIds[url];
saveSharedRss();
updateRssUI();
addLog('rss-log', 'RSS削除', 'info');
}
function setRssInterval() {
const iv = document.getElementById('rss-interval').valueAsNumber || 300;
if (iv < 10) return alert('間隔は10秒以上で設定してください。');
sharedRssInterval = iv;
saveSharedRss();
updateRssUI();
startRssAuto();
addLog('rss-log', `自動投稿間隔を${iv}秒に設定`, 'success');
}
function fetchRssNow() { fetchRssFeeds(); }
// ===== RSS取得(OFFのフィードはスキップ) =====
function fetchRssFeeds() {
if (!sharedRssFeeds || sharedRssFeeds.length === 0) return;
sharedRssFeeds.forEach(feedUrl => {
if (sharedRssEnabled[feedUrl] === false) {
addLog('rss-log', `OFFのため取得スキップ: ${feedUrl}`, 'info');
return;
}
fetch('https://api.rss2json.com/v1/api.json?rss_url=' + encodeURIComponent(feedUrl))
.then(resp => resp.json())
.then(data => {
if (!data.items || !data.items.length) return;
let lastId = sharedRssLastIds[feedUrl] || '';
let newItems = [];
for (const item of data.items) {
const guid = item.guid || item.link || item.pubDate || item.title;
if (!lastId || String(guid) > String(lastId)) newItems.push(item);
}
if (newItems.length === 0) return;
newItems.reverse().forEach(item => {
const guid = item.guid || item.link || item.pubDate || item.title;
// FEED本文は省略(タイトル+リンクのみポスト)
if (!posts.some(p => p.type === 'feed' && p.link === item.link)) {
createPost(item.title, 'feed', 'FEEDBOT', 'https://cdn-icons-png.flaticon.com/512/3416/3416046.png', { link: item.link });
addLog('rss-log', `新しい記事: ${item.title}`, 'success');
}
sharedRssLastIds[feedUrl] = guid;
});
saveSharedRss();
})
.catch(() => addLog('rss-log', 'RSS取得エラー: ' + feedUrl, 'error'));
});
}
function startRssAuto() {
stopRssAuto();
fetchRssFeeds();
rssInterval = setInterval(fetchRssFeeds, (sharedRssInterval || 300) * 1000);
updateStatusIndicators();
addLog('rss-log', `RSS自動投稿を開始 (${sharedRssInterval}秒間隔)`, 'success');
}
function stopRssAuto() {
if (rssInterval) clearInterval(rssInterval);
rssInterval = null;
updateStatusIndicators();
addLog('rss-log', 'RSS自動投稿を停止しました', 'info');
}
// ===== BOT =====
function postBotMessage() {
const ta = document.getElementById('botContent');
const txt = ta.value.trim();
if (!txt) return alert('BOT投稿内容を入力してください。');
if (!currentUser) return alert('ログインが必要です。');
if (createPost(txt, 'bot', 'BOT', profile.icon)) {
ta.value = '';
addLog('bot-log', `BOT投稿: "${txt.substring(0, 30)}..."`, 'success');
}
}
function generateMarkovText() {
// FEED(外部記事)は学習対象から除外し、ユーザーとBOT投稿のみ学習
let text = posts
.filter(p => ['user','bot'].includes(p.type))
.map(p => {
const d = document.createElement('div');
d.innerHTML = p.content;
return (d.textContent || d.innerText || '')
.replace(/\s+/g, ' ').replace(/https?:\/\/\S+/g, '').trim();
}).join(' ');
if (text.length < 20) {
const fallbacks = ["今日はいい天気ですね!","最近面白いニュースありましたか?","新しいアイデアが浮かんできました。","皆さんはどう思いますか?"];
return fallbacks[Math.floor(Math.random() * fallbacks.length)];
}
const tokens = text.match(/[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\w]+|[。、!?\r\n]/g) || [];
if (tokens.length < 2) return tokens.join('');
const markov = {};
for (let i = 0; i < tokens.length - 2; i++) {
const key = tokens[i] + '|' + tokens[i+1];
if (!markov[key]) markov[key] = [];
markov[key].push(tokens[i+2]);
}
let idx = Math.floor(Math.random() * (tokens.length - 2));
let key = tokens[idx] + '|' + tokens[idx+1];
let result = [tokens[idx], tokens[idx+1]];
let maxLen = 60 + Math.floor(Math.random() * 40);
for (let i = 0; i < maxLen; i++) {
const nexts = markov[key];
if (!nexts || nexts.length === 0) break;
const next = nexts[Math.floor(Math.random() * nexts.length)];
result.push(next);
if (/[。!?\n]/.test(next)) break;
key = result[result.length - 2] + '|' + result[result.length - 1];
}
return result.join('').replace(/\n/g, '');
}
function postMarkovBot() {
const txt = generateMarkovText();
if (createPost(txt, 'markov', 'MarkovBOT', profile.icon)) {
addLog('bot-log', `マルコフ投稿: "${txt.substring(0, 40)}..."`, 'success');
}
}
function startBotAutoPost() {
const iv = document.getElementById('botIntervalSec').valueAsNumber || 60;
if (iv < 10) { alert('間隔は10秒以上で設定してください。'); return; }
stopBotAutoPost();
setTimeout(postMarkovBot, 3000);
botInterval = setInterval(postMarkovBot, iv * 1000);
updateStatusIndicators();
addLog('bot-log', `マルコフBOT自動投稿開始 (${iv}秒間隔)`, 'success');
}
function stopBotAutoPost() {
if (botInterval) {
clearInterval(botInterval);
botInterval = null;
updateStatusIndicators();
addLog('bot-log', 'マルコフBOT自動投稿を停止しました', 'info');
}
}
// ===== 共通UI/保存 =====
function addLog(id, msg, type = 'info') {
const el = document.getElementById(id);
const ts = new Date().toLocaleTimeString('ja-JP');
const div = document.createElement('div');
const cls = { error: 'error-message', success: 'success-message', info: 'text-white opacity-90' }[type] || 'text-white opacity-90';
div.className = cls;
div.innerHTML = `<span class="opacity-75">[${ts}]</span> ${escapeHtml(msg)}`;
if (el) {
el.appendChild(div);
el.scrollTop = el.scrollHeight;
while (el.children.length > 100) el.removeChild(el.firstChild);
}
try { console.log(`[${ts}] ${msg}`); } catch(_) {}
}
function updateStatusIndicators() {
const botI = document.getElementById('bot-status');
const botT = document.getElementById('bot-status-text');
if (botI && botT) {
const active = botInterval !== null;
botI.className = `status-indicator ${active ? 'status-active' : 'status-inactive'}`;
botT.textContent = active ? '動作中' : '停止中';
}
const rssI = document.getElementById('rss-status');
const rssT = document.getElementById('rss-status-text');
if (rssI && rssT) {
const active = rssInterval !== null;
rssI.className = `status-indicator ${active ? 'status-active' : 'status-inactive'}`;
rssT.textContent = active ? '動作中' : '停止中';
}
}
function showSystemStatus() {
alert(
`=== Verse システムステータス ===\n全体投稿数: ${posts.length}\nRSS登録数: ${sharedRssFeeds.length}\nBOT投稿数: ${posts.filter(p => ['bot', 'markov'].includes(p.type)).length}\n\nBOT自動投稿: ${botInterval ? '動作中' : '停止中'}\nRSS自動投稿: ${rssInterval ? '動作中' : '停止中'}`
);
}
function uploadProfileIcon(e) {
const f = e.target.files[0];
if (!f) return;
if (f.size > 5 * 1024 * 1024) { alert('5MB以下にしてください。'); return; }
const r = new FileReader();
r.onload = () => {
profile.icon = r.result;
saveProfileNoAlert();
updateAllUI();
alert('プロフィール画像更新!');
};
r.readAsDataURL(f);
}
function saveProfile() {
const un = document.getElementById('username').value.trim();
const si = document.getElementById('self-intro').value.trim();
if (un.length > 20) { alert('ユーザー名は20文字以内で。'); return; }
profile.username = un || 'ゲストユーザー';
profile.selfIntro = si;
saveProfileNoAlert();
updateAllUI();
alert('プロフィール保存!');
}
function saveProfileNoAlert() {
users = JSON.parse(localStorage.getItem('verse_users') || '[]');
const idx = users.findIndex(u => u.email === (currentUser && currentUser.email));
if (idx >= 0) {
users[idx].profile = profile;
localStorage.setItem('verse_users', JSON.stringify(users));
}
}
function updateAllUI() {
const pi = document.getElementById('profile-icon');
const hi = document.getElementById('header-profile-icon');
if (pi) pi.src = profile.icon;
if (hi) hi.src = profile.icon;
['username-preview','header-username'].forEach(id => {
const el = document.getElementById(id);
if (el) el.textContent = profile.username;
});
const sip = document.getElementById('self-intro-preview');
if (sip) sip.textContent = profile.selfIntro || 'まだ自己紹介がありません';
const emailEl = document.getElementById('header-user-email');
if (emailEl && currentUser) emailEl.textContent = currentUser.email;
renderTimeline();
}
function updateCharCount() {
const pc = document.getElementById('postContent');
const cc = document.getElementById('char-count');
if (pc && cc) {
const l = pc.value.length;
cc.textContent = `(${l}/500)`;
cc.style.color = l > 450 ? '#ef4444' : '';
}
}
function toggleDarkMode() {
isDarkMode = !isDarkMode;
if (isDarkMode) {
document.body.classList.add('dark');
document.body.style.background = 'linear-gradient(135deg, #1a202c 0%, #2d3748 100%)';
} else {
document.body.classList.remove('dark');
document.body.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
}
localStorage.setItem('verse_darkMode', isDarkMode.toString());
}
function saveData() { localStorage.setItem('verse_posts', JSON.stringify(posts)); }
// 入力UIフック
document.addEventListener('DOMContentLoaded', () => {
const pc = document.getElementById('postContent');
if (pc) {
pc.addEventListener('input', updateCharCount);
pc.addEventListener('keydown', e => { if (e.key === 'Enter' && e.ctrlKey) { e.preventDefault(); createUserPost(); } });
}
const ui = document.getElementById('username');
if (ui) ui.addEventListener('input', () => {
const v = ui.value.trim() || 'ゲストユーザー';
['username-preview','header-username'].forEach(id => {
const el = document.getElementById(id);
if (el) el.textContent = v;
});
});
const si = document.getElementById('self-intro');
if (si) si.addEventListener('input', () => {
const v = si.value.trim() || 'まだ自己紹介がありません';
const el = document.getElementById('self-intro-preview');
if (el) el.textContent = v;
});
});
// 終了処理・エラー
window.addEventListener('beforeunload', () => { stopBotAutoPost(); stopRssAuto(); saveData(); });
window.addEventListener('error', e => { addLog('bot-log', `システムエラー: ${e.message}`, 'error'); });
</script>
</body>
</html>
GPT-5
GPT‑5(正式には次世代ChatGPTとしてリリース予定)は、無料プランでも利用可能になる予定で、ユーザーはいわゆる標準的なインテリジェンス設定で無制限にチャット利用できるとOpenAI CEOのサム・アルトマン氏が公式に発表しています。 Plus や Pro の有料プランでは、さらに高度な知能レベルや追加機能にアクセスできるようになります note(ノート)+7note(ノート)+7MiraLab.inc+7。
🎯 どこで無料で使えるか?
- ChatGPT(OpenAI公式ウェブ版/アプリ)
無料アカウントでも GPT‑5(標準モード)を利用可能。乱用防止のための使用制限(例:1日の回数上限など)は設けられる可能性があるが、無制限チャットが基本です Reddit。 - Microsoft Copilot
Windows PCに統合された Copilot は GPT‑5 に対応する「Smart モード」を導入予定で、プラットフォームそのもので GPT‑5 を無料で利用できるようになると見られます(ただし、精度やレスポンスの仕様は流動的)note(ノート)+4note(ノート)+4accelc.co.jp+4。
📋 まとめ
| サービス | GPT‑5 利用状況(無料プラン) |
|---|---|
| ChatGPT(OpenAI公式) | ✅ 無料で標準インテリジェンス設定において無制限利用可能(ただし乱用対策あり) |
| Microsoft Copilot(Windows統合) | ✅ Smart モードで GPT‑5 を採用、無料での使用が期待される |
| その他のAIサービス | 現時点では GPT‑5 の対応情報は限定的 |
ℹ️ 注意点と補足
- GPT‑5 は現在、2025年8月ごろの正式リリースが予定されていますThe Verge。したがって、ChatGPT の無料版でも GPT‑5 を使い始めるのはリリース後になります。
- アルトマン氏は「地球上のすべての人へのGPT‑5の無料提供」を理想として語っていますが、これはまだ公式に実現しているわけではなく、長期的な構想です chatgpt5free.com+15note(ノート)+15shift-ai.co.jp+15。
- 無料プランでは乱用防止や公平性維持のために、トークン制限・アクセス制限などの利用制約が設けられる可能性があります。また、Plus/Pro プランでは優先アクセスや高度な機能が得られます MiraLab.inc+1note(ノート)+1。
✅ ご参考まで
- 使い始めるには?
OpenAI公式サイトまたはモバイルアプリから ChatGPT にサインアップ・ログインすれば、無料プランで GPT‑5(標準モード)が利用可能になります(リリース後)。 - Microsoft Copilot を使うなら?
Windows PC に標準搭載されている Copilot を通じて、GPT‑5 Smart モードで利用できるようになる予定です。
まとめると、GPT‑5 はリリース後に ChatGPT の無料プランで使えます(標準モードで無制限利用、ただし制限あり)。さらに、Microsoft Copilot ユーザーも同時に利用できる可能性があるとの見通しです。
