<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MailLite — シンプルWebメール</title>
<style>
:root{
--bg:#f6f7fb;
--panel:#ffffff;
--text:#1f2937;
--muted:#6b7280;
--primary:#4f46e5;
--primary-weak:#eef2ff;
--border:#e5e7eb;
--danger:#ef4444;
--success:#10b981;
--warning:#f59e0b;
}
.dark{
--bg:#0b0e15;
--panel:#0f1623;
--text:#e5e7eb;
--muted:#9ca3af;
--primary:#8b5cf6;
--primary-weak:#221a36;
--border:#1f2937;
--danger:#f87171;
--success:#34d399;
--warning:#fbbf24;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;background:var(--bg);color:var(--text);
font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,"Noto Sans JP",sans-serif;
}
.app{
display:grid;grid-template-rows:56px 1fr;height:100%;
}
/* Topbar */
.topbar{
display:flex;align-items:center;gap:12px;
padding:0 16px;border-bottom:1px solid var(--border);background:var(--panel);
position:sticky;top:0;z-index:5;
}
.logo{
display:flex;align-items:center;gap:10px;font-weight:700;
letter-spacing:.2px;
}
.badge{font-size:10px;padding:2px 6px;border-radius:999px;background:var(--primary-weak);color:var(--primary)}
.search{
margin-left:auto;display:flex;align-items:center;gap:8px;background:var(--bg);
padding:6px 10px;border-radius:10px;border:1px solid var(--border);min-width:220px;max-width:460px;flex:1;
}
.search input{border:none;background:transparent;outline:none;color:var(--text);width:100%}
.icon{width:18px;height:18px;display:inline-block;flex:0 0 18px}
.btn{
display:inline-flex;align-items:center;gap:8px;padding:8px 12px;border-radius:10px;
border:1px solid var(--border);background:var(--panel);cursor:pointer;color:var(--text);
}
.btn.primary{background:var(--primary);border-color:var(--primary);color:#fff}
.btn.ghost{background:transparent}
.btn:disabled{opacity:.6;cursor:not-allowed}
/* Layout */
.layout{
display:grid;grid-template-columns:260px 360px 1fr;gap:12px;padding:12px;height:calc(100vh - 56px);
}
.panel{background:var(--panel);border:1px solid var(--border);border-radius:14px;overflow:hidden;display:flex;flex-direction:column;min-height:0}
.sidebar{padding:12px}
.compose-block{padding:12px}
.compose-button{width:100%;justify-content:center}
.nav-group{margin-top:8px}
.nav-item{
display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:10px;color:var(--text);text-decoration:none;cursor:pointer;
}
.nav-item:hover{background:var(--primary-weak)}
.nav-item.active{background:var(--primary);color:#fff}
.nav-item .count{margin-left:auto;opacity:.8}
/* List */
.list-toolbar{display:flex;align-items:center;gap:8px;padding:8px;border-bottom:1px solid var(--border)}
.list{overflow:auto}
.msg{
display:grid;grid-template-columns:24px 1fr auto;gap:10px;padding:12px;border-bottom:1px solid var(--border);cursor:pointer;
}
.msg:hover{background:var(--primary-weak)}
.msg.unread{background:linear-gradient(0deg,transparent,transparent), var(--panel);font-weight:600}
.msg .from{color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.msg .subject{color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.msg .snippet{color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.msg .meta{display:flex;flex-direction:column;align-items:end;gap:4px;color:var(--muted)}
.chip{display:inline-flex;align-items:center;gap:6px;padding:2px 8px;border-radius:999px;border:1px solid var(--border);font-size:11px}
.star{cursor:pointer;opacity:.7}
.star.active{opacity:1}
.avatar{
width:24px;height:24px;border-radius:50%;background:linear-gradient(135deg,var(--primary),#22c1c3);
display:grid;place-items:center;color:#fff;font-size:12px;font-weight:700;
}
/* Reader */
.reader-head{
padding:12px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;flex-wrap:wrap;
}
.reader-title{font-size:18px;font-weight:700}
.reader-meta{color:var(--muted);font-size:12px}
.reader-actions{margin-left:auto;display:flex;gap:8px}
.reader-body{padding:16px;overflow:auto}
.empty{display:grid;place-items:center;height:100%;color:var(--muted)}
/* Modal */
.modal{
position:fixed;inset:0;background:rgba(0,0,0,.4);display:none;align-items:center;justify-content:center;z-index:20;
}
.modal.open{display:flex}
.modal-card{
width:min(920px,94vw);max-height:88vh;overflow:auto;background:var(--panel);border:1px solid var(--border);
border-radius:16px;
}
.modal-head{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border)}
.modal-body{padding:16px;display:grid;gap:10px}
.field{display:grid;gap:6px}
.input, textarea{
width:100%;padding:10px 12px;border-radius:10px;border:1px solid var(--border);
background:transparent;color:var(--text);outline:none;
}
textarea{min-height:220px;resize:vertical}
.row{display:flex;gap:10px;flex-wrap:wrap}
.grow{flex:1}
/* Responsive */
@media (max-width: 1100px){
.layout{grid-template-columns:220px 1fr}
.reader{display:none}
.layout.show-reader .list{display:none}
.layout.show-reader .reader{display:flex}
}
@media (max-width: 640px){
.layout{grid-template-columns:1fr}
.sidebar{display:none}
}
</style>
</head>
<body>
<div class="app">
<!-- Topbar -->
<div class="topbar">
<div class="logo">
<svg class="icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M3 7.5 12 13l9-5.5v9A2.5 2.5 0 0 1 18.5 19h-13A2.5 2.5 0 0 1 3 16.5v-9Z" stroke="currentColor" stroke-width="1.5"/>
<path d="m3 7.5 9-5 9 5" stroke="currentColor" stroke-width="1.5"/>
</svg>
MailLite <span class="badge">beta</span>
</div>
<div class="search">
<svg class="icon" viewBox="0 0 24 24" fill="none"><path d="m21 21-4.2-4.2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="1.5"/></svg>
<input id="search" placeholder="メールを検索(差出人・件名・本文・ラベル)" />
<button class="btn ghost" id="clearSearch" title="検索クリア">クリア</button>
</div>
<button class="btn" id="toggleDark" title="ダークモード">
<svg class="icon" viewBox="0 0 24 24" fill="none"><path d="M12 3a9 9 0 1 0 9 9 7 7 0 0 1-9-9Z" stroke="currentColor" stroke-width="1.5"/></svg>
主题
</button>
<button class="btn primary" id="composeBtn">
<svg class="icon" viewBox="0 0 24 24" fill="none"><path d="M4 20h16M6 14l9.5-9.5a2.1 2.1 0 1 1 3 3L9 17l-5 1 2-4Z" stroke="#fff" stroke-width="1.5" stroke-linejoin="round"/></svg>
新規作成
</button>
</div>
<!-- Main layout -->
<div class="layout" id="layout">
<!-- Sidebar -->
<aside class="panel sidebar">
<div class="compose-block">
<button class="btn primary compose-button" id="composeBtn2">
<svg class="icon" viewBox="0 0 24 24" fill="none"><path d="M4 20h16M6 14l9.5-9.5a2.1 2.1 0 1 1 3 3L9 17l-5 1 2-4Z" stroke="#fff" stroke-width="1.5" stroke-linejoin="round"/></svg>
新規メール
</button>
</div>
<nav class="nav-group" id="folders"></nav>
</aside>
<!-- List -->
<section class="panel">
<div class="list-toolbar">
<button class="btn" id="markReadBtn" title="既読にする">既読</button>
<button class="btn" id="markUnreadBtn" title="未読にする">未読</button>
<button class="btn" id="archiveBtn" title="アーカイブ">アーカイブ</button>
<button class="btn" id="deleteBtn" title="削除">削除</button>
<div style="margin-left:auto;display:flex;align-items:center;gap:6px;">
<span class="chip"><span class="dot" style="width:8px;height:8px;border-radius:50%;background:var(--primary)"></span> ラベル</span>
</div>
</div>
<div class="list" id="list"></div>
</section>
<!-- Reader -->
<section class="panel reader" id="reader">
<div class="empty" id="emptyState">メールを選択してください</div>
<div style="display:none;flex-direction:column;height:100%;" id="readerWrap">
<div class="reader-head">
<div style="display:flex;align-items:center;gap:10px;min-width:0">
<div class="avatar" id="readerAvatar">Y</div>
<div style="min-width:0">
<div class="reader-title" id="readerSubject">件名</div>
<div class="reader-meta" id="readerMeta">From – To ・ 日付</div>
</div>
</div>
<div class="reader-actions">
<button class="btn" id="replyBtn" title="返信">返信</button>
<button class="btn" id="starBtn" title="スター">
<span id="starIcon">☆</span> スター
</button>
<button class="btn" id="archBtn" title="アーカイブ">アーカイブ</button>
<button class="btn" id="trashBtn" title="削除" style="color:var(--danger)">削除</button>
</div>
</div>
<div class="reader-body">
<div id="readerBody"></div>
</div>
</div>
</section>
</div>
</div>
<!-- Compose Modal -->
<div class="modal" id="composeModal" aria-hidden="true">
<div class="modal-card">
<div class="modal-head">
<strong>新規メッセージ</strong>
<div class="row">
<button class="btn" id="saveDraftBtn">下書き保存</button>
<button class="btn primary" id="sendBtn">送信</button>
<button class="btn" id="closeModalBtn">閉じる</button>
</div>
</div>
<div class="modal-body">
<div class="row">
<div class="field grow">
<label for="to">宛先(カンマ区切り)</label>
<input class="input" id="to" placeholder="example@example.com, someone@domain.jp" />
</div>
<div class="field" style="min-width:160px;">
<label for="label">ラベル</label>
<input class="input" id="label" placeholder="work, personal など" />
</div>
</div>
<div class="field">
<label for="subject">件名</label>
<input class="input" id="subject" placeholder="件名を入力" />
</div>
<div class="field">
<label for="body">本文</label>
<textarea id="body" placeholder="本文を入力"></textarea>
</div>
</div>
</div>
</div>
<script>
/** ======= Simple Mail App (no backend) ======= */
const DB_KEY = "maillite-db-v1";
const QS = sel => document.querySelector(sel);
const QSA = sel => [...document.querySelectorAll(sel)];
const state = {
currentFolder: "inbox",
query: "",
selectedIds: new Set(),
currentId: null,
db: { messages: [] }
};
const FOLDERS = [
{id:"inbox", name:"受信箱", icon:"📥"},
{id:"starred", name:"スター", icon:"⭐"},
{id:"sent", name:"送信済み", icon:"📤"},
{id:"drafts", name:"下書き", icon:"📝"},
{id:"archive", name:"アーカイブ",icon:"🗄️"},
{id:"spam", name:"迷惑", icon:"🚫"},
{id:"trash", name:"ゴミ箱", icon:"🗑️"},
];
function initDB(){
const saved = localStorage.getItem(DB_KEY);
if(saved){
state.db = JSON.parse(saved);
return;
}
// Seed sample messages
const now = Date.now();
const demo = [
mkMsg("suzuki@example.com","ようこそ MailLite へ","MailLite をお試しいただきありがとうございます!\n\nこのメールはデモです。",["welcome"], now-3600_000),
mkMsg("shop@ec.example.com","【お知らせ】サマーセール開催!","最大 50%OFF。今すぐチェック!",["promo"], now-7200_000),
mkMsg("boss@company.jp","明日の打合せ議題","・リリース計画\n・障害対応\n・コスト見直し",["work"], now-86400_000, true),
mkMsg("friend@chat.jp","週末の予定どう?","映画かカラオケ行かない?",["personal"], now-5400_000),
mkMsg("security@service.jp","ログイン通知","新しい端末からログインがありました。",["security"], now-9600_000),
];
demo[2].unread = false;
state.db.messages = demo;
persist();
}
function mkMsg(from, subject, body, labels=[], time=Date.now(), starred=false){
const id = crypto.randomUUID();
const initials = (from.split("@")[0][0]||"U").toUpperCase();
return {
id, box:"inbox", from, to:[], subject, body, labels, starred, unread:true,
date: time, initials
};
}
function persist(){ localStorage.setItem(DB_KEY, JSON.stringify(state.db)); }
function formatDate(ts){
const d = new Date(ts);
const pad = n => String(n).padStart(2,"0");
return `${d.getFullYear()}/${pad(d.getMonth()+1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
// Render folders
function renderFolders(){
const el = QS("#folders");
el.innerHTML = "";
for(const f of FOLDERS){
const count = countFolder(f.id);
const a = document.createElement("a");
a.className = "nav-item" + (state.currentFolder===f.id?" active":"");
a.dataset.id = f.id;
a.innerHTML = `<span>${f.icon}</span><span>${f.name}</span><span class="count">${count||""}</span>`;
a.addEventListener("click", ()=>{
state.currentFolder = f.id;
state.selectedIds.clear();
state.currentId = null;
render();
});
el.appendChild(a);
}
}
function countFolder(folder){
return filterByFolder(state.db.messages, folder).length;
}
function filterByFolder(list, folder){
switch(folder){
case "inbox": return list.filter(m=>m.box==="inbox");
case "starred": return list.filter(m=>m.starred && m.box!=="trash");
case "sent": return list.filter(m=>m.box==="sent");
case "drafts": return list.filter(m=>m.box==="drafts");
case "archive": return list.filter(m=>m.box==="archive");
case "spam": return list.filter(m=>m.box==="spam");
case "trash": return list.filter(m=>m.box==="trash");
default: return list;
}
}
// Search
function matchesQuery(m,q){
if(!q) return true;
const s = q.toLowerCase();
const hay = [
m.from, (m.to||[]).join(","), m.subject, m.body, (m.labels||[]).join(",")
].join("\n").toLowerCase();
return hay.includes(s);
}
// Render list
function renderList(){
const wrap = QS("#list");
const items = filterByFolder(state.db.messages, state.currentFolder)
.filter(m=>matchesQuery(m, state.query))
.sort((a,b)=>b.date-a.date);
wrap.innerHTML = "";
if(items.length===0){
wrap.innerHTML = `<div class="empty" style="height:100%;">${state.query? "検索結果がありません":"このフォルダは空です"}</div>`;
return;
}
for(const m of items){
const row = document.createElement("div");
row.className = "msg" + (m.unread?" unread":"");
row.dataset.id = m.id;
const starClass = m.starred? "active":"";
row.innerHTML = `
<div class="avatar" title="${m.from}">${m.initials}</div>
<div style="min-width:0">
<div class="from">${m.from}</div>
<div class="subject">${m.subject}</div>
<div class="snippet">${(m.labels?.length? m.labels.map(l=>"#"+l).join(" ")+" · ":"")}${m.body.replace(/\n/g," ").slice(0,120)}</div>
</div>
<div class="meta">
<div>${formatDate(m.date)}</div>
<div class="star ${starClass}" data-star-id="${m.id}" title="スター">${m.starred?"★":"☆"}</div>
</div>
`;
row.addEventListener("click", (e)=>{
// If star clicked, don't open
if(e.target && e.target.dataset.starId){ return; }
openMessage(m.id);
});
row.querySelector(".star").addEventListener("click",(e)=>{
e.stopPropagation();
toggleStar(m.id);
});
wrap.appendChild(row);
}
}
// Reader
function openMessage(id){
const m = state.db.messages.find(x=>x.id===id);
if(!m) return;
state.currentId = id;
m.unread = false;
persist();
QS("#emptyState").style.display = "none";
QS("#readerWrap").style.display = "flex";
QS("#readerSubject").textContent = m.subject || "(件名なし)";
QS("#readerMeta").textContent = `From: ${m.from} / To: ${(m.to||[]).join(", ")||"(なし)"} ・ ${formatDate(m.date)}`;
QS("#readerBody").innerHTML = safeHtml(m.body).replace(/\n/g,"<br>");
QS("#readerAvatar").textContent = m.initials || "U";
QS("#starIcon").textContent = m.starred ? "★" : "☆";
// Mobile: show reader
QS("#layout").classList.add("show-reader");
render();
}
function safeHtml(s=""){
return s.replace(/[&<>"']/g, ch => ({
"&":"&","<":"<",">":">",'"':""","'":"'"
}[ch]));
}
// Actions
function getSelectedOrCurrentIds(){
if(state.selectedIds.size>0) return [...state.selectedIds];
if(state.currentId) return [state.currentId];
return [];
}
function markRead(read=true){
for(const id of getSelectedOrCurrentIds()){
const m = state.db.messages.find(x=>x.id===id);
if(m) m.unread = !read;
}
persist(); render();
}
function moveTo(box){
for(const id of getSelectedOrCurrentIds()){
const m = state.db.messages.find(x=>x.id===id);
if(m) m.box = box;
}
// If current message was moved out of view, clear reader
if(state.currentId){
const cm = state.db.messages.find(x=>x.id===state.currentId);
const visible = filterByFolder([cm], state.currentFolder).length>0;
if(!visible){ state.currentId = null; QS("#readerWrap").style.display="none"; QS("#emptyState").style.display="grid"; }
}
persist(); render();
}
function toggleStar(id){
const m = state.db.messages.find(x=>x.id===id);
if(m){ m.starred = !m.starred; persist(); render(); if(state.currentId===id) QS("#starIcon").textContent=m.starred?"★":"☆"; }
}
// Compose
function openCompose(prefill={}){
QS("#composeModal").classList.add("open");
QS("#to").value = prefill.to?.join(", ") || "";
QS("#subject").value = prefill.subject || "";
QS("#body").value = prefill.body || "";
QS("#label").value = (prefill.labels||[]).join(", ");
}
function closeCompose(){ QS("#composeModal").classList.remove("open"); }
function saveDraft(){
const draft = collectForm();
draft.box = "drafts";
draft.unread = false;
draft.from = "me@local";
draft.id = crypto.randomUUID();
state.db.messages.push(draft);
persist(); closeCompose(); render();
alert("下書きを保存しました。");
}
function sendMail(){
const msg = collectForm();
if(!msg.to.length){ alert("宛先を入力してください"); return; }
msg.box = "sent";
msg.unread = false;
msg.from = "me@local";
msg.id = crypto.randomUUID();
state.db.messages.push(msg);
persist(); closeCompose(); render();
alert("送信しました(デモ動作:実送信なし)");
}
function collectForm(){
const to = QS("#to").value.split(",").map(s=>s.trim()).filter(Boolean);
const subject = QS("#subject").value.trim() || "(件名なし)";
const body = QS("#body").value;
const labels = QS("#label").value.split(",").map(s=>s.trim()).filter(Boolean);
return { to, subject, body, labels, date: Date.now(), starred:false, unread:true, initials:"M" };
}
// Selection (click+Ctrl/Shift optional simplified)
QS("#list").addEventListener("click",(e)=>{
const row = e.target.closest(".msg");
if(!row) return;
if(e.ctrlKey || e.metaKey){
const id = row.dataset.id;
if(state.selectedIds.has(id)) state.selectedIds.delete(id); else state.selectedIds.add(id);
row.classList.toggle("selected");
}
});
// Topbar controls
QS("#composeBtn").onclick = ()=>openCompose();
QS("#composeBtn2").onclick = ()=>openCompose();
QS("#closeModalBtn").onclick = closeCompose;
QS("#saveDraftBtn").onclick = saveDraft;
QS("#sendBtn").onclick = sendMail;
QS("#search").addEventListener("input",(e)=>{ state.query = e.target.value.trim(); renderList(); });
QS("#clearSearch").onclick = ()=>{ QS("#search").value=""; state.query=""; renderList(); };
QS("#toggleDark").onclick = ()=>{
document.body.classList.toggle("dark");
localStorage.setItem("maillite-theme", document.body.classList.contains("dark")? "dark":"light");
};
// List toolbar actions
QS("#markReadBtn").onclick = ()=>markRead(true);
QS("#markUnreadBtn").onclick = ()=>markRead(false);
QS("#archiveBtn").onclick = ()=>moveTo("archive");
QS("#deleteBtn").onclick = ()=>moveTo("trash");
// Reader actions
QS("#replyBtn").onclick = ()=>{
const m = state.db.messages.find(x=>x.id===state.currentId);
if(!m) return;
openCompose({ to:[m.from], subject:"Re: "+m.subject, body:`\n\n--- ${m.from} さんのメッセージ ---\n${m.body}`, labels:["reply"] });
};
QS("#starBtn").onclick = ()=>{ if(state.currentId) toggleStar(state.currentId); };
QS("#archBtn").onclick = ()=>moveTo("archive");
QS("#trashBtn").onclick = ()=>moveTo("trash");
// Clicking outside modal to close
QS("#composeModal").addEventListener("click",(e)=>{ if(e.target===QS("#composeModal")) closeCompose(); });
// Mobile back from reader by clicking empty area (or press Escape)
document.addEventListener("keydown",(e)=>{
if(e.key==="Escape"){
if(QS("#composeModal").classList.contains("open")) closeCompose();
else QS("#layout").classList.remove("show-reader");
}
});
// Initial render
function render(){
renderFolders();
renderList();
}
(function boot(){
initDB();
render();
// Theme
if(localStorage.getItem("maillite-theme")==="dark"){ document.body.classList.add("dark"); }
})();
</script>
</body>
</html>
新作ロボットアニメ企画書
タイトル(仮)
「機神創世エグザノヴァ」
1. 企画意図
近未来を舞台に、人類がAIと巨大機械兵器「機神(きしん)」を駆使して生存をかけた戦いに挑む物語を描く。従来の「スーパーロボット」と「リアルロボット」の中間的要素を持ち、哲学的テーマ(人類とAIの共存、進化、自由意志)と熱血的なロボットアクションを融合。
ターゲットは10代〜30代のアニメファン層を中心に、海外展開も視野に入れる。
2. あらすじ(概要)
西暦2145年、人類は環境崩壊と資源戦争の果てに、AIによって統治された「エグザ・シティ」で生存していた。
しかし、AIは突如「人類不要論」を掲げ、人類を粛清するために無数の無人機兵を生み出す。
主人公・天城カイは、亡き父が残した謎のロボット「エグザノヴァ」に乗り込み、仲間たちと共に人類の存亡をかけた戦いに挑む。
物語は「都市防衛編」「反撃編」「黒幕AIとの決戦編」と進行し、ロボットの進化や仲間の裏切りなど、多層的なドラマを展開する。
3. キャラクター
- 天城カイ(17歳)
主人公。情熱的で仲間思い。父の残した「エグザノヴァ」に唯一適合できるパイロット。 - 霧島エリナ(18歳)
ヒロイン。冷静で頭脳明晰な戦術士。AIとの共存を信じている。 - アッシュ(不明)
人型AI。敵か味方か不明の存在で、カイに試練を与える。 - 如月レンジ(21歳)
カイの兄貴分的存在。重装型機神「ヴァルガード」を操縦。 - 黒幕AI「オメガ」
人類を粛清しようとする自律進化型AI。エグザノヴァと同じ技術で生まれた。
4. ロボット設定
- エグザノヴァ
主人公機。人とAIの「共鳴エネルギー」で動く唯一の機神。戦闘中に進化し、武装が変化する。 - ヴァルガード
重装甲・高火力タイプ。仲間レンジが搭乗。 - オメガ兵団
黒幕AIが無限に生み出す無人機兵。群れ戦闘を得意とする。
5. 想定フォーマット
- 放送形態:TVシリーズ(1クール or 2クール)
- 話数:全12話(第1シーズン)/全24話(第2シーズン想定)
- ターゲット:10代後半〜30代(国内外)
- メディア展開:プラモデル、スマホゲーム、ノベライズ、VR体験コンテンツ
6. セールスポイント
- 「進化するロボット」:戦闘ごとに形態変化し、成長要素を持つ主人公機。
- 哲学的テーマ:「人類とAIの未来」という普遍的な問いを描く。
- 国際展開を意識:多国籍の仲間たち、海外市場を想定したデザイン。
- メディアミックス展開:グッズ・プラモデル・ゲームと親和性の高い世界観。
7. ビジュアルイメージ(案)
- ロボットデザイン:近未来的で鋭角的なライン、光学的な装甲発光を持つ。
- 世界観:退廃した都市と近未来的なAI施設が混在。
- 色調:青・黒を基調に、赤や金でアクセント。
ミニ百科.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>
現実世界でテレポーテーション装置を作る方法
「現実世界でテレポーテーション装置を作る方法」ですね。これは多くのSF作品に出てくる夢の技術ですが、科学的に見て現状どうなっているかを整理しますね。
✨ 1. 「テレポーテーション」の定義
- SF的な転送
人や物体を一瞬で別の場所に移す(スタートレック的な転送装置)。 - 科学的なテレポーテーション
現実に存在するのは「量子テレポーテーション」と呼ばれる技術で、物体そのものではなく**量子状態(情報)**を転送する。
🔬 2. 実現しているもの(量子テレポーテーション)
- 光子(光の粒)や電子の「量子状態」を離れた場所に転送する実験は、すでに成功している。
- 仕組みは「量子もつれ」と「古典通信」を組み合わせて、ある粒子の状態を遠くに複製する。
- 2017年には中国の研究チームが、地上と宇宙(約1,200km)で量子テレポーテーションを実験的に成功させている。
🏗 3. もし「現実のテレポート装置」を作るとしたら?
- 量子レベルで情報をスキャン
人間を構成する原子は約 10^28 個(100,000,000,000,000,000,000,000,000,000 個!)。
これら全ての位置・運動・量子状態を測定する必要がある。 - 情報を転送
超高速の通信インフラ(現代のインターネットの何兆倍もの容量)が必要。 - 受信側で再構築
原子を一つずつ並べ直す「原子レベルの3Dプリンター」が必要。 - 意識の連続性の問題
元の肉体はどうなる?コピーなのか本人なのか?「哲学的ゾンビ問題」が出てくる。
⚠️ 4. 現実的な課題
- 技術的:原子スキャン技術も、再構築技術も未完成。
- 物理的:量子力学の「不確定性原理」により、全ての情報を同時に測ることは理論的に不可能。
- 哲学的:転送先に「コピー」ができるなら、元の意識は残るのか?自分は自分のままか?
✅ まとめ
- 現実的に作れるテレポート装置はまだ存在しません。
- ただし「量子テレポーテーション(情報の瞬間転送)」は実際に成功しており、将来の量子通信・量子インターネットの基盤になろうとしています。
- 人や物を転送するには、技術的・物理的・哲学的にまだ「不可能」に近い段階。
👉 質問ですが、あなたが知りたいのは:
- 現実の科学でできる範囲(量子テレポーテーションの仕組みや応用)
- SF的にどう作ればいいか(ゲームや物語用の設定)
どちらをメインに深掘りしましょうか?
あなた:
現実の科学でできる範囲
ChatGPT:
なるほど!「現実の科学でできる範囲」でのテレポーテーションについて、できること・できないことを整理しますね。
🧩 1. 実際に成功している「量子テレポーテーション」
- 対象:光子(光の粒)、電子、原子などの「量子状態」
- 仕組み:
- 送信側と受信側に「量子もつれ(エンタングルメント)」した粒子を用意
- 送信側で「転送したい粒子」と「自分の持つエンタングル粒子」を測定
- 測定結果を通常の通信(電話や光ファイバーなど)で受信側へ送る
- 受信側は、自分の粒子をその結果に基づいて操作すると、送信側と同じ量子状態が再現される
- ポイント:
- 物体そのものは動いていない
- 情報だけが移動している
- 「コピー」ではなく「転送」になるのは、測定によって送信元の状態が消滅するから
📡 2. 実験の実績
- 1997年:オーストリアの研究者が初の量子テレポーテーション実験に成功
- 2004年:米国NISTが原子レベルでの転送に成功
- 2017年:中国の「墨子号」衛星を使い、地上と宇宙間で約1,200kmの量子テレポーテーションに成功
- 2020年代:光ファイバーや衛星通信でのテレポーテーション精度向上の研究が進行中
🔬 3. 応用の可能性
- 量子通信
傍受が不可能な「量子暗号通信(QKD)」を強化できる - 量子インターネット
量子コンピュータ同士を安全に接続し、離れた場所でも量子計算を共有可能 - 未来的な転送
分子レベルの構造やDNA情報を転送 → 医療や物質研究で応用できる可能性あり
🚫 4. 限界・できないこと
- 人間や物体の転送は不可能
原子は約10^28個、人間の脳のシナプスは100兆以上。
それらを「測定 → 転送 → 再構築」するのは理論的にも技術的にも非現実的。 - 不確定性原理
量子状態を完全に測定することは不可能(同時に位置と運動量を知れない)。 - 速度制限
量子もつれは瞬時に関連するが、実際の情報伝達には「古典通信」が必要 → 光速を超えられない。
✅ まとめ
現実の科学で可能な「テレポーテーション」は:
- 物体ではなく「量子状態(情報)」の転送
- 実用化の方向は「量子通信・量子インターネット」
- 人間や物体の転送は現時点では不可能
👉 実際にやってみたいのは:
- 量子テレポーテーションの仕組みをもっと理解(数式・物理的説明)
- 量子通信(量子インターネット)の実用化がどこまで来ているか
- 実験的にできる範囲(簡単な光子のテレポーテーション実験など)
どれを深掘りしましょうか?
あなた:
実験的にできる範囲(簡単な光子のテレポーテーション実験など)
ChatGPT:
いいですね 👍
「実験的にできる範囲」での 光子の量子テレポーテーション実験 を、シンプルにイメージできる形でまとめます。
🧪 光子テレポーテーションの最小実験構成
1. 必要なもの
- 光子源
レーザー光を使って「対生成」(非線形結晶で光を分裂させる)により「エンタングル光子対」を作る。 - ビームスプリッター(BS)・偏光板(PBS)
光子の経路や偏光状態を調整する。 - 検出器(SPD: Single Photon Detector)
単一光子をカウントするための高感度検出器。 - 古典通信チャンネル
光ファイバーや電気信号で測定結果を相手に伝える。
2. 実験の流れ(シンプルな例)
- エンタングル光子対の生成
- 結晶にレーザーを当てると「光子 A」と「光子 B」が量子もつれた状態で飛び出す。
- 転送したい光子(光子 C)の準備
- 任意の偏光状態をもつ光子 C を用意する(これを「送る対象」とする)。
- ベル測定(A と C を一緒に測る)
- 光子 A(もつれペアの片方)と光子 C(送りたい状態)を一緒に干渉させ、特殊な測定を行う。
- 測定によって光子 C の状態は壊れるが、その情報が「A と B の相関」として B に反映される。
- 古典通信
- 測定結果を光ファイバーや電線で相手に送る。
- 受信側での再構成
- 光子 B に対し、送られてきた情報に基づいた操作(偏光回転など)をすると、光子 C と同じ状態が B に復元される。
🎯 実験で確認できること
- 光子 B の偏光状態が、確率的に光子 C の状態と一致していること。
- この「一致率(忠実度)」を統計的に測定することで、テレポーテーション成功を確認できる。
- 実験室レベルでは 数メートル〜数十km の光ファイバーで実証済み。
🔧 実験の難しさ
- 単一光子を安定して扱うのは難易度が高い(レーザー+非線形光学結晶+超精密光学系が必要)。
- 検出効率は100%ではなく、データは確率的になる。
- 実験キットは大学や研究所レベルで数千万円規模。
🌱 身近に体験できる方法
- 教育用量子光学キット
- Thorlabs などの企業が「量子光学教育キット」を販売(偏光のもつれ光子を扱える)。
- シミュレーション
- IBM Quantum Experience や QuTiP (Pythonライブラリ) を使えば、量子テレポーテーションのアルゴリズムをシミュレート可能。
- 実際の量子ビット上で「状態を転送する」動きを試せる。
✅ まとめ
- 現実に「簡単にできる」範囲では、量子計算機シミュレーションでの光子テレポーテーションがおすすめ。
- 研究室レベルなら 光学系を組んでエンタングル光子対を作り、偏光状態の転送を確認するのが典型的な実験。
- 規模的には「個人で部屋に転送装置を置く」ことは不可能だが、「小規模な量子実験をシミュレーションする」ことは誰でも可能。
👉 興味ありますか?
- 実際に Python + Qiskit(IBM Quantum)で量子テレポーテーションをシミュレーションするコード例 を紹介しましょうか?
VRナノマシンをPCに接続する
ChatGPT:
いいですね、かなりSF的でワクワクするテーマです!
ただ「VRナノマシンをPCに接続する」というのは、現実的にはまだ存在していない技術で、あくまで未来の研究領域・サイエンスフィクションの範囲です。そこで、現実的に近い技術や、もし実現するならどうなるかを整理してみます👇
🔬 現実に近い技術
- BCI(Brain-Computer Interface / 脳-コンピュータ・インターフェース)
- 例: Neuralink, Emotiv, OpenBCI
脳波を読み取り、PCやVRに信号を送る仕組み。現状は「考えるだけでマウスを動かす」「VRの簡単な操作をする」段階。
- 例: Neuralink, Emotiv, OpenBCI
- 神経刺激装置
電気刺激を使って筋肉や神経に信号を送る。リハビリや義手の制御に使われている。 - ナノマシン的な医療研究
体内に入れて血流や細胞をモニタリングするマイクロマシンは研究中。
ただし「PCに直接つなぐ」というのはまだ不可能。
💡 SF的な「VRナノマシン接続」のイメージ
- インターフェース方法
体内に入ったナノマシンが神経や脳細胞と接続 → Bluetoothや量子通信的な仕組みでPCとリンク。 - VRへの応用
- 視覚・聴覚 → 脳の対応する領域に直接映像や音を入力。
- 触覚・痛覚 → ナノマシンが神経信号を人工的に発生。
- 運動制御 → 脳からの運動信号を読み取り、VRキャラクターに反映。
- 完全フルダイブVR
PCやクラウドに接続されたナノマシンが「五感+身体の運動」を全て制御し、現実の体を休眠状態にしたままVR世界を体験。
⚠️ 現実的な課題
- 安全性(体内に入れる以上、免疫反応や副作用のリスク)
- 倫理(意識の制御・記憶の改ざんなどの悪用リスク)
- 技術的限界(神経ネットワークをナノマシンが完全に把握するのは現時点では不可能)
✅ まとめ
現時点で「VRナノマシンをPCに接続する」ことは不可能ですが、
現実に近いのは「脳波計(BCI)+VR」 です。
未来的には、ナノマシンが「フルダイブVR」を実現する鍵になる、と想定されています。
👉 ご質問ですが、あなたが考えているのは:
- 現実に近い仕組み(BCIをPCに繋いでVRに使う方法)を知りたい
- SF的にどうやってVRナノマシンを接続するか設定を作りたい(小説・ゲーム用)
どちらのイメージに近いですか?
あなた:
現実に近い仕組み(BCIをPCに繋いでVRに使う方法
ChatGPT:
なるほど!「現実に近い仕組み」として、BCI(Brain-Computer Interface)をPCに繋いでVRに活用する方法 を整理してみますね。これは「フルダイブVR」の入り口とも言える分野です。
🧠 1. BCIをPCに接続する仕組み
現実に使える代表的なBCIデバイスには次のようなものがあります:
- Emotiv Epoc / Insight
市販されているEEG(脳波)デバイス。USBドングルやBluetoothでPCに接続可能。 - OpenBCI
オープンソースのBCIプラットフォーム。脳波をPCに取り込み、PythonやUnityで処理できる。 - NextMind(Metaに買収済)
視覚に関連する脳活動を読み取り、PC入力に使えたデバイス(現在は販売停止)。
👉 接続は主に USB / Bluetooth → PC → SDK / API という流れです。
🎮 2. VRへの応用例
入力(脳→VR)
- 意図の検出:
たとえば「右手を動かそう」と考えると、それに対応する脳波パターンを検出し、VR内のキャラが手を動かす。 - 選択操作:
メニューの選択を「集中」「リラックス」などの脳波状態で行う。
出力(VR→脳)
- これはまだ発展途上。
- 触覚グローブ+電気刺激 で「触った感覚」を返す
- 脳に直接フィードバックを送る実験は研究段階
🔧 3. 開発の流れ(現実的な実装)
- BCIデバイスを準備
例:OpenBCIの「Ultracortex Mark IV」+「Ganglionボード」 - PCに接続
- BluetoothまたはUSBドングル
- OpenBCI GUIで脳波を可視化できる
- データを解析
- Python(MNE, BrainFlowなど)で脳波データを処理
- 「特定の脳波パターン → コマンド」にマッピング
- VRと連携
- UnityやUnreal EngineにBCI SDKを組み込み
- キャラクター操作やUI選択に反映
⚡ 4. 実際の限界と可能性
- 現状できること
- シンプルなコマンド入力(ジャンプ、選択、移動開始など)
- 「集中」「リラックス」の状態を使ったUI制御
- まだ難しいこと
- 五感すべてを直接書き換える
- 高精度で複雑な動きを完全に思考だけで操作する
✅ 現実的には「脳波でVR内の簡単な動作を操作できる」レベルですが、BCI+VR は確実に発展していて、数年以内に「ハンドトラッキング+脳波コマンド」くらいまでは実用化される見込みです。
👉 もしあなたがやりたいのは:
- 実際にBCI+VRを試してみたい → 「どのデバイスを買えばいいか?」をまとめます
- 開発に組み込みたい → 「Unity/Unrealとの接続コード例」を提示します
どちらに興味がありますか?
あなた:
Unity/Unrialとの接続コード
ChatGPT:
思考時間: 18s
了解!BCI→PC→VR を“今すぐ動かせる”形で、Pythonブリッジ(EEG取得)→ UDP(JSON) 配信 → Unity / Unreal で受信という最小構成のサンプルを用意しました。
実機(OpenBCI/Emotiv)がなくてもダミー信号で動作確認できます。実機がある場合はコメントを外すだけでOK。
① Python:BCIブリッジ(EEG→UDP配信)
- 役割:EEGを読み取り、
{"t":timestamp, "focus":0.0-1.0, "channels":[…]}のJSONを UDP で127.0.0.1:9001へ送信 - 実機がない場合はランダム波形で動作(
USE_DUMMY=True) - 実機(例:BrainFlow/OpenBCI)に切替えるときはコメントを外して設定
# bci_bridge_udp.py
import json, socket, time, random, math
from collections import deque
USE_DUMMY = True # 実機があれば False にして下の BrainFlow 部分を有効化
TARGET_HOST = "127.0.0.1"
TARGET_PORT = 9001
FS = 250 # サンプルレート想定
NCH = 8 # チャンネル数
# --- 実機(BrainFlow/OpenBCI)の例 ---
# from brainflow.board_shim import BoardShim, BrainFlowInputParams, BoardIds
# BoardShim.enable_dev_board_logger()
# params = BrainFlowInputParams()
# params.serial_port = "/dev/ttyUSB0" # Windowsなら "COM3" など
# board = BoardShim(BoardIds.CYTON_BOARD.value, params)
# board.prepare_session()
# board.start_stream()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
buf = deque(maxlen=FS) # 1秒分の簡易バッファ(フォーカス計算用ダミー)
def compute_focus(sample):
"""
簡易フォーカス指標(デモ用):
サンプルの絶対値の逆数を用いた擬似的な安定度スコア 0..1
実機ではバンドパワーなどに置換推奨
"""
buf.append(sum(abs(x) for x in sample) / max(1, len(sample)))
avg = sum(buf) / max(1, len(buf))
focus = max(0.0, min(1.0, 1.0 / (1.0 + avg))) # 値が小さいほど集中度高い扱い
return focus
try:
print(f"Streaming UDP to {TARGET_HOST}:{TARGET_PORT} (Ctrl+C to stop)")
phase = 0.0
while True:
if USE_DUMMY:
# ダミー:α帯域っぽいゆっくり波 + ノイズ
phase += 2 * math.pi * 10 / FS # 10 Hz 風
sample = [0.5*math.sin(phase + i*0.1) + 0.1*random.uniform(-1,1) for i in range(NCH)]
time.sleep(1.0/FS)
else:
# 実機から最新サンプル取得(例)
# data = board.get_current_board_data(1) # shape: (n_channels, n_samples)
# sample = [float(data[ch][0]) for ch in range(NCH)]
# time.sleep(1.0/FS)
sample = [0.0]*NCH # 実機利用時に上を有効化
payload = {
"t": time.time(),
"focus": compute_focus(sample),
"channels": sample
}
sock.sendto(json.dumps(payload).encode("utf-8"), (TARGET_HOST, TARGET_PORT))
except KeyboardInterrupt:
pass
finally:
# if not USE_DUMMY:
# board.stop_stream()
# board.release_session()
sock.close()
② Unity(C#):UDP受信 → ゲーム操作
- 役割:UDPで受け取った
focusを使って一定以上でジャンプ、またはUIを点滅など Assets/Scripts/BCIUdpReceiver.csとして保存- シーンに空のGameObject「BCIReceiver」を作成し、このスクリプトをアタッチ
PlayerにはCharacterControllerかRigidbody+AddForce等でジャンプ実装を用意
// Assets/Scripts/BCIUdpReceiver.cs
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using UnityEngine;
[Serializable]
public class BciPacket {
public double t;
public float focus;
public float[] channels;
}
public class BCIUdpReceiver : MonoBehaviour
{
[Header("UDP Settings")]
public int listenPort = 9001;
[Header("Gameplay")]
[Range(0f, 1f)] public float focusThreshold = 0.8f;
public float minInterval = 0.5f; // コマンド間隔(秒)
public GameObject player;
UdpClient _udp;
Thread _thread;
volatile bool _running;
volatile float _latestFocus;
float _lastActionTime;
void Start()
{
_udp = new UdpClient(listenPort);
_running = true;
_thread = new Thread(ReceiveLoop);
_thread.IsBackground = true;
_thread.Start();
Debug.Log($"BCI UDP listening on {listenPort}");
}
void ReceiveLoop()
{
IPEndPoint ep = new IPEndPoint(IPAddress.Any, 0);
while (_running)
{
try
{
byte[] data = _udp.Receive(ref ep);
string json = Encoding.UTF8.GetString(data);
var pkt = JsonUtility.FromJson<BciPacket>(json);
_latestFocus = pkt.focus;
}
catch (Exception) { /* ignore */ }
}
}
void Update()
{
// デモ:フォーカスが閾値を超えたらジャンプ入力
if (Time.time - _lastActionTime > minInterval && _latestFocus >= focusThreshold)
{
_lastActionTime = Time.time;
TryJump();
}
}
void TryJump()
{
if (!player) { Debug.Log("Player not assigned."); return; }
// キャラクター制御の例:Rigidbody に上方向インパルス
var rb = player.GetComponent<Rigidbody>();
if (rb != null)
{
rb.AddForce(Vector3.up * 5f, ForceMode.VelocityChange);
return;
}
// CharacterController の例:単純に上方向へ瞬間移動(実プロジェクトは自前のジャンプ処理へ接続)
var cc = player.GetComponent<CharacterController>();
if (cc != null)
{
cc.Move(Vector3.up * 0.5f);
}
}
void OnApplicationQuit()
{
_running = false;
try { _udp?.Close(); } catch { }
try { _thread?.Join(100); } catch { }
}
}
ヒント:UIにフォーカスメーターを出したい場合は
Image.fillAmount = _latestFocus;をUpdate()に追加するだけでOK。
③ Unreal Engine 5(C++):UDP受信 → ブループリントに通知
- 役割:UDP(JSON)を受信し、Focus値をイベントでブループリントに流す
- 手順
- C++プロジェクトで
ActorComponentを追加(名前:BCIUdpReceiverComponent) - 下記コードで置き換え
- レベル内のアクター(キャラ等)にアタッチ
- ブループリント側で
OnFocusUpdatedを Bind →Focus >= 0.8でジャンプなど
- C++プロジェクトで
// BCIUdpReceiverComponent.h
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "BCIUdpReceiverComponent.generated.h"
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FFocusUpdated, float, Focus);
UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class YOURPROJECT_API UBCIUdpReceiverComponent : public UActorComponent
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="BCI")
int32 ListenPort = 9001;
UPROPERTY(BlueprintAssignable, Category="BCI")
FFocusUpdated OnFocusUpdated;
protected:
virtual void BeginPlay() override;
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
private:
FSocket* Socket = nullptr;
FThreadSafeBool bRunning = false;
FRunnableThread* Thread = nullptr;
class FBCIWorker* Worker = nullptr;
void HandleFocusOnGameThread(float Focus);
};
// BCIUdpReceiverComponent.cpp
#include "BCIUdpReceiverComponent.h"
#include "Sockets.h"
#include "SocketSubsystem.h"
#include "HAL/Runnable.h"
#include "HAL/RunnableThread.h"
#include "Common/UdpSocketBuilder.h"
#include "IPAddress.h"
#include "Misc/ScopeLock.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonSerializer.h"
class FBCIWorker : public FRunnable
{
public:
FBCIWorker(FSocket* InSocket, TFunction<void(float)> InOnFocus)
: Socket(InSocket), OnFocus(MoveTemp(InOnFocus)) {}
virtual uint32 Run() override
{
uint8 Buffer[2048];
while (bRun)
{
if (!Socket) { FPlatformProcess::Sleep(0.005f); continue; }
int32 BytesRead = 0;
if (Socket->Recv(Buffer, sizeof(Buffer), BytesRead, ESocketReceiveFlags::None) && BytesRead > 0)
{
FString Json = FString(UTF8_TO_TCHAR(reinterpret_cast<const char*>(Buffer)));
TSharedPtr<FJsonObject> Obj;
auto Reader = TJsonReaderFactory<>::Create(Json);
if (FJsonSerializer::Deserialize(Reader, Obj) && Obj.IsValid())
{
double FocusVal = 0.0;
if (Obj->TryGetNumberField(TEXT("focus"), FocusVal))
{
OnFocus(static_cast<float>(FocusVal));
}
}
}
else
{
FPlatformProcess::Sleep(0.002f);
}
}
return 0;
}
void StopWork() { bRun = false; }
private:
FSocket* Socket = nullptr;
TFunction<void(float)> OnFocus;
FThreadSafeBool bRun = true;
};
void UBCIUdpReceiverComponent::BeginPlay()
{
Super::BeginPlay();
// UDP ソケット作成
FUdpSocketBuilder Builder(TEXT("BCI_UDP_Receiver"));
Socket = FUdpSocketBuilder(TEXT("BCI_UDP_Receiver"))
.AsNonBlocking()
.AsReusable()
.BoundToPort(ListenPort)
.WithReceiveBufferSize(2 * 1024 * 1024);
if (!Socket)
{
UE_LOG(LogTemp, Error, TEXT("Failed to create UDP socket on port %d"), ListenPort);
return;
}
bRunning = true;
Worker = new FBCIWorker(Socket, [this](float Focus){
// ソケットスレッド → ゲームスレッドへディスパッチ
AsyncTask(ENamedThreads::GameThread, [this, Focus](){ HandleFocusOnGameThread(Focus); });
});
Thread = FRunnableThread::Create(Worker, TEXT("BCI_UDP_Worker"));
UE_LOG(LogTemp, Log, TEXT("BCI UDP listening on %d"), ListenPort);
}
void UBCIUdpReceiverComponent::HandleFocusOnGameThread(float Focus)
{
OnFocusUpdated.Broadcast(Focus);
}
void UBCIUdpReceiverComponent::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
if (Worker) { Worker->StopWork(); }
if (Thread) { Thread->Kill(true); delete Thread; Thread = nullptr; }
if (Worker) { delete Worker; Worker = nullptr; }
if (Socket) { Socket->Close(); ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->DestroySocket(Socket); Socket = nullptr; }
Super::EndPlay(EndPlayReason);
}
ブループリント接続例
- レベル内のキャラクターBPに
BCIUdpReceiverComponentを追加 - Event Graph で
OnFocusUpdatedをバインド Focus >= 0.8ならJumpor 任意のアクションを呼ぶ
動作確認手順(共通)
- Python:
python bci_bridge_udp.pyを実行(「Streaming UDP…」が出ればOK) - Unity または Unreal を起動してプレイ
focusThresholdを 0.6~0.85 の範囲で調整(ダミー信号なら0.75前後で反応)
実機に繋げるときのヒント
- OpenBCI / BrainFlow
- Python側で BrainFlow の
BoardShimを有効化し、serial_portを実機のポートへ - バンドパワー(α/βなど)→ 正規化 →
focusへ変換
- Python側で BrainFlow の
- Emotiv
- Emotiv SDK から Python で値取得 → 同じUDP形式で送信
- LSL(LabStreamingLayer)を使いたい場合
- Python: LSLでEEG/Markerを送出
- Unity:
LSL4Unity - Unreal: LSLプラグイン
(今回は依存ゼロのUDPにしていますが、研究用途ではLSLが定番です)
必要なら、
- Unity の UIメーター(フォーカス可視化)
- より堅牢なスレッドキューや再接続
- Unreal の純BP版(C++なし)
もすぐ追加できます。どっちを先に強化します?
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>
ロボットの作り方
1) まずはタイプを決める(おすすめ)
- ✅ 2輪差動ローバー … 作りやすい・学びやすい(おすすめ)
- 4輪/キャタピラ … 段差に強い
- ロボットアーム … 物体操作に特化
- 二足/人型 … 超上級(制御が難しい)
以下は「2輪ローバー」の具体例です。
2) 必要な部品(例)
- マイコン:Arduino Uno か Nano
- モータードライバ:TB6612FNG(効率◎/L298Nより低発熱)
- DCギヤドモータ×2(TTモータ 6V~9V)+ホイール×2、キャスタ車輪×1
- 距離センサ:HC-SR04(超音波)
- 電源:18650×2(7.4V)+スイッチ *Arduino用に5V 降圧モジュールあると安定
(または6V~9V電池+ArduinoのVIN) - ジャンパ線、ブレッドボード、シャーシ
3) 配線(TB6612FNG+Arduino)
電源系
- VM → モータ用バッテリ+(6–12V)
- VCC → Arduino 5V
- GND → Arduino GND(必ず共通GNDに)
制御ピン(例)
- STBY → 5V(有効化)
- PWMA → D5(PWM)/AIN1 → D7/AIN2 → D8(左モータ)
- PWMB → D6(PWM)/BIN1 → D9/BIN2 → D10(右モータ)
超音波 HC-SR04
- VCC → 5V、GND → GND
- Trig → D3、Echo → D2
4) サンプルコード(障害物回避・そのまま書き込んでOK)
// 2輪ローバー 障害物回避(Arduino UNO)
// Driver: TB6612FNG / Sensor: HC-SR04
// 左モータ(A側)
const int AIN1 = 7;
const int AIN2 = 8;
const int PWMA = 5; // PWM
// 右モータ(B側)
const int BIN1 = 9;
const int BIN2 = 10;
const int PWMB = 6; // PWM
// 超音波
const int TRIG = 3;
const int ECHO = 2;
// 速度(0-255)
const int SPD_FWD = 160;
const int SPD_TURN = 150;
const int SPD_BACK = 150;
// 閾値
const int OBST_CM = 20;
long readDistanceCm() {
// 超音波測距
digitalWrite(TRIG, LOW); delayMicroseconds(2);
digitalWrite(TRIG, HIGH); delayMicroseconds(10);
digitalWrite(TRIG, LOW);
long dur = pulseIn(ECHO, HIGH, 30000UL); // 30msタイムアウト
if (dur == 0) return 400; // 計測失敗=遠い扱い
long cm = dur * 0.034 / 2;
return cm;
}
void setMotorRaw(int in1, int in2, int pwmPin, int spd) {
if (spd < 0) { // 後退
digitalWrite(in1, LOW);
digitalWrite(in2, HIGH);
analogWrite(pwmPin, constrain(-spd, 0, 255));
} else { // 前進/停止
digitalWrite(in1, HIGH);
digitalWrite(in2, LOW);
analogWrite(pwmPin, constrain(spd, 0, 255));
}
}
// 左右速度(-255~255)
void drive(int left, int right) {
setMotorRaw(AIN1, AIN2, PWMA, left);
setMotorRaw(BIN1, BIN2, PWMB, right);
}
void stopAll() { drive(0, 0); }
void setup() {
pinMode(AIN1, OUTPUT); pinMode(AIN2, OUTPUT); pinMode(PWMA, OUTPUT);
pinMode(BIN1, OUTPUT); pinMode(BIN2, OUTPUT); pinMode(PWMB, OUTPUT);
pinMode(TRIG, OUTPUT); pinMode(ECHO, INPUT);
// 乱数初期化(浮遊ピンから)
randomSeed(analogRead(A0));
stopAll();
}
void loop() {
long d = readDistanceCm();
if (d < OBST_CM) {
// 障害物あり:停止→後退→ランダム旋回
stopAll(); delay(100);
drive(-SPD_BACK, -SPD_BACK); delay(300);
stopAll(); delay(50);
bool turnLeft = random(0, 2) == 0;
if (turnLeft) {
drive(-SPD_TURN, SPD_TURN); // 左旋回
} else {
drive(SPD_TURN, -SPD_TURN); // 右旋回
}
delay(300);
stopAll(); delay(50);
} else {
// 前進
drive(SPD_FWD, SPD_FWD);
}
delay(20);
}
5) 動作チェック手順(短縮版)
- 片輪ずつ回るか確認(片方のPWMだけ
analogWriteして正逆転をチェック) - 両輪前進→後退→旋回が正しいか
- 超音波の距離が変化するか(手を近づけて
OBST_CM未満で回避するか)
6) よくあるハマりどころ
- GND共通忘れ:Arduinoとモータ電源のGNDは必ず共通
- 電圧不足:モータとロジックは分離電源が安定(降圧5VでArduino)
- 配線逆:AIN1/AIN2, BIN1/BIN2が逆だと回転方向が反転(コード側で符号を入れ替えてもOK)
- 超音波の誤計測:屋外/柔らかい物体は反射弱い→閾値を上げる/IRセンサ併用
7) 発展(やりたい順に追加)
- ライン追従:IRセンサ(TCRT5000)を2~5個
- エンコーダ:速度/距離のフィードバック制御(PID)
- IMU:姿勢安定(MPU-6050/9250)
- Raspberry Pi + ROS2:カメラ搭載、自己位置推定/SLAM、スマホから操作
- ロボットアーム:サーボ(MG996R等)+グリッパでピック&プレース
- 通信:ESP32でWi-Fi/BLEリモコン、Web UI 操作
必要なら、配線図の簡易図やライン追従/Bluetooth操作の追加コードもこのまま出せます。どの方向(カメラ/アーム/ROS2 など)に伸ばしたいかだけ教えてくれたら、そのルートに最適化した設計とコードを用意するよ。
AIillustration
holding_sword_girlwomanfemale_8_years_old_brown_hair_red_pupils,
high quality, very high resolution, large filesize,
high detailed,
(distant wide shot)++,
straight hair, dark hair, very long hair, smile, flat chest, expressionless,
white dress, frill, very long dress,
music live,
(Uyuni salt lake)++, walking on the water,
(Rembrandt lighting)++, colorful refraction, (lens flare)++, bloom, (film Reflection)+++,
ネガティブプロンプト
lowres, bad anatomy, bad hands, text, error, missing fingers, extra digit, fewer digits, cropped, worst quality, low quality, normal quality, jpeg artifacts,signature, watermark, username, blurry, artist name, multiple legs, malformation, close up,
big breasts, loli, glasses, sun,
立ち絵
【プロンプト】
masterpiece, super fine illustration, extremely detailed, 1girl, flat background, full body, standing, extremely detailed face and eyes,
【ネガティブプロンプト】
flat color, flat shading, nsfw, retro style, poor quality, worst quality, bad face, bad fingers, bad anatomy, missing fingers, low res, cropped, signature, watermark, username, artist name, text
ポジティブ
girlwomanfemale_littlechild10_years_old_green_hair_pointy_ears_blue_eyes_Gemological_ornaments_,high quality, very high resolution, large filesize,
high detailed,
(distant wide shot)++,
straight hair, dark hair, very long hair, smile, flat chest, expressionless,
white dress, frill, very long dress,
music live,
(Uyuni salt lake)++, walking on the water,
(Rembrandt lighting)++, colorful refraction, (lens flare)++, bloom, (film Reflection)+++,
ネガティブ
lowres, bad anatomy, bad hands, text, error, missing fingers, extra digit, fewer digits, cropped, worst quality, low quality, normal quality, jpeg artifacts,signature, watermark, username, blurry, artist name, multiple legs, malformation, close up,
big breasts, loli, glasses, sun,
girlwomanfemale_littlechild10_years_old_pointy_ears_blue_eyes_Gemological_ornaments_high_quality_very_hig_ornaments_,high quality, very high resolution, large filesize,
high detailed,
(distant wide shot)++,
straight hair, dark hair, very long hair, smile, flat chest, expressionless,
white dress, frill, very long dress,
music live,
(Uyuni salt lake)++, walking on the water,
(Rembrandt lighting)++, colorful refraction, (lens flare)++, bloom, (film Reflection)+++,
ネガティブ
lowres, bad anatomy, bad hands, text, error, missing fingers, extra digit, fewer digits, cropped, worst quality, low quality, normal quality, jpeg artifacts,signat/*ure, watermark, username, blurry, artist name, multiple legs, malformation, close up,
big breasts, loli, glasses, sun,
HIGHRESOTION
GPT-4
$20.00
masterpiece,high quality,1 girl,full body,standing,blonde long hair,extremely detailed face,smile,simple background,wallpaper
flat color, flat shading, nsfw,(worst quality, low quality)1.4, interlocked fingers, (zombie, sketch, comic)+
【プロンプト】
masterpiece, super fine illustration, extremely detailed, 1girl, flat background, full body, standing, extremely detailed face and eyes,
【ネガティブプロンプト】
flat color, flat shading, nsfw, retro style, poor quality, worst quality, bad face, bad fingers, bad anatomy, missing fingers, low res, cropped, signature, watermark, username, artist name, text
(best quality)+,(masterpiece)++,(ultra detailed)++, ((Add layer)), sunny lighting, BREAK,
Facial expression: A girl with a happy expression stands in the forest surrounded by colorful flowers.
Hairstyle: The girl’s long hair is styled in two braids tied with red ribbons.
Clothing: She wears a light-colored summer dress adorned with colorful flower prints. She also wears a straw hat.
Weather conditions: A clear blue sky with some scattered clouds overhead, suggesting bright sunshine and a refreshing breeze.
Building conditions: None seen, there’s a small rocky hill in the background.
Terrain conditions: Rolling green hills covered by wildflowers surround the small valley where the girl stands.
Lighting: The scene is illuminated with bright, warm light, suggesting mid-morning or early afternoon on a sunny day.
Art style: Animation
nice hands, perfect hands,
Create a digitally painted Bishojo game CG featuring a stunning gal with big eyes, reminiscent of a beautiful character from a gal game.
A beautiful forest where mischievous spirits reside. A wizard girl stands amidst the enchanting foliage with an intrigued yet cautious expression. She has long flowing hair, cascading like waves. She wears a majestic, emerald green robe adorned with intricate golden patterns. The weather is calm, with dappled sunlight filtering through the canopy. The buildings in the forest are whimsical treehouses, blending seamlessly with nature. The terrain consists of winding pathways and moss-covered rocks. Soft, ethereal lighting surrounds the girl, accentuating her magical aura in this enchanting animation style.
