- VisitionPro(= Apple Vision Pro 現行モデル)の価格予想(日本)
→ 税込 599,800円(256GB)を維持と見ます。実際に日本発売時も599,800円で、米国のベース価格は$3,499据え置きのままです。PhoneArena+2Apple+2 - VisitionPro 2(第2世代 or 後継)価格予想(日本)
シナリオ別に幅を持たせます:- マイナー刷新(現行の後継・性能向上版)
→ 税込 599,800円前後(±3万円)
根拠:直近のアップデート版も米国$3,499据え置きで発表されており(日本は為替変動がなければ59.98万円帯が目安)。The Verge+1 - 廉価版(別ライン)
→ 税込 299,800〜349,800円のレンジを予想(精度低め)。
根拠:2025年に約30万円台の低価格版が出るとの報道・噂が過去にあり(正式名称や発売確度は不明)。実現すればこの帯が目安。GIGAZINE+1
- マイナー刷新(現行の後継・性能向上版)
タグ: programming
WordPressの基礎
WordPressって?
- 世界シェア最大のCMS(Webサイト管理システム)。ブログからコーポレート、EC(WooCommerce)まで対応。
- WordPress.org(自分でサーバーに入れる・自由度高) / WordPress.com(ホスティング付き・制限あり)
動かすには(要件と準備)
- サーバー:PHP(最新版推奨)、MySQL/MariaDB、HTTPS
- 取得物:独自ドメイン(任意)+レンタルサーバー or ローカル環境(Local/Wamp/MAMPなど)
- 手順:
- サーバーにデータベース作成
- WordPress公式ZIPをアップロード&解凍
wp-config.php設定(画面案内に従う)- 管理画面ログイン(/wp-admin)
管理画面の地図(超重要)
- 投稿(ブログ記事) / 固定ページ(会社概要などの“独立ページ”)
- メディア(画像・動画)
- 外観(テーマ・メニュー・ウィジェット・サイトエディター/カスタマイザー)
- プラグイン(機能追加)
- ユーザー(権限:管理者/編集者/投稿者/寄稿者/購読者)
- 設定(一般/表示/ディスカッション/メディア/パーマリンク)
ブロックエディター(Gutenberg)の使い方の型
- 見出しブロック(H2/H3)で骨組み
- 段落・画像・ギャラリー・ボタン等のブロックを追加
- 再利用ブロック(パーツ化)で共通部品に
- ページは固定ページ、記事は投稿+カテゴリ/タグで整理
テーマとデザイン
- テーマ=サイト全体の見た目(ブロックテーマ/従来テーマ)
- 子テーマ:テーマ更新に備えてカスタマイズを上書きしないための“安全な入れ物”
- テンプレート階層(ざっくり):
front-page.php→home.php→single.php/page.php→archive.php→index.php
最低限入れると楽なプラグイン(指針)
- SEO:基本は1つだけ(例:All in One SEO など)
- キャッシュ/高速化:ページキャッシュ+画像圧縮(WebP)
- セキュリティ:ログイン試行制限、基本的な防御
- 連絡先フォーム:Contact Form 7 等
※ 入れすぎ注意。まずは必要最低限。
パーマリンク(URL)設定
- 初期設定のままだとNG。
設定 → パーマリンク → 投稿名(/%postname%/)が分かりやすい。
バックアップと更新の鉄則
- バックアップ(DB+wp-content)→ テーマ/プラグイン/本体の順で更新
- 重大変更はステージング(テスト環境)で確認してから本番へ
役割分担の考え方
- 投稿:日付順のコンテンツ(ニュース/ブログ)
- 固定ページ:構造ページ(トップ/会社情報/サービス)
- カテゴリ/タグ:情報の“整理”と“横断”
よく使うカスタマイズの入口
- CSSだけで調整:外観 → 追加CSS
- ちょいPHP:子テーマの
functions.php(スニペット集約でもOK) - 独自データが必要:カスタム投稿タイプ+カスタムフィールド(ACFが定番)
失敗しやすいポイント
- プラグイン入れすぎで重い/競合する
- 子テーマを作らずに親テーマを直接編集
- 画像が巨大(→ アップ前に圧縮/リサイズ、WebP化)
- 本番でいきなり更新してレイアウト崩壊(→ 事前バックアップ&ステージング)
すぐ使える最小チェックリスト
- パーマリンクを「投稿名」に変更
- サイト基本情報(タイトル/キャッチ/ロゴ/ファビコン)
- トップ・会社概要・お問い合わせの固定ページ作成
- メニュー作成(外観 → メニュー/サイトエディター)
- お問い合わせフォーム設置&送信テスト
- sitemap.xmlとrobots.txt(SEOプラグインで自動生成可)
- 画像圧縮&キャッシュで表示速度を確保
- バックアップの定期実行設定
QuestFoundry
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Quest Foundry | 世界観からクエスト自動設計</title>
<meta name="description" content="世界観のキーワードからNPC・アイテム・場所・クエストを一括生成。JSON/CSVエクスポート、依存関係、難易度バランス、シード固定対応。" />
<!-- Tailwind CDN (Node不要) -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ["Noto Sans JP", "ui-sans-serif", "system-ui"] },
colors: { brand: { 50: '#eef2ff', 100:'#e0e7ff', 200:'#c7d2fe', 300:'#a5b4fc', 400:'#818cf8', 500:'#6366f1', 600:'#4f46e5', 700:'#4338ca', 800:'#3730a3', 900:'#312e81'} }
}
}
};
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700;900&display=swap" rel="stylesheet">
<style>
html, body { height: 100%; }
.glass { backdrop-filter: blur(10px); background: rgba(255,255,255,0.7); }
.prose pre { white-space: pre-wrap; word-break: break-word; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.card { @apply rounded-2xl shadow-lg p-5 bg-white; }
.prose h1{font-size:1.5rem;line-height:1.3;margin:0 0 .6rem;font-weight:800}
.prose h2{font-size:1.2rem;line-height:1.35;margin:1.2rem 0 .4rem;font-weight:700;border-left:4px solid #6366f1;padding-left:.6rem}
.prose h3{font-size:1rem;line-height:1.4;margin:1rem 0 .3rem;font-weight:700}
.prose ul{list-style:disc;padding-left:1.25rem;margin:.4rem 0 .8rem}
.prose li{margin:.2rem 0}
.badge{display:inline-block;font-size:.72rem;line-height:1;background:#eef2ff;color:#3730a3;border:1px solid #c7d2fe;border-radius:.5rem;padding:.15rem .45rem;margin-right:.25rem}
details.quest{border:1px solid #e5e7eb;border-radius:.75rem;padding:.6rem .8rem;margin:.5rem 0;background:#fff}
details.quest > summary{cursor:pointer;list-style:none}
details.quest > summary::-webkit-details-marker{display:none}
.kv{display:inline-grid;grid-template-columns:auto auto;gap:.2rem .6rem;align-items:center}
</style>
</head>
<body class="min-h-screen bg-gradient-to-br from-brand-50 to-white text-slate-800">
<header class="sticky top-0 z-40 border-b bg-white/80 backdrop-blur">
<div class="mx-auto max-w-7xl px-4 py-3 flex items-center gap-4">
<div class="text-2xl font-black tracking-tight"><span class="text-brand-700">Quest</span> Foundry</div>
<div class="text-xs text-slate-500">世界観→NPC/アイテム/場所/クエストを自動生成(JSON/CSV出力可)</div>
<div class="ml-auto flex items-center gap-2">
<button id="btnSave" class="px-3 py-2 text-sm rounded-lg border hover:bg-slate-50">保存</button>
<button id="btnLoad" class="px-3 py-2 text-sm rounded-lg border hover:bg-slate-50">読込</button>
<button id="btnPrint" class="px-3 py-2 text-sm rounded-lg border hover:bg-slate-50">印刷/PDF</button>
</div>
</div>
</header>
<main class="mx-auto max-w-7xl px-4 py-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 左:設定フォーム -->
<section class="lg:col-span-1 card">
<h2 class="text-lg font-bold mb-4">ワールド設定</h2>
<form id="worldForm" class="space-y-4">
<div>
<label class="block text-sm font-medium">世界名</label>
<input id="worldName" type="text" class="w-full mt-1 rounded-lg border px-3 py-2" placeholder="例:アトラティア" />
</div>
<div>
<label class="block text-sm font-medium">テーマ・キーワード(読点・スペース区切り)</label>
<input id="themes" type="text" class="w-full mt-1 rounded-lg border px-3 py-2" placeholder="例:古代遺跡 砂漠 精霊 冒険者ギルド" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium">難易度</label>
<select id="difficulty" class="w-full mt-1 rounded-lg border px-3 py-2">
<option value="easy">Easy</option>
<option value="normal" selected>Normal</option>
<option value="hard">Hard</option>
<option value="epic">Epic</option>
</select>
</div>
<div>
<label class="block text-sm font-medium">クエスト数</label>
<input id="questCount" type="number" min="1" max="30" value="8" class="w-full mt-1 rounded-lg border px-3 py-2" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium">シード(同じ結果を再現)</label>
<input id="seed" type="text" class="w-full mt-1 rounded-lg border px-3 py-2" placeholder="未入力なら自動" />
</div>
<div class="flex items-end gap-2">
<input id="lockSeed" type="checkbox" class="h-5 w-5" />
<label for="lockSeed" class="text-sm">シード固定(再生成でも変化しない)</label>
</div>
</div>
<div>
<label class="block text-sm font-medium">トーン</label>
<select id="tone" class="w-full mt-1 rounded-lg border px-3 py-2">
<option value="classic" selected>古典ファンタジー</option>
<option value="dark">ダーク</option>
<option value="steampunk">スチームパンク</option>
<option value="myth">神話/叙事詩</option>
<option value="sci">サイファンタジー</option>
</select>
</div>
<div class="flex flex-wrap gap-2 pt-2">
<button id="btnGenerate" type="button" class="px-4 py-2 rounded-xl bg-brand-600 text-white hover:bg-brand-700">生成</button>
<button id="btnRegenerate" type="button" class="px-4 py-2 rounded-xl bg-slate-800 text-white hover:bg-slate-900">再生成(同条件)</button>
<button id="btnShuffleSeed" type="button" class="px-4 py-2 rounded-xl border">シード再抽選</button>
</div>
</form>
<p class="text-xs text-slate-500 mt-4">※外部API不使用。テンプレート×確率モデルでローカル生成。ブラウザ上で完結。</p>
</section>
<!-- 中央:結果(テキスト) -->
<section class="lg:col-span-2 card">
<div class="flex items-center gap-2 mb-4">
<h2 class="text-lg font-bold">生成結果</h2>
<span id="meta" class="ml-auto text-xs text-slate-500"></span>
</div>
<div class="flex flex-wrap gap-2 mb-4">
<button id="btnCopyText" class="px-3 py-2 rounded-lg border">テキストをコピー</button>
<button id="btnDownloadJSON" class="px-3 py-2 rounded-lg border">JSONダウンロード</button>
<button id="btnExportCSV" class="px-3 py-2 rounded-lg border">CSV書き出し</button>
<button id="btnToggleJson" class="px-3 py-2 rounded-lg border">JSON表示切替</button>
</div>
<div id="outText" class="prose max-w-none text-sm leading-6"></div>
<details id="jsonBlock" class="mt-4 hidden">
<summary class="cursor-pointer select-none text-sm text-slate-600">JSON表示</summary>
<pre id="outJSON" class="mono text-xs bg-slate-50 p-3 rounded-lg overflow-x-auto"></pre>
</details>
</section>
<!-- 下:プレビュー(カードレイアウト) -->
<section class="lg:col-span-3 card">
<h2 class="text-lg font-bold mb-4">カードビュー</h2>
<div class="grid md:grid-cols-3 gap-4" id="cards"></div>
</section>
</main>
<footer class="py-8 text-center text-xs text-slate-500">
© 2025 Quest Foundry — Local-first Fantasy Content Generator
</footer>
<script>
/* =========================
* 乱数とユーティリティ
* ========================= */
function cyrb128(str){ let h1=1779033703,h2=3144134277,h3=1013904242,h4=2773480762; for(let i=0;i<str.length;i++){ let k=str.charCodeAt(i); h1=h2^(Math.imul(h1^k,597399067)); h2=h3^(Math.imul(h2^k,2869860233)); h3=h4^(Math.imul(h3^k,951274213)); h4=h1^(Math.imul(h4^k,2716044179)); } h1=Math.imul(h3^(h1>>>18),597399067); h2=Math.imul(h4^(h2>>>22),2869860233); h3=Math.imul(h1^(h3>>>17),951274213); h4=Math.imul(h2^(h4>>>19),2716044179); let r=(h1^h2^h3^h4)>>>0; return r.toString(36); }
function mulberry32(a){ return function(){ let t=a+=0x6D2B79F5; t=Math.imul(t^(t>>>15), t|1); t^=t+Math.imul(t^(t>>>7), t|61); return ((t^(t>>>14))>>>0)/4294967296; } }
function rngFromSeed(seed){ let n=0; for(const ch of seed) n=(n*31 + ch.charCodeAt(0))>>>0; return mulberry32(n||1); }
function choice(r, arr){ return arr[Math.floor(r()*arr.length)] }
function pickN(r, arr, n){ const a=[...arr]; const out=[]; for(let i=0;i<n && a.length;i++){ out.push(a.splice(Math.floor(r()*a.length),1)[0]); } return out; }
function cap(s){ return s.charAt(0).toUpperCase()+s.slice(1) }
function id(prefix, i){ return `${prefix}-${String(i).padStart(3,'0')}` }
function syllableName(r, tone){
const syll = {
classic:["an","ar","bel","ca","da","el","fa","gal","har","is","jor","kel","lir","mor","nel","or","pa","qua","rhi","sa","tor","ur","val","wen","xel","yor","zel"],
dark:["mor","noir","gloam","umb","dol","grav","nek","var","zul","vex","drei","thar","khar","wyrm"],
steampunk:["gear","steam","bolt","cog","brass","tink","pneu","copper","fuse","riv","spindle"],
myth:["aeg","od","ish","ra","zeph","io","sol","lun","tyr","fre","eir","hel"],
sci:["neo","ion","quant","cyber","astra","plasma","proto","omega","nova","phase","flux"]
};
const pool = (syll[tone]||[]).concat(syll.classic);
const len = 2 + Math.floor(r()*2);
let s=""; for(let i=0;i<len;i++) s+= choice(r,pool);
return cap(s);
}
/* =========================
* テンプレ/語彙
* ========================= */
const LEX = {
roles: ["ギルドマスター","考古学者","巡回騎士","密偵","占星術師","錬金術師","旅の商人","巫女","司書","鍛冶師","船乗り","薬師","狩人","吟遊詩人","修道士"],
traits: ["勇敢","狡猾","博識","短気","誠実","猜疑心が強い","陽気","冷静","計算高い","臆病","義理堅い","野心家"],
factions: ["碧星同盟","砂冠商会","螺旋教団","古図書騎士団","白霧旅団","錆鉄工房","風詠み集落","赤砂盗賊団"],
biomes: ["砂漠","湿原","黒森","高地","沿岸","雪原","火山地帯","古代都市跡"],
itemTypes: ["剣","短剣","槍","杖","弓","護符","指輪","書","設計図","薬","鉱石","布","レンズ","コイル"],
rarities: ["Common","Uncommon","Rare","Epic","Legendary"],
verbs: ["救出せよ","護衛せよ","探索せよ","奪還せよ","調査せよ","討伐せよ","修復せよ","封印せよ","交渉せよ","護送せよ","潜入せよ"],
twists: ["依頼主は真犯人","実は時間制限あり","二重スパイがいる","偽物が混じっている","古き呪いが再発","天候異常が発生","儀式の日が前倒し"],
rewardsExtra: ["評判+10","ギルドランク昇格","隠し店舗の解放","旅人の加護","快速移動の解放"]
};
const DIFF_MULT = { easy: 0.8, normal: 1.0, hard: 1.3, epic: 1.7 };
/* =========================
* 生成器
* ========================= */
function genFactions(r, themes){
const count = Math.min(5, 2 + Math.floor(r()*4));
return Array.from({length:count}, (_,i)=>({ id: id('F',i+1), name: `${choice(r,LEX.factions)}`, goal: `${choice(r,["遺物の独占","古文書の解読","交易路の掌握","禁術の復活","辺境防衛"])}`, vibe: choice(r,["協調的","中立","敵対的"]) }));
}
function genLocations(r, themes){
const count = Math.min(8, 4 + Math.floor(r()*5));
return Array.from({length:count}, (_,i)=>({ id: id('L',i+1), name: `${choice(r,LEX.biomes)}の${syllableName(r,'classic')}`, feature: choice(r,["崩れた門","封じ石","光る碑文","隠し水路","浮遊足場","古代機構"]) }));
}
function genNPCs(r, tone, factions){
const count = Math.min(12, 6 + Math.floor(r()*6));
return Array.from({length:count}, (_,i)=>{
const fac = choice(r, factions);
return {
id: id('N',i+1),
name: syllableName(r,tone),
role: choice(r, LEX.roles),
trait: choice(r, LEX.traits),
faction: fac?.id || null
}
});
}
function genItems(r, tone){
const count = Math.min(18, 8 + Math.floor(r()*10));
return Array.from({length:count}, (_,i)=>{
const t = choice(r, LEX.itemTypes);
const rare = choice(r, LEX.rarities);
return {
id: id('I',i+1),
name: `${syllableName(r,tone)}の${t}`,
type: t,
rarity: rare,
value: Math.floor((10+ r()*90) * (1 + 0.3*LEX.rarities.indexOf(rare)))
}
});
}
function genQuests(r, tone, count, npcs, locations, items, difficulty){
const q = [];
const scale = DIFF_MULT[difficulty] || 1.0;
for(let i=0;i<count;i++){
const giver = choice(r, npcs);
const loc = choice(r, locations);
const verb = choice(r, LEX.verbs);
const keyItem = choice(r, items);
const level = Math.max(1, Math.round((i+1)*scale + r()*3));
const objectives = [
`${loc.name}で手掛かりを見つける`,
`${giver.name}(${giver.role})に報告する`,
`${keyItem.name}を入手する`
];
// 依存関係:稀に前のクエストを前提にする
let dependsOn = null;
if(i>0 && r()<0.4){ dependsOn = q[Math.floor(r()*i)].id; }
// ツイストは低確率で
const twist = r()<0.35 ? choice(r, LEX.twists) : null;
const rewardGold = Math.floor((100+ r()*200) * scale * (1 + i*0.05));
const rewardItems = pickN(r, items, r()<0.6?1:2).map(o=>o.id);
q.push({
id: id('Q',i+1),
title: `${verb}:${loc.name}`,
level,
giver: giver.id,
location: loc.id,
objectives,
requires: dependsOn,
reward: { gold: rewardGold, items: rewardItems, extra: r()<0.25? choice(r, LEX.rewardsExtra): null },
twist
});
}
return q;
}
function assembleWorld(input){
const seed = input.seed || `${Date.now().toString(36)}-${cyrb128(input.worldName + (input.themes||''))}`;
const r = rngFromSeed(seed);
const tone = input.tone || 'classic';
const factions = genFactions(r, input.themes);
const locations = genLocations(r, input.themes);
const npcs = genNPCs(r, tone, factions);
const items = genItems(r, tone);
const quests = genQuests(r, tone, input.questCount, npcs, locations, items, input.difficulty);
return { meta: { seed, createdAt: new Date().toISOString(), worldName: input.worldName||syllableName(r,tone), themes: input.themes, difficulty: input.difficulty, tone }, factions, locations, npcs, items, quests };
}
/* =========================
* 出力レンダリング
* ========================= */
function renderText(world){
const idmap = (arr)=> Object.fromEntries(arr.map(a=>[a.id,a]));
const NPC = idmap(world.npcs);
const LOC = idmap(world.locations);
const ITM = idmap(world.items);
const lines = [];
lines.push(`# 世界:${world.meta.worldName}`);
lines.push(`- テーマ:${world.meta.themes||'—'} / トーン:${world.meta.tone} / 難易度:${world.meta.difficulty}`);
lines.push(`- 生成日時:${new Date(world.meta.createdAt).toLocaleString()}`);
lines.push(`- シード:${world.meta.seed}`);
lines.push(`\n## 勢力(${world.factions.length})`);
world.factions.forEach(f=>{ lines.push(`- [${f.id}] ${f.name}|目的:${f.goal}|態度:${f.vibe}`) });
lines.push(`\n## 場所(${world.locations.length})`);
world.locations.forEach(l=>{ lines.push(`- [${l.id}] ${l.name}|特徴:${l.feature}`) });
lines.push(`\n## NPC(${world.npcs.length})`);
world.npcs.forEach(n=>{ lines.push(`- [${n.id}] ${n.name}(${n.role}/${n.trait}) 所属:${n.faction||'なし'}`) });
lines.push(`\n## アイテム(${world.items.length})`);
world.items.forEach(i=>{ lines.push(`- [${i.id}] ${i.name}|種類:${i.type}|希少度:${i.rarity}|価値:${i.value}`) });
lines.push(`\n## クエスト(${world.quests.length})`);
world.quests.forEach(q=>{
const giver = NPC[q.giver]?.name || q.giver;
const loc = LOC[q.location]?.name || q.location;
const req = q.requires? `(前提:${q.requires})` : '';
lines.push(`\n### [${q.id}] ${q.title} Lv.${q.level} ${req}`);
lines.push(`- 依頼主:${giver}`);
lines.push(`- 場所:${loc}`);
lines.push(`- 目的:`);
q.objectives.forEach(o=>lines.push(` - ${o}`));
const rewardItems = q.reward.items.map(id=> ITM[id]?.name || id).join('、');
lines.push(`- 報酬:${q.reward.gold}G / アイテム:${rewardItems}${q.reward.extra? ' / '+q.reward.extra:''}`);
if(q.twist) lines.push(`- ツイスト:${q.twist}`);
});
return lines.join('\n');
}
function renderHTML(world){
const idmap = (arr)=> Object.fromEntries(arr.map(a=>[a.id,a]));
const NPC = idmap(world.npcs);
const LOC = idmap(world.locations);
const ITM = idmap(world.items);
const head = `
<h1>世界:${world.meta.worldName}</h1>
<div class="kv text-sm text-slate-600 gap-x-2">
<span class="badge">トーン:${world.meta.tone}</span>
<span class="badge">難易度:${world.meta.difficulty}</span>
<span class="badge">クエスト:${world.quests.length}</span>
<span class="badge">シード:${world.meta.seed}</span>
</div>
<p class="mt-2 text-sm text-slate-600">テーマ:${world.meta.themes||'—'} / 生成日時:${new Date(world.meta.createdAt).toLocaleString()}</p>
`;
const factions = `
<h2>勢力(${world.factions.length})</h2>
<ul>
${world.factions.map(f=>`<li><code>[${f.id}]</code> ${f.name}|目的:${f.goal}|態度:${f.vibe}</li>`).join('')}
</ul>
`;
const locs = `
<h2>場所(${world.locations.length})</h2>
<ul>
${world.locations.map(l=>`<li><code>[${l.id}]</code> ${l.name}|特徴:${l.feature}</li>`).join('')}
</ul>
`;
const npcs = `
<h2>NPC(${world.npcs.length})</h2>
<ul>
${world.npcs.map(n=>`<li><code>[${n.id}]</code> ${n.name}(${n.role}/${n.trait}) 所属:${n.faction||'なし'}</li>`).join('')}
</ul>
`;
const items = `
<h2>アイテム(${world.items.length})</h2>
<ul>
${world.items.map(i=>`<li><code>[${i.id}]</code> ${i.name}|種類:${i.type}|希少度:${i.rarity}|価値:${i.value}</li>`).join('')}
</ul>
`;
const quests = `
<h2>クエスト(${world.quests.length})</h2>
${world.quests.map(q=>{
const giver = NPC[q.giver]?.name || q.giver;
const loc = LOC[q.location]?.name || q.location;
const req = q.requires? `(前提:${q.requires})` : '';
const rewardItems = q.reward.items.map(id=> ITM[id]?.name || id).join('、');
return `
<details class="quest">
<summary><strong><code>[${q.id}]</code> ${q.title}</strong> <span class="text-sm text-slate-600">Lv.${q.level} ${req}</span></summary>
<div class="mt-2 text-sm">
<div>依頼主:${giver}</div>
<div>場所:${loc}</div>
<div class="mt-1">目的:</div>
<ul>
${q.objectives.map(o=>`<li>${o}</li>`).join('')}
</ul>
<div class="mt-1">報酬:${q.reward.gold}G / アイテム:${rewardItems}${q.reward.extra? ' / '+q.reward.extra:''}</div>
${q.twist? `<div class="mt-1 text-rose-700">ツイスト:${q.twist}</div>`:''}
</div>
</details>`;
}).join('')}
`;
return [head, factions, locs, npcs, items, quests].join('');
}
function renderCards(world){
const $cards = document.getElementById('cards');
$cards.innerHTML = '';
const make = (title, body)=>{
const el = document.createElement('div');
el.className = 'rounded-2xl border p-4 bg-white';
el.innerHTML = `<div class="text-sm font-bold mb-2">${title}</div><div class="text-xs text-slate-700 whitespace-pre-wrap">${body}</div>`;
$cards.appendChild(el);
};
make('ワールド', `名前:${world.meta.worldName}\n難易度:${world.meta.difficulty}\nトーン:${world.meta.tone}\nシード:${world.meta.seed}`);
make('勢力', world.factions.map(f=>`[${f.id}] ${f.name}/目的:${f.goal}`).join('\n'));
make('場所', world.locations.map(l=>`[${l.id}] ${l.name}/${l.feature}`).join('\n'));
make('NPC', world.npcs.slice(0,12).map(n=>`[${n.id}] ${n.name}/${n.role}`).join('\n'));
make('アイテム', world.items.slice(0,15).map(i=>`[${i.id}] ${i.name}/${i.rarity}`).join('\n'));
make('クエスト', world.quests.map(q=>`[${q.id}] ${q.title} Lv.${q.level}${q.requires? '(前提:'+q.requires+')':''}`).join('\n'));
}
/* =========================
* CSV/JSON/コピー/保存
* ========================= */
function toCSV(rows){
return rows.map(r=> r.map(v=>`"${String(v).replaceAll('"','""')}"`).join(',')).join('\n');
}
function exportCSVs(world){
const npcRows = [["id","name","role","trait","faction"]].concat(world.npcs.map(n=>[n.id,n.name,n.role,n.trait,n.faction||'']));
const itemRows = [["id","name","type","rarity","value"]].concat(world.items.map(i=>[i.id,i.name,i.type,i.rarity,i.value]));
const questRows = [["id","title","level","giver","location","requires","objectives","reward_gold","reward_items","twist"]].concat(
world.quests.map(q=>[
q.id, q.title, q.level, q.giver, q.location, q.requires||'', q.objectives.join(' / '), q.reward.gold, q.reward.items.join('|'), q.twist||''
])
);
const files = [
{name:`${world.meta.worldName}_NPC.csv`, data: toCSV(npcRows)},
{name:`${world.meta.worldName}_Items.csv`, data: toCSV(itemRows)},
{name:`${world.meta.worldName}_Quests.csv`, data: toCSV(questRows)}
];
files.forEach(f=>{
const blob = new Blob(["\ufeff"+f.data], {type:'text/csv'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = f.name; a.click(); URL.revokeObjectURL(a.href);
});
}
function downloadJSON(world){
const blob = new Blob([JSON.stringify(world, null, 2)], {type:'application/json'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `${world.meta.worldName}_world.json`; a.click(); URL.revokeObjectURL(a.href);
}
function copyText(text){
navigator.clipboard.writeText(text).then(()=>{
toast('テキストをコピーしました');
});
}
function saveLocal(world){ localStorage.setItem('quest_foundry_last', JSON.stringify(world)); toast('保存しました'); }
function loadLocal(){ const s=localStorage.getItem('quest_foundry_last'); if(!s){ toast('保存データなし'); return null; } try{ return JSON.parse(s);}catch(e){ toast('読込失敗'); return null; } }
/* =========================
* UI
* ========================= */
function toast(msg){
const t = document.createElement('div');
t.className = 'fixed bottom-4 left-1/2 -translate-x-1/2 bg-slate-900 text-white text-sm px-4 py-2 rounded-xl shadow-lg';
t.textContent = msg; document.body.appendChild(t);
setTimeout(()=>{ t.classList.add('opacity-0'); t.style.transition='opacity .6s'; }, 1600);
setTimeout(()=> t.remove(), 2300);
}
let lastInput = null;
let lastWorld = null;
function currentInput(){
const worldName = document.getElementById('worldName').value.trim();
const themes = document.getElementById('themes').value.trim();
const difficulty = document.getElementById('difficulty').value;
const questCount = Math.max(1, Math.min(30, parseInt(document.getElementById('questCount').value || '8')));
const seed = document.getElementById('seed').value.trim();
const tone = document.getElementById('tone').value;
return { worldName, themes, difficulty, questCount, seed, tone };
}
function applyWorld(world){
lastWorld = world;
document.getElementById('meta').textContent = `ワールド:${world.meta.worldName} / クエスト:${world.quests.length}件`;
document.getElementById('outText').innerHTML = renderHTML(world);
document.getElementById('outJSON').textContent = JSON.stringify(world, null, 2);
renderCards(world);
}
function generate(withNewSeed=false){
const input = currentInput();
if(withNewSeed && !document.getElementById('lockSeed').checked){ input.seed = ''; }
if(!input.seed) { input.seed = cyrb128((input.worldName||'World') + (input.themes||'') + Date.now()); document.getElementById('seed').value = input.seed; }
lastInput = input;
const world = assembleWorld(input);
applyWorld(world);
}
// イベント
document.getElementById('btnGenerate').addEventListener('click', ()=> generate(false));
document.getElementById('btnRegenerate').addEventListener('click', ()=> generate(false));
document.getElementById('btnShuffleSeed').addEventListener('click', ()=> generate(true));
document.getElementById('btnCopyText').addEventListener('click', ()=>{ if(lastWorld) copyText(renderText(lastWorld)); });
document.getElementById('btnDownloadJSON').addEventListener('click', ()=>{ if(lastWorld) downloadJSON(lastWorld); });
document.getElementById('btnExportCSV').addEventListener('click', ()=>{ if(lastWorld) exportCSVs(lastWorld); });
document.getElementById('btnToggleJson').addEventListener('click', ()=>{ document.getElementById('jsonBlock').classList.toggle('hidden'); });
document.getElementById('btnSave').addEventListener('click', ()=>{ if(lastWorld) saveLocal(lastWorld); });
document.getElementById('btnLoad').addEventListener('click', ()=>{ const w=loadLocal(); if(w) applyWorld(w); });
document.getElementById('btnPrint').addEventListener('click', ()=> window.print());
// 初期プレースホルダ生成
window.addEventListener('DOMContentLoaded', ()=>{
document.getElementById('worldName').value = '運命の剣界';
document.getElementById('themes').value = '古代遺跡 風の精霊 砂漠 旅人ギルド';
generate(true);
});
</script>
</body>
</html>
ホロライブAIプロンプトサイト.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ホロライブ AIプロンプト生成サイト</title>
<meta name="description" content="ホロライブのメンバー向けに、画像生成AIで使えるプロンプトをワンクリック作成。日英対応/テンプレ/ネガティブ/コピーボタン/履歴保存。" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700;900&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['Noto Sans JP', 'ui-sans-serif', 'system-ui'] },
colors: {
skin: {
base: '#0b1020',
card: '#0f152b',
accent: '#60a5fa',
soft: '#a5b4fc'
}
},
boxShadow: {
glass: '0 8px 30px rgba(0,0,0,.35)'
}
}
}
}
</script>
<style>
html,body{height:100%}
.glass{backdrop-filter: saturate(140%) blur(12px); background: linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.03));}
.chip{border:1px solid rgba(255,255,255,.15);}
.mono{font-feature-settings: "ss01" on, "cv01" on;}
</style>
</head>
<body class="min-h-screen bg-gradient-to-br from-slate-900 via-skin-base to-black text-slate-100 font-sans">
<header class="sticky top-0 z-30 border-b border-white/10 bg-slate-900/70 glass">
<div class="max-w-6xl mx-auto px-4 py-4 flex items-center gap-4">
<div class="size-10 rounded-2xl bg-skin-accent/20 grid place-items-center shadow-glass">
<span class="text-skin-accent font-black">AI</span>
</div>
<div>
<h1 class="text-xl md:text-2xl font-extrabold tracking-tight">ホロライブ AIプロンプト生成</h1>
<p class="text-slate-300 text-sm">メンバーを選ぶ→テンプレを選ぶ→生成! 日/英・ネガティブ・履歴・コピペ完備</p>
</div>
<div class="ms-auto flex items-center gap-3">
<label class="flex items-center gap-2 text-sm"><input id="langToggle" type="checkbox" class="accent-skin-accent"> 英語で出力</label>
<button id="randomBtn" class="px-3 py-2 rounded-xl bg-white/10 hover:bg-white/20 border border-white/10">ランダム</button>
<button id="resetBtn" class="px-3 py-2 rounded-xl bg-white/10 hover:bg-white/20 border border-white/10">リセット</button>
</div>
</div>
</header>
<main class="max-w-6xl mx-auto px-4 py-6 grid lg:grid-cols-2 gap-6">
<!-- 左:入力パネル -->
<section class="glass rounded-2xl p-5 shadow-glass border border-white/10">
<h2 class="font-bold text-lg mb-3">1) メンバー & スタイル設定</h2>
<div class="grid md:grid-cols-2 gap-4">
<div>
<label class="block text-sm mb-1">メンバー</label>
<div class="flex gap-2">
<input id="memberSearch" type="text" placeholder="名前/世代/特徴で検索" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10" />
</div>
<div class="mt-2 max-h-48 overflow-auto pr-1">
<ul id="memberList" class="space-y-1"></ul>
</div>
</div>
<div>
<label class="block text-sm mb-1">テンプレート (用途)</label>
<select id="templateSelect" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10">
<option value="portrait">高品質ポートレート</option>
<option value="fullbody">全身イラスト</option>
<option value="chibi">デフォルメ/ちびキャラ</option>
<option value="vtuber">VTuber配信サムネ</option>
<option value="live2d">Live2D立ち絵</option>
<option value="vrchat">VRChat アバター風</option>
<option value="manga">モノクロ漫画コマ</option>
<option value="poster">キービジュアル/ポスター</option>
<option value="landscape">背景&小さめ人物</option>
</select>
<div class="grid grid-cols-2 gap-2 mt-3">
<label class="text-sm flex items-center gap-2"><input id="nsfwSafe" type="checkbox" checked class="accent-skin-accent"> NSFW禁止</label>
<label class="text-sm flex items-center gap-2"><input id="useNeg" type="checkbox" checked class="accent-skin-accent"> ネガティブ付与</label>
<label class="text-sm flex items-center gap-2"><input id="addPose" type="checkbox" class="accent-skin-accent"> ポーズ指定</label>
<label class="text-sm flex items-center gap-2"><input id="addCamera" type="checkbox" class="accent-skin-accent"> カメラ/レンズ</label>
</div>
</div>
</div>
<div class="grid md:grid-cols-3 gap-4 mt-5">
<div>
<label class="block text-sm mb-1">画風プリセット</label>
<select id="stylePreset" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10">
<option value="anime">アニメ塗り(鮮やか)</option>
<option value="semiReal">セミリアル</option>
<option value="watercolor">水彩/やわらか</option>
<option value="celshade">セルルック</option>
<option value="painterly">厚塗り/絵画風</option>
<option value="3dtoon">3Dトゥーン</option>
</select>
</div>
<div>
<label class="block text-sm mb-1">照明/雰囲気</label>
<select id="moodPreset" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10">
<option value="soft">柔らかい光 / やさしい雰囲気</option>
<option value="dramatic">ドラマチック / リムライト</option>
<option value="studio">スタジオ照明 / クリーン</option>
<option value="sunset">夕焼け / ゴールデンアワー</option>
<option value="night">夜景 / ネオン</option>
</select>
</div>
<div>
<label class="block text-sm mb-1">解像度・比率</label>
<select id="aspectPreset" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10">
<option value="square">正方形(1024×1024)</option>
<option value="portrait">縦長(768×1152)</option>
<option value="landscape">横長(1152×768)</option>
<option value="thumb">サムネ(1280×720)</option>
</select>
</div>
</div>
<div class="mt-5 grid md:grid-cols-2 gap-4">
<div>
<label class="block text-sm mb-1">衣装・小物(任意)</label>
<input id="outfitInput" type="text" placeholder="例: 制服, ライブ衣装, 私服, 王冠, マント" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10" />
</div>
<div>
<label class="block text-sm mb-1">背景・シーン(任意)</label>
<input id="bgInput" type="text" placeholder="例: ステージ, 星空, 教室, サイバーシティ" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10" />
</div>
</div>
<div class="mt-5 grid md:grid-cols-2 gap-4">
<div>
<label class="block text-sm mb-1">モデル/LoRA(任意)</label>
<input id="modelInput" type="text" placeholder="例: anime-v4, AnythingV5, holo_member_lora:0.8" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10" />
</div>
<div>
<label class="block text-sm mb-1">追加キーワード(任意)</label>
<input id="extraInput" type="text" placeholder="例: 1girl, detailed eyes, dynamic lighting" class="w-full px-3 py-2 rounded-xl bg-black/30 border border-white/10" />
</div>
</div>
<div class="mt-6 flex flex-wrap gap-3">
<button id="generateBtn" class="px-5 py-3 rounded-2xl bg-skin-accent text-black font-bold hover:opacity-90">生成</button>
<button id="copyBtn" class="px-5 py-3 rounded-2xl bg-white/10 border border-white/10 hover:bg-white/20">コピー</button>
<button id="saveBtn" class="px-5 py-3 rounded-2xl bg-white/10 border border-white/10 hover:bg-white/20">履歴に保存</button>
<button id="exportBtn" class="px-5 py-3 rounded-2xl bg-white/10 border border-white/10 hover:bg-white/20">JSON書き出し</button>
<button id="importBtn" class="px-5 py-3 rounded-2xl bg-white/10 border border-white/10 hover:bg-white/20">JSON読込</button>
</div>
</section>
<!-- 右:出力/履歴 -->
<section class="space-y-6">
<div class="glass rounded-2xl p-5 shadow-glass border border-white/10">
<h2 class="font-bold text-lg">2) 出力(Positive / Negative / メタ)</h2>
<div class="mt-3 grid gap-3">
<label class="text-sm">Positive Prompt</label>
<textarea id="posOut" rows="6" class="mono w-full px-3 py-2 rounded-xl bg-black/50 border border-white/10" placeholder="ここに生成結果"></textarea>
<label class="text-sm">Negative Prompt</label>
<textarea id="negOut" rows="4" class="mono w-full px-3 py-2 rounded-xl bg-black/50 border border-white/10" placeholder="ここにネガティブ"></textarea>
<div class="grid md:grid-cols-3 gap-3">
<div>
<label class="text-sm">解像度</label>
<input id="metaRes" class="w-full px-3 py-2 rounded-xl bg-black/50 border border-white/10" readonly>
</div>
<div>
<label class="text-sm">推奨CFG/Steps</label>
<input id="metaCfg" class="w-full px-3 py-2 rounded-xl bg-black/50 border border-white/10" value="CFG 6-8 / Steps 28-36">
</div>
<div>
<label class="text-sm">推奨Sampler</label>
<input id="metaSampler" class="w-full px-3 py-2 rounded-xl bg-black/50 border border-white/10" value="DPM++ 2M Karras">
</div>
</div>
</div>
</div>
<div class="glass rounded-2xl p-5 shadow-glass border border-white/10">
<div class="flex items-center gap-3 mb-3">
<h2 class="font-bold text-lg">3) 履歴</h2>
<button id="clearHist" class="ms-auto px-3 py-1.5 text-sm rounded-xl bg-white/10 border border-white/10 hover:bg-white/20">全削除</button>
</div>
<div id="history" class="space-y-3 max-h-72 overflow-auto pr-1"></div>
</div>
</section>
</main>
<footer class="max-w-6xl mx-auto px-4 pb-10 text-slate-400 text-sm">
<div class="glass rounded-2xl p-4 border border-white/10">
<p class="leading-relaxed">注意:本ツールは各メンバーの公式ガイドラインを尊重し、成人向けや誹謗中傷の内容を禁止します。商用利用や二次創作ルールは各社ポリシーをご確認ください。</p>
</div>
</footer>
<script>
// --- データセット(抜粋・追加可) ---
const MEMBERS = [
// JP
{ key:'Tokino Sora', gen:'JP0', color:'#5bc0eb', motifs:['星','リボン'], traits:['清楚','やさしい'], outfit:['セーラー風','リボン'], keywords:['idol','first gen','blue ribbon'], en:true },
{ key:'Shirakami Fubuki', gen:'JP1', color:'#ffffff', motifs:['狐','尻尾'], traits:['元気','明るい'], outfit:['セーラー','マフラー'], keywords:['fox girl','white hair','animal ears'] },
{ key:'Natsuiro Matsuri', gen:'JP1', color:'#f4a261', motifs:['祭','ポニーテール'], traits:['活発','いたずら'], outfit:['体操服','浴衣'], keywords:['cheerful','ponytail'] },
{ key:'Minato Aqua', gen:'JP2', color:'#b388ff', motifs:['メイド','ヘッドドレス'], traits:['ドジっ子','ピンク紫髪'], outfit:['メイド服'], keywords:['maid','twin tails'] },
{ key:'Shion', gen:'JP2', color:'#c084fc', motifs:['魔法','三角帽'], traits:['小悪魔','ツリ目'], outfit:['魔女服'], keywords:['witch','purple hair'] },
{ key:'Nakiri Ayame', gen:'JP2', color:'#ef4444', motifs:['鬼角','和装'], traits:['クール','凛'], outfit:['巫女風'], keywords:['oni horns','kimono style'] },
{ key:'Ookami Mio', gen:'GAMERS', color:'#111827', motifs:['狼','耳'], traits:['頼れる','落ち着き'], outfit:['黒衣装'], keywords:['wolf girl','black outfit'] },
{ key:'Houshou Marine', gen:'JP3', color:'#ef4444', motifs:['海賊帽','錨'], traits:['情熱','大人っぽい'], outfit:['海賊衣装'], keywords:['pirate','captain hat'] },
{ key:'Usada Pekora', gen:'JP3', color:'#93c5fd', motifs:['うさ耳','人参'], traits:['やんちゃ','元気'], outfit:['うさぎパーカー'], keywords:['bunny ears','carrot'] },
{ key:'Shiranui Flare', gen:'JP3', color:'#f59e0b', motifs:['エルフ','耳'], traits:['包容','陽気'], outfit:['冒険者'], keywords:['elf ears','adventurer'] },
{ key:'Shirogane Noel', gen:'JP3', color:'#9ca3af', motifs:['騎士','鎧'], traits:['真面目','力持ち'], outfit:['鎧','マント'], keywords:['knight armor','silver hair'] },
{ key:'Hoshimachi Suisei', gen:'INoNaka/JP', color:'#60a5fa', motifs:['星','アイドル'], traits:['クール','アイドル'], outfit:['青系衣装'], keywords:['star motif','blue idol'] },
{ key:'Amane Kanata', gen:'JP4', color:'#60a5fa', motifs:['天使','羽'], traits:['ストイック'], outfit:['白蒼衣装'], keywords:['angel wings','halo'] },
{ key:'Kiryu Coco', gen:'JP4', color:'#f97316', motifs:['ドラゴン','角'], traits:['豪快'], outfit:['ドラゴンモチーフ'], keywords:['dragon horns','orange hair'] },
{ key:'Tsunomaki Watame', gen:'JP4', color:'#facc15', motifs:['羊','リボン'], traits:['ふわふわ'], outfit:['羊モチーフ'], keywords:['sheep girl','blonde'] },
{ key:'Himemori Luna', gen:'JP4', color:'#f472b6', motifs:['姫','王冠'], traits:['キュート'], outfit:['姫ドレス'], keywords:['princess crown','pink'] },
{ key:'Laplus Darknesss', gen:'HoloX', color:'#6d28d9', motifs:['悪魔','マント'], traits:['いたずら'], outfit:['黒紫コート'], keywords:['devilish','hooded coat'] },
{ key:'Takane Lui', gen:'HoloX', color:'#ef4444', motifs:['スパイ','赤黒'], traits:['クール'], outfit:['スーツ風'], keywords:['spy','red black'] },
{ key:'Sakamata Chloe', gen:'HoloX', color:'#94a3b8', motifs:['シャチ','フード'], traits:['あざとい'], outfit:['白黒フード'], keywords:['orca hoodie','monochrome'] },
{ key:'Hakui Koyori', gen:'HoloX', color:'#fb7185', motifs:['研究','ピンク'], traits:['好奇心'], outfit:['研究白衣'], keywords:['lab coat','pink hair'] },
{ key:'Kazama Iroha', gen:'HoloX', color:'#86efac', motifs:['忍者','刀'], traits:['素直'], outfit:['忍装束'], keywords:['ninja','katana'] },
// EN
{ key:'Mori Calliope', gen:'EN Myth', color:'#ef4444', motifs:['鎌','死神'], traits:['クール'], outfit:['黒×ピンク'], keywords:['reaper scythe','rapper'] },
{ key:'Gawr Gura', gen:'EN Myth', color:'#60a5fa', motifs:['サメ','フード'], traits:['いたずら'], outfit:['サメパーカー'], keywords:['shark hoodie','trident'] },
{ key:'Takanashi Kiara', gen:'EN Myth', color:'#fb923c', motifs:['鳥','オレンジ'], traits:['情熱'], outfit:['アイドル衣装'], keywords:['phoenix','orange hair'] },
{ key:"Ninomae Ina'nis", gen:'EN Myth', color:'#a78bfa', motifs:['触手','本'], traits:['穏やか'], outfit:['修道服風'], keywords:['tentacle motif','violet'] }, <!-- ★ 修正済み:ダブルクォート -->
{ key:'Amelia Watson', gen:'EN Myth', color:'#fbbf24', motifs:['探偵','時計'], traits:['好奇心'], outfit:['探偵コート'], keywords:['detective','magnifying glass'] },
{ key:'Hakos Baelz', gen:'EN Council', color:'#ef4444', motifs:['ネズミ','カオス'], traits:['ハイテンション'], outfit:['赤系衣装'], keywords:['chaos','rat tail'] },
{ key:'IRyS', gen:'EN Project:Hope', color:'#ef5fff', motifs:['天使悪魔','クリスタル'], traits:['希望'], outfit:['黒×赤×紫'], keywords:['nephilim','crystal'] },
// ID (抜粋)
{ key:'Kobo Kanaeru', gen:'ID3', color:'#60a5fa', motifs:['雨','水'], traits:['やんちゃ'], outfit:['青系パーカー'], keywords:['rain theme','blue hair'] },
];
// ネガティブテンプレ
const NEGATIVE = 'nsfw, nude, lowres, low quality, worst quality, extra fingers, deformed hands, poorly drawn, watermark, logo, signature, text, blurry, jpeg artifacts, bad anatomy, out of frame';
// UI取得
const memberSearch = document.getElementById('memberSearch');
const memberList = document.getElementById('memberList');
const templateSelect = document.getElementById('templateSelect');
const stylePreset = document.getElementById('stylePreset');
const moodPreset = document.getElementById('moodPreset');
const aspectPreset = document.getElementById('aspectPreset');
const outfitInput = document.getElementById('outfitInput');
const bgInput = document.getElementById('bgInput');
const modelInput = document.getElementById('modelInput');
const extraInput = document.getElementById('extraInput');
const langToggle = document.getElementById('langToggle');
const nsfwSafe = document.getElementById('nsfwSafe');
const useNeg = document.getElementById('useNeg');
const addPose = document.getElementById('addPose');
const addCamera = document.getElementById('addCamera');
const generateBtn = document.getElementById('generateBtn');
const copyBtn = document.getElementById('copyBtn');
const saveBtn = document.getElementById('saveBtn');
const exportBtn = document.getElementById('exportBtn');
const importBtn = document.getElementById('importBtn');
const randomBtn = document.getElementById('randomBtn');
const resetBtn = document.getElementById('resetBtn');
const posOut = document.getElementById('posOut');
const negOut = document.getElementById('negOut');
const metaRes = document.getElementById('metaRes');
const metaCfg = document.getElementById('metaCfg');
const metaSampler = document.getElementById('metaSampler');
const history = document.getElementById('history');
const clearHist = document.getElementById('clearHist');
// 状態
let selectedMember = null;
// 初期描画
function renderMembers(filter=''){
const f = filter.toLowerCase().trim();
memberList.innerHTML = '';
MEMBERS.filter(m=>{
const s = [m.key, m.gen, ...(m.motifs||[]), ...(m.traits||[]), ...(m.keywords||[])].join(' ').toLowerCase();
return !f || s.includes(f);
}).forEach(m=>{
const li = document.createElement('li');
li.className = 'chip rounded-xl px-3 py-2 flex items-center gap-2 hover:bg-white/5 cursor-pointer';
li.innerHTML = `<span class="inline-block size-3 rounded-full" style="background:${m.color}"></span><span class="font-medium">${m.key}</span><span class="text-xs text-slate-400">(${m.gen})</span>`;
// クリック選択
li.addEventListener('click',()=>{ selectedMember = m; highlightSelection(li); });
// ★ キーボード対応
li.setAttribute('tabindex', '0');
li.setAttribute('role', 'button');
li.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectedMember = m;
highlightSelection(li);
}
});
// 既選択のハイライト維持
if (selectedMember && selectedMember.key === m.key) li.classList.add('bg-white/10');
memberList.appendChild(li);
})
}
function highlightSelection(activeLi){
[...memberList.children].forEach(li=> li.classList.remove('bg-white/10'));
activeLi.classList.add('bg-white/10');
}
memberSearch.addEventListener('input', e=> renderMembers(e.target.value));
// アスペクト → 表示解像度
function applyAspect(){
const map = {
square: '1024x1024',
portrait: '768x1152',
landscape: '1152x768',
thumb: '1280x720'
};
metaRes.value = map[aspectPreset.value] || '1024x1024';
}
aspectPreset.addEventListener('change', applyAspect);
applyAspect();
// テンプレ文
function templateText(id, name){
const jp = {
portrait: `${name}の高品質なバストアップポートレート, 視線はこちら, きらめく瞳, 細密な髪, 肌の質感,`,
fullbody: `${name}の全身イラスト, ダイナミックなポーズ,`,
chibi: `${name}のデフォルメちびキャラ, 等身2~3,`,
vtuber: `${name}の配信サムネ風イラスト, サイドライト, 目を引くタイポの余白,`,
live2d: `${name}のLive2D立ち絵, 胸上〜腰上, レイヤー分けしやすいシンプル背景,`,
vrchat: `${name}のVRChatアバター風デザイン, 全身, セルルック,`,
manga: `${name}が登場するモノクロ漫画コマ, スクリーントーン,`,
poster: `${name}のキービジュアル, 迫力のある構図,`,
landscape: `背景美術の中に小さく${name}, 遠景, 雰囲気重視,`
};
const en = {
portrait: `high-quality bust portrait of ${name}, looking at viewer, sparkling eyes, detailed hair, skin texture,`,
fullbody: `full-body illustration of ${name}, dynamic pose,`,
chibi: `super-deformed chibi ${name}, 2~3 heads tall,`,
vtuber: `stream thumbnail style illustration of ${name}, side lighting, space for bold typography,`,
live2d: `Live2D standing illustration of ${name}, bust to waist-up, simple background for easy layer separation,`,
vrchat: `VRChat avatar style design of ${name}, full body, toon shading,`,
manga: `monochrome manga panel featuring ${name}, screen tones,`,
poster: `key visual poster of ${name}, impactful composition,`,
landscape: `cinematic background with small ${name} in scene, distant view, mood-focused,`
}
return (langToggle.checked? en : jp)[id] || '';
}
// スタイル・ムード
function styleText(id){
const jp = {
anime: 'アニメ塗り, 高発色, クリアライン,',
semiReal: 'セミリアル, 繊細なライティング, 細密質感,',
watercolor: '水彩風, 柔らかい発色, にじみ,',
celshade: 'セルシェーディング, シャープな影,',
painterly: '厚塗り, 筆致, 奥行き,',
threetoon: '3Dトゥーン, ノンフォトリアル,'
};
const en = {
anime: 'anime coloring, vivid, clean linework,',
semiReal: 'semi-realistic, delicate lighting, fine textures,',
watercolor: 'watercolor style, soft colors, bleeding,',
celshade: 'cel-shaded, sharp shadows,',
painterly: 'painterly, visible brush strokes, depth,',
threetoon: '3D toon, non-photorealistic,'
};
const key = id === '3dtoon' ? 'threetoon' : id;
return (langToggle.checked? en : jp)[key] || '';
}
function moodText(id){
const jp = {
soft: 'やわらかい環境光, 穏やかな表情,',
dramatic: 'ドラマチックライティング, リムライト, コントラスト強,',
studio: 'スタジオ照明, 均一な光, 背景シンプル,',
sunset: '夕焼けの光, ゴールデンアワー,',
night: '夜景ネオン, グロー, 反射,'
};
const en = {
soft: 'soft ambient light, gentle expression,',
dramatic: 'dramatic lighting, rim light, high contrast,',
studio: 'studio lighting, even illumination, simple background,',
sunset: 'sunset glow, golden hour,',
night: 'neon nightscape, glow, reflections,'
};
return (langToggle.checked? en : jp)[id] || '';
}
function poseText(){
const jp = ['ピースサイン','片手を胸に','ほほえみ','ダンスポーズ','跳躍'];
const en = ['peace sign','hand on chest','gentle smile','dance pose','jumping'];
const arr = langToggle.checked? en : jp;
return arr[Math.floor(Math.random()*arr.length)]
}
function cameraText(){
const jp = ['50mmレンズ相当','f1.8被写界深度','極小ノイズ','シャープ'];
const en = ['50mm lens equivalent','f1.8 shallow depth of field','very low noise','sharp'];
const arr = langToggle.checked? en : jp;
return arr.join(', ');
}
function buildPositive(){
if(!selectedMember){
alert('メンバーを選択してください');
return '';
}
const name = selectedMember.key;
const tp = templateText(templateSelect.value, name);
const st = styleText(stylePreset.value);
const md = moodText(moodPreset.value);
const ex = extraInput.value?.trim();
const pieces = [tp, st, md];
if(outfitInput.value.trim()) pieces.push(langToggle.checked? `outfit: ${outfitInput.value.trim()}` : `衣装: ${outfitInput.value.trim()}`);
if(bgInput.value.trim()) pieces.push(langToggle.checked? `background: ${bgInput.value.trim()}` : `背景: ${bgInput.value.trim()}`);
if(addPose.checked) pieces.push(langToggle.checked? `pose: ${poseText()}` : `ポーズ: ${poseText()}`);
if(addCamera.checked) pieces.push(cameraText());
// メンバー特徴
const motif = (selectedMember.motifs||[]).join(', ');
const trait = (selectedMember.traits||[]).join(', ');
const kw = (selectedMember.keywords||[]).join(', ');
const profJP = `特徴: ${motif}, 性格: ${trait}, キーワード: ${kw}`;
const profEN = `motifs: ${motif}, traits: ${trait}, keywords: ${kw}`;
pieces.push(langToggle.checked? profEN : profJP);
if(ex) pieces.push(ex);
// モデル/LoRA
if(modelInput.value.trim()) pieces.push(`[${modelInput.value.trim()}]`);
// NSFW安全
if(nsfwSafe.checked){
pieces.push(langToggle.checked? 'sfw, wholesome' : '全年齢, 健全');
}
return pieces.filter(Boolean).join(' ');
}
function buildNegative(){
return useNeg.checked ? NEGATIVE : '';
}
function generate(){
const pos = buildPositive();
if (!pos) return;
posOut.value = pos;
negOut.value = buildNegative();
}
function copyAll(){
const txt = `Positive:\n${posOut.value}\n\nNegative:\n${negOut.value}\n\nMeta:\nres=${metaRes.value}, ${metaCfg.value}, sampler=${metaSampler.value}`;
navigator.clipboard.writeText(txt).then(()=>{ toast('コピーしました'); });
}
function toast(msg){
const t = document.createElement('div');
t.textContent = msg;
t.className = 'fixed bottom-5 left-1/2 -translate-x-1/2 px-4 py-2 rounded-xl bg-white/10 border border-white/10 shadow-glass';
document.body.appendChild(t);
setTimeout(()=> t.remove(), 1600);
}
function saveHistory(){
const item = {
time: new Date().toISOString(),
member: selectedMember?.key || '(未選択)',
template: templateSelect.value,
style: stylePreset.value,
mood: moodPreset.value,
aspect: aspectPreset.value,
pos: posOut.value,
neg: negOut.value,
meta: { res: metaRes.value, cfg: metaCfg.value, sampler: metaSampler.value }
};
const arr = JSON.parse(localStorage.getItem('holo_prompt_hist')||'[]');
arr.unshift(item);
// 保存上限:最新から最大100件(文字列長で切るとJSONが壊れるため件数で制御)
const MAX_ITEMS = 100;
localStorage.setItem('holo_prompt_hist', JSON.stringify(arr.slice(0, MAX_ITEMS)));
renderHistory();
toast('履歴に保存しました');
}
function renderHistory(){
const arr = JSON.parse(localStorage.getItem('holo_prompt_hist')||'[]');
history.innerHTML = '';
arr.forEach((it, idx)=>{
const card = document.createElement('div');
card.className = 'rounded-xl p-3 border border-white/10 bg-black/30';
card.innerHTML = `
<div class='flex items-center gap-2 mb-2'>
<span class='text-slate-300 text-sm'>${new Date(it.time).toLocaleString()}</span>
<span class='ms-auto text-xs chip rounded-lg px-2 py-0.5'>${it.member}</span>
</div>
<div class='text-xs text-slate-400 mb-2'>${it.template} / ${it.style} / ${it.mood} / ${it.aspect}</div>
<details class='mb-2'>
<summary class='cursor-pointer text-skin-soft'>Positive</summary>
<pre class='whitespace-pre-wrap text-sm'>${escapeHtml(it.pos)}</pre>
</details>
<details class='mb-2'>
<summary class='cursor-pointer text-skin-soft'>Negative</summary>
<pre class='whitespace-pre-wrap text-sm'>${escapeHtml(it.neg)}</pre>
</details>
<div class='flex gap-2'>
<button class='px-3 py-1.5 rounded-lg bg-white/10 border border-white/10 hover:bg-white/20' data-act='load' data-idx='${idx}'>読み込む</button>
<button class='px-3 py-1.5 rounded-lg bg-white/10 border border-white/10 hover:bg-white/20' data-act='copy' data-idx='${idx}'>コピー</button>
<button class='px-3 py-1.5 rounded-lg bg-white/10 border border-white/10 hover:bg-white/20' data-act='del' data-idx='${idx}'>削除</button>
</div>
`;
card.addEventListener('click', e=>{
const btn = e.target.closest('button');
if(!btn) return;
const { act, idx } = btn.dataset;
const list = JSON.parse(localStorage.getItem('holo_prompt_hist')||'[]');
if(act==='del'){
list.splice(idx,1);
localStorage.setItem('holo_prompt_hist', JSON.stringify(list));
renderHistory();
}else if(act==='copy'){
navigator.clipboard.writeText(`Positive:\n${list[idx].pos}\n\nNegative:\n${list[idx].neg}`);
toast('コピーしました');
}else if(act==='load'){
loadHistoryItem(list[idx]);
}
})
history.appendChild(card);
})
}
function loadHistoryItem(it){
selectedMember = MEMBERS.find(m=> m.key === it.member) || null;
templateSelect.value = it.template;
stylePreset.value = it.style;
moodPreset.value = it.mood;
aspectPreset.value = it.aspect; applyAspect();
posOut.value = it.pos; negOut.value = it.neg;
toast('履歴を読み込みました');
renderMembers(memberSearch.value);
}
clearHist.addEventListener('click', ()=>{
localStorage.removeItem('holo_prompt_hist');
renderHistory();
toast('履歴を削除しました');
});
// JSON入出力
exportBtn.addEventListener('click', ()=>{
const data = localStorage.getItem('holo_prompt_hist')||'[]';
const blob = new Blob([data], {type:'application/json'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'holo_prompt_history.json';
a.click();
});
importBtn.addEventListener('click', ()=>{
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'application/json';
inp.onchange = () => {
const file = inp.files[0];
if(!file) return;
const reader = new FileReader();
reader.onload = e => {
try{
const arr = JSON.parse(e.target.result);
if(Array.isArray(arr)){
localStorage.setItem('holo_prompt_hist', JSON.stringify(arr));
renderHistory();
toast('JSONを読み込みました');
} else { alert('不正なJSONです'); }
}catch(err){ alert('読み込み失敗: '+err.message); }
};
reader.readAsText(file);
};
inp.click();
});
// ランダム&リセット
randomBtn.addEventListener('click', ()=>{
selectedMember = MEMBERS[Math.floor(Math.random()*MEMBERS.length)];
renderMembers(memberSearch.value);
generate();
toast('ランダム選択しました');
});
resetBtn.addEventListener('click', ()=>{
memberSearch.value=''; selectedMember=null; renderMembers('');
templateSelect.value='portrait'; stylePreset.value='anime'; moodPreset.value='soft'; aspectPreset.value='square'; applyAspect();
outfitInput.value=''; bgInput.value=''; modelInput.value=''; extraInput.value='';
posOut.value=''; negOut.value='';
toast('初期化しました');
});
// 生成/コピー/保存
generateBtn.addEventListener('click', generate);
copyBtn.addEventListener('click', copyAll);
saveBtn.addEventListener('click', saveHistory);
// 言語切替時に再生成
langToggle.addEventListener('change', ()=>{ if(posOut.value) generate(); });
// HTMLエスケープ
function escapeHtml(str=''){
return str.replace(/[&<>"]/g, s=> ({'&':'&','<':'<','>':'>','"':'"'}[s]));
}
// 初期描画
renderMembers('');
renderHistory();
</script>
</body>
</html>
ブラウザゲーム.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>Endless Dodge ULTRA - Bullet & Boss</title>
<style>
:root{
--bg1:#070816; --bg2:#0f1b38; --accent:#6ee7ff; --accent2:#9bffb7; --danger:#ff6b6b; --panel:rgba(255,255,255,.08);
--text:#eaf2ff; --muted:#b5c0d0; --gold:#ffd166; --purple:#c4a7ff; --emerald:#86efac;
}
*{box-sizing:border-box}
html,body{height:100%;}
body{ margin:0; font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans JP";
color:var(--text);
background: radial-gradient(1200px 800px at 20% 10%, #1b2444 0%, var(--bg1) 50%), linear-gradient(160deg, var(--bg2), var(--bg1));
overflow:hidden;}
.wrap{position:fixed; inset:0; display:grid; grid-template-rows:auto 1fr auto;}
header, footer{display:flex; gap:.75rem; align-items:center; justify-content:space-between; padding:.6rem .9rem; backdrop-filter: blur(6px); background:linear-gradient( to bottom, rgba(255,255,255,.06), rgba(255,255,255,.02)); border-bottom:1px solid rgba(255,255,255,.08)}
header h1{font-size:1rem; margin:0; letter-spacing:.05em; font-weight:700}
header .right{display:flex; gap:.5rem; align-items:center}
.pill{ pointer-events:auto; border:1px solid rgba(255,255,255,.14); background:var(--panel); padding:.5rem .8rem; border-radius:999px; font-size:.9rem; color:var(--text); cursor:pointer; user-select:none; transition:transform .08s ease}
.pill:active{ transform:scale(.97)}
#gamePanel{ position:relative; display:grid; place-items:center;}
canvas{ width: min(94vw, 800px); aspect-ratio: 9/16; border-radius: 18px; box-shadow: 0 10px 40px rgba(0,0,0,.5), inset 0 0 0 1px rgba(255,255,255,.06);
background: radial-gradient(600px 500px at 50% 10%, rgba(110,231,255,.12), transparent 60%), linear-gradient(180deg, rgba(255,255,255,.03), rgba(255,255,255,.02));}
.hud{ position:absolute; inset:0; pointer-events:none;}
.row{ display:flex; justify-content:space-between; align-items:center; padding:10px;}
.score{ font-variant-numeric: tabular-nums; font-size: clamp(18px, 3.5vw, 28px); text-shadow:0 1px 0 rgba(0,0,0,.5)}
.muted{ color: var(--muted)}
.center{ position:absolute; inset:0; display:grid; place-items:center;}
.card{ pointer-events:auto; background:rgba(7,8,22,.92); border:1px solid rgba(255,255,255,.14); border-radius:16px; padding:20px; width:min(92vw, 480px); box-shadow:0 20px 60px rgba(0,0,0,.6)}
.card h2{ margin:0 0 8px; font-size:1.25rem}
.card p{ margin:.25rem 0; color:var(--muted)}
.btn{ display:inline-flex; align-items:center; justify-content:center; gap:.5rem; padding:.7rem 1rem; border-radius:12px; border:1px solid rgba(255,255,255,.16); background:linear-gradient(180deg, rgba(255,255,255,.12), rgba(255,255,255,.06)); color:var(--text); cursor:pointer; font-weight:600}
.btn:hover{ filter:brightness(1.08)}
.btn.primary{ border-color: rgba(110,231,255,.5); box-shadow: 0 0 30px rgba(110,231,255,.15) inset}
.grid{ display:grid; grid-template-columns:1fr 1fr; gap:.6rem}
.touch{ position:absolute; inset:auto 0 10px 0; display:flex; justify-content:center; gap:12px; pointer-events:auto}
.touch button{ width:clamp(64px, 22vw, 106px); aspect-ratio:1/1; border-radius:16px; border:1px solid rgba(255,255,255,.14); background:var(--panel); color:var(--text); font-weight:700; font-size:clamp(16px, 4.5vw, 22px); text-shadow:0 1px 0 rgba(0,0,0,.35)}
.badge{border:1px solid rgba(255,255,255,.14); background:var(--panel); padding:.35rem .6rem; border-radius:999px; font-size:.75rem}
.toast{ position:absolute; left:50%; top:14%; transform:translateX(-50%); pointer-events:none; opacity:0; transition: opacity .2s, transform .2s; background:rgba(0,0,0,.5); border:1px solid rgba(255,255,255,.18); padding:.35rem .7rem; border-radius:10px; font-weight:700}
.toast.show{ opacity:1; transform:translate(-50%, -6px)}
footer{ border-top:1px solid rgba(255,255,255,.08); border-bottom:none; justify-content:center}
a{ color:var(--accent)}
dialog{ border:none; border-radius:16px; background:rgba(7,8,22,.96); color:var(--text); width:min(92vw,560px); }
dialog::backdrop{ background:rgba(0,0,0,.6); }
.field{ display:flex; justify-content:space-between; align-items:center; gap:10px; padding:8px 0; }
.range{ width:58% }
.switch{ appearance:none; width:42px; height:24px; border-radius:999px; background:#445; position:relative; outline:none; cursor:pointer; }
.switch:checked{ background:#2aa }
.switch::after{ content:""; position:absolute; top:3px; left:3px; width:18px; height:18px; border-radius:50%; background:#fff; transition:left .15s}
.switch:checked::after{ left:21px }
.shop-item{ display:grid; grid-template-columns:1fr auto; gap:.4rem; align-items:center; padding:.5rem; border:1px solid rgba(255,255,255,.12); border-radius:12px; margin:.35rem 0; }
.chip{ padding:.2rem .5rem; border:1px solid rgba(255,255,255,.16); border-radius:999px; font-size:.75rem; }
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Endless Dodge <span class="badge">ULTRA</span></h1>
<div class="right">
<span class="badge">💎 <span id="wallet">0</span></span>
<button id="btnShop" class="pill" aria-label="shop">🛒 ショップ</button>
<button id="btnSkins" class="pill" aria-label="skins">🎨 スキン</button>
<button id="btnPause" class="pill" aria-label="pause">⏸</button>
<button id="btnSound" class="pill" aria-label="sound">🔊</button>
<button id="btnSettings" class="pill" aria-label="settings">⚙</button>
</div>
</header>
<div id="gamePanel">
<canvas id="game" width="360" height="640" aria-label="game canvas"></canvas>
<div class="hud">
<div class="row">
<div class="score">
<span id="score">0</span> pts
· <span class="muted">Best:</span> <span id="best">0</span>
· <span class="muted">Combo:</span> <span id="combo">x1.0</span>
· <span class="muted">Stage:</span> <span id="stage">1</span>
</div>
<div class="row" style="gap:.5rem">
<span class="badge" id="badges">⛨ 0 · 🧲 0 · ⏳ 0</span>
</div>
</div>
<div class="center" id="overlayStart">
<div class="card">
<h2>避けて、撃って、強化して、ボスを倒せ!</h2>
<p>← → / A・D で移動。<strong>Spaceでショット</strong>、<kbd>Shift</kbd>でダッシュ(無敵0.4s)。</p>
<p>パワーアップ:⛨シールド / 🧲マグネット / ⏳スロウ。コンボでスコア倍率UP。</p>
<p>ステージごとにボス戦。ボスは弾幕を発射。ショットでHPを削ろう。</p>
<div class="grid" style="margin-top:10px">
<button class="btn primary" id="btnStart">▶ ゲーム開始</button>
<button class="btn" id="btnHow">❓ 操作</button>
</div>
<div style="margin-top:10px" class="muted" id="missions"></div>
</div>
</div>
<div class="center" id="overlayBoss" style="display:none">
<div class="card" style="text-align:center">
<h2>⚠ B O S S ⚠</h2>
<p>弾幕を避けつつ、Spaceで撃て!Shiftダッシュも活用。</p>
<button class="btn primary" id="btnBossGo">戦闘開始</button>
</div>
</div>
<div class="center" id="overlayGameOver" style="display:none">
<div class="card">
<h2>ゲームオーバー</h2>
<p>スコア: <strong id="finalScore">0</strong> / ベスト: <strong id="finalBest">0</strong> / 💎<strong id="earned">0</strong></p>
<p>達成:<span id="finalMissions" class="muted">-</span></p>
<div class="grid" style="margin-top:10px">
<button class="btn primary" id="btnRetry">↻ リトライ</button>
<button class="btn" id="btnHome">⌂ タイトル</button>
</div>
</div>
</div>
<div class="touch" id="touchControls" aria-hidden="true">
<button id="leftBtn" aria-label="left">⟵</button>
<button id="dashBtn" aria-label="dash">⇧</button>
<button id="rightBtn" aria-label="right">⟶</button>
</div>
<div class="toast" id="toast">Ready</div>
</div>
</div>
<footer>
<small class="muted">© 2025 Endless Dodge ULTRA · 図形のみ · ローカル保存(設定/進行/ウォレット/実績)</small>
</footer>
</div>
<!-- Settings / Shop / Skins (unchanged structure) -->
<dialog id="dlgSettings">
<form method="dialog" style="padding:16px">
<h3 style="margin:0 0 8px">設定</h3>
<div class="field"><span>難易度(速度倍率)</span><input class="range" id="rangeSpeed" type="range" min="0.8" max="1.6" step="0.05"></div>
<div class="field"><span>画面シェイク</span><input id="chkShake" class="switch" type="checkbox"></div>
<div class="field"><span>色弱モード(高コントラスト)</span><input id="chkCB" class="switch" type="checkbox"></div>
<div class="field"><span>省エネ描画(★数減少)</span><input id="chkEco" class="switch" type="checkbox"></div>
<div class="field"><span>操作ヒントの表示</span><input id="chkHints" class="switch" type="checkbox"></div>
<div style="display:flex; gap:.5rem; justify-content:flex-end; margin-top:10px">
<button class="btn" value="cancel">閉じる</button>
<button class="btn primary" id="btnSaveSettings" value="default">保存</button>
</div>
</form>
</dialog>
<dialog id="dlgShop"><form method="dialog" style="padding:16px"><h3 style="margin:0 0 8px">ショップ</h3><p class="muted">💎はプレイ後にスコアから換算(100pts ≒ 1💎)。</p><div id="shopList"></div><div style="display:flex; gap:.5rem; justify-content:flex-end; margin-top:10px"><button class="btn" value="cancel">閉じる</button></div></form></dialog>
<dialog id="dlgSkins"><form method="dialog" style="padding:16px"><h3 style="margin:0 0 8px">スキン</h3><div id="skinList"></div><div style="display:flex; gap:.5rem; justify-content:flex-end; margin-top:10px"><button class="btn" value="cancel">閉じる</button></div></form></dialog>
<script>
// ===== Utilities & Persistence =====
const clamp=(v,min,max)=>Math.max(min,Math.min(max,v));
const rand=(a,b)=>Math.random()*(b-a)+a; const choice=a=>a[(Math.random()*a.length)|0];
const storage={ get(k,def){ try{return JSON.parse(localStorage.getItem(k)) ?? def}catch{ return def }}, set(k,v){ localStorage.setItem(k, JSON.stringify(v)); } };
const SAVE={ best:'ultra-best', opts:'ultra-opts', stats:'ultra-stats', wallet:'ultra-wallet', upgrades:'ultra-upgrades', missions:'ultra-missions', skin:'ultra-skin' };
const opts = Object.assign({ speedMul:1.0, shake:true, colorblind:false, eco:false, hints:true }, storage.get(SAVE.opts, {})); storage.set(SAVE.opts, opts);
const wallet = { gems: storage.get(SAVE.wallet, 0) };
function addGems(n){ wallet.gems = Math.max(0, Math.floor(wallet.gems + n)); storage.set(SAVE.wallet, wallet.gems); walletEl.textContent = wallet.gems; }
const upgrades = Object.assign({ startShield:0, magnetDur:0, dashCD:0, scoreMul:0, extraLife:0 }, storage.get(SAVE.upgrades, {}));
function uLevel(name){ return upgrades[name]||0 } function saveUpgrades(){ storage.set(SAVE.upgrades, upgrades); buildShop(); }
const skins = [
{id:'default', name:'Default', cost:0, color:'#eaf2ff'},
{id:'neon', name:'Neon Blue', cost:50, color:'#7ee0ff'},
{id:'sun', name:'Sun Gold', cost:80, color:'#ffd166'},
{id:'void', name:'Void Purple', cost:120, color:'#c4a7ff'},
{id:'leaf', name:'Leaf Green', cost:120, color:'#86efac'}
];
let currentSkin = storage.get(SAVE.skin, 'default');
function toast(msg, t=1200){ const el=document.getElementById('toast'); el.textContent=msg; el.classList.add('show'); clearTimeout(el._t); el._t=setTimeout(()=>el.classList.remove('show'), t); }
// ===== Audio =====
const AudioKit=(()=>{ let ctx, enabled=false; function ensure(){ if(!ctx){ const C=window.AudioContext||window.webkitAudioContext; if(C){ ctx=new C(); }} return ctx }
function beep(freq=440, dur=0.08, type='sine', gain=0.02){ if(!enabled) return; const c=ensure(); if(!c) return; const o=c.createOscillator(); const g=c.createGain(); o.type=type; o.frequency.setValueAtTime(freq,c.currentTime); g.gain.setValueAtTime(gain,c.currentTime); o.connect(g).connect(c.destination); const t=c.currentTime; o.start(t); o.stop(t+dur); }
function arpeggio(){ if(!enabled) return; const c=ensure(); if(!c) return; const base=220; const seq=[0,4,7,12,7,4]; seq.forEach((st,i)=>{ const o=c.createOscillator(); const g=c.createGain(); o.type='triangle'; o.frequency.setValueAtTime(base*Math.pow(2,st/12), c.currentTime + i*0.08); g.gain.setValueAtTime(0.02, c.currentTime + i*0.08); o.connect(g).connect(c.destination); o.start(c.currentTime + i*0.08); o.stop(c.currentTime + i*0.08 + 0.1); }); }
return{ enable(){ enabled=true; ensure(); }, disable(){ enabled=false; }, toggle(){ enabled=!enabled; if(enabled) ensure(); return enabled; }, hit(){ beep(120,0.18,'square',0.05); }, coin(){ beep(880,0.07,'triangle',0.03); }, tick(){ beep(660,0.02,'sine',0.015); }, power(){ beep(520,0.1,'sawtooth',0.04); }, dash(){ beep(240,0.06,'square',0.05); }, fanfare(){ arpeggio(); }, shoot(){ beep(720,0.04,'square',0.03); } }
})();
// ===== Canvas & World =====
const canvas=document.getElementById('game'); const ctx=canvas.getContext('2d');
let dpr=1; function resize(){ dpr=Math.max(1, Math.min(2, window.devicePixelRatio||1)); const w=canvas.clientWidth; const h=canvas.clientHeight; canvas.width=Math.round(w*dpr); canvas.height=Math.round(h*dpr); ctx.setTransform(dpr,0,0,dpr,0,0); }
new ResizeObserver(resize).observe(canvas); window.addEventListener('orientationchange', resize); resize();
const state={ running:false, over:false, t:0, score:0, best: storage.get(SAVE.best, 0), baseSpeed:120, speed:120, worldW:360, worldH:640, combo:1, comboTime:0, slowed:0, stage:1, boss:false };
const fx={ shakeTime:0, shakeAmp:0 };
const starCount = opts.eco? 40 : 90; const stars=[...Array(starCount)].map(()=>({x:rand(0,360), y:rand(0,640), s:rand(0.5,2), sp:rand(10,40)}));
const player={ x:180, y:560, r:12, vx:0, speed:270, color:'#eaf2ff', alive:true, flash:0, shield:0, magnet:0, dashCD:0, dashT:0, extra:0, fireCD:0 };
const obstacles=[]; const coins=[]; const lasers=[]; const particles=[]; const powerups=[]; const bullets=[]; // boss bullets
const pbullets=[]; // player bullets
// ===== Spawning =====
let lastSpawn=0, spawnInt=0.9; let lastLaser=0, laserInt=6.0; let stageTime=0, nextBossAt=28; // seconds
function spawnBlockRow(yOff=-40){ const gap = clamp(140 - state.t*0.02, 70, 150); const blockW = rand(40, 90); const leftW = rand(10, state.worldW - gap - blockW - 10); const rightX = leftW + gap + blockW; const moving = Math.random()<clamp(0.08 + state.t*0.0006, 0.08, 0.4); const speed = moving? rand(30, 90)* (Math.random()<0.5?-1:1) : 0; obstacles.push({x:0, y:yOff, w:leftW, h:16, vx:0}); obstacles.push({x:rightX, y:yOff, w: state.worldW - rightX, h:16, vx:0}); if(moving){ obstacles.push({x:leftW+4, y:yOff-18, w: blockW-8, h:10, vx:speed}); }
const cx = leftW + gap/2 + rand(-gap*0.35, gap*0.35); const cluster = (Math.random()<0.6) ? 4 : 1; for(let i=0;i<cluster;i++) coins.push({x:cx + (cluster>1?(i-1.5)*10:0), y:yOff-20 - i*8, r:6, vy:0}); if(Math.random()<0.22) powerups.push({x:cx+rand(-gap*0.3,gap*0.3), y:yOff-36, r:8, kind: choice(['shield','magnet','slow'])}); }
function spawnLaser(){ const side = Math.random()<0.5? 'L':'R'; const x = side==='L'? -40 : state.worldW+40; const dir = side==='L'? 1 : -1; lasers.push({x, y: rand(120, state.worldH-160), w:120, h:10, vx: 170*dir, life: 4}); }
// ===== Boss & Bullet Hell =====
let boss=null; let patternT=0, patternId=0, spiralAng=0; // patterns
function enterBoss(){ state.boss=true; show(bossOverlay); }
function startBoss(){ hide(bossOverlay); boss = { x: state.worldW/2, y: 160, r: 22, hp: 6 + state.stage*2, vx: 80 }; bullets.length=0; patternT=0; patternId=0; spiralAng=0; }
function bossShootFan(){ // 扇状(自機狙い)
const dx = player.x - boss.x; const dy = (player.y - boss.y); const base = Math.atan2(dy, dx); const n=5; const spread=0.6; for(let i=0;i<n;i++){ const a = base + (i-(n-1)/2)*spread/n; bullets.push({x:boss.x, y:boss.y, r:4, vx:Math.cos(a)*160, vy:Math.sin(a)*160}); }
}
function bossShootRing(){ // 全方位リング
const n=14; for(let i=0;i<n;i++){ const a = (i/n)*Math.PI*2; bullets.push({x:boss.x, y:boss.y, r:3.5, vx:Math.cos(a)*120, vy:Math.sin(a)*120}); }
}
function bossShootSpiral(){ // 渦巻き
const a1 = spiralAng; const a2 = spiralAng + Math.PI; spiralAng += 0.35; bullets.push({x:boss.x, y:boss.y, r:3.5, vx:Math.cos(a1)*150, vy:Math.sin(a1)*150}); bullets.push({x:boss.x, y:boss.y, r:3.5, vx:Math.cos(a2)*150, vy:Math.sin(a2)*150}); }
function updateBoss(dt){ if(!boss) return; boss.x += boss.vx*dt; if(boss.x<40){ boss.x=40; boss.vx=Math.abs(boss.vx);} if(boss.x>state.worldW-40){ boss.x=state.worldW-40; boss.vx=-Math.abs(boss.vx);} // pattern timeline
patternT += dt; if(patternId===0){ if(patternT>0.6){ bossShootFan(); patternT=0; if(Math.random()<0.25) patternId=1; } }
else if(patternId===1){ bossShootSpiral(); if(patternT>2.4){ patternT=0; patternId=2; } }
else if(patternId===2){ if(patternT>1.0){ bossShootRing(); patternT=0; if(Math.random()<0.5) patternId=0; else patternId=1; } }
// move bullets
for(const b of bullets){ b.x += b.vx*dt; b.y += b.vy*dt; }
for(let i=bullets.length-1;i>=0;i--){ const b=bullets[i]; if(b.x<-40||b.x>state.worldW+40||b.y<-40||b.y>state.worldH+60) bullets.splice(i,1); }
// hit player
for(const b of bullets){ const dx=player.x-b.x, dy=player.y-b.y; if(dx*dx+dy*dy <= (player.r+b.r)*(player.r+b.r)){ if(player.dashT<=0){ if(player.shield>0){ player.shield-=1; emit(player.x,player.y,12,'#6ee7ff'); } else if(player.extra>0){ player.extra--; toast('Extra Life!'); } else { return gameOver(); } } } }
}
function damageBoss(dmg=1){ if(!boss) return; boss.hp-=dmg; emit(boss.x,boss.y,16,'#c4a7ff'); if(boss.hp<=0){ boss=null; state.boss=false; state.stage++; stageTime=0; nextBossAt = clamp(26 - state.stage, 18, 26); addScore(200); toast(`Stage ${state.stage} クリア!`); AudioKit.fanfare(); }
}
function emit(x,y, n=8, col='#a8ffce'){ for(let i=0;i<n;i++){ particles.push({x,y, vx:rand(-90,90), vy:rand(-120,-40), life: rand(.3,.75), col}) } }
// ===== Input =====
let left=false, right=false, dashReq=false, shootHold=false;
window.addEventListener('keydown',e=>{
if(e.key==='ArrowLeft'||e.key==='a'||e.key==='A') left=true;
if(e.key==='ArrowRight'||e.key==='d'||e.key==='D') right=true;
if(e.code==='Space'){ shootHold=true; e.preventDefault(); }
if(e.key==='Shift') dashReq=true;
});
window.addEventListener('keyup',e=>{
if(e.key==='ArrowLeft'||e.key==='a'||e.key==='A') left=false;
if(e.key==='ArrowRight'||e.key==='d'||e.key==='D') right=false;
if(e.code==='Space') shootHold=false;
});
const leftBtn=document.getElementById('leftBtn'); const rightBtn=document.getElementById('rightBtn'); const dashBtn=document.getElementById('dashBtn');
const tp=document.getElementById('touchControls'); const isMobile = /Mobi|Android/i.test(navigator.userAgent); tp.style.display = isMobile? 'flex':'none';
const press=(b)=>{ b.dataset.down='1'; if(b===leftBtn) left=true; else if(b===rightBtn) right=true; else dashReq=true; };
const release=(b)=>{ b.dataset.down='0'; if(b===leftBtn) left=false; else if(b===rightBtn) right=false; };
[leftBtn,rightBtn,dashBtn].forEach(b=>{ b.addEventListener('pointerdown',()=>press(b)); b.addEventListener('pointerup',()=>release(b)); b.addEventListener('pointerleave',()=>release(b)); });
// mobile taps: single tap=shot, two-finger=dash
canvas.addEventListener('touchstart',e=>{ if(e.touches.length>=2) { dashReq=true; } else { shootOnce(); } }, {passive:true});
// desktop click to shoot too
canvas.addEventListener('mousedown', shootOnce);
// ===== Loop =====
let last=performance.now(); function loop(t){ const dt=Math.min(0.033,(t-last)/1000); last=t; if(state.running) update(dt); draw(dt); requestAnimationFrame(loop); } requestAnimationFrame(loop);
// ===== Mechanics =====
const stats = { coins:0, dash:0, maxCombo:1, shield:0, score:0 };
function reset(){ state.running=false; state.over=false; state.t=0; state.score=0; state.stage=1; stageTime=0; nextBossAt=28; state.speed=state.baseSpeed*opts.speedMul; state.combo=1; state.comboTime=0; state.slowed=0; state.boss=false; fx.shakeTime=0; fx.shakeAmp=0; boss=null;
obstacles.length=0; coins.length=0; particles.length=0; lasers.length=0; powerups.length=0; bullets.length=0; pbullets.length=0;
player.x=state.worldW/2; player.alive=true; player.flash=0; player.shield=0; player.magnet=0; player.dashCD=Math.max(0,2.6 - uLevel('dashCD')*0.4); player.dashT=0; player.extra = uLevel('extraLife'); player.fireCD=0;
if(uLevel('startShield')>0) player.shield = 0.8 + 0.4*uLevel('startShield');
spawnBlockRow(0); updateUI(); }
function start(){ state.running=true; hide(startOverlay); hide(gameoverOverlay); hide(bossOverlay); AudioKit.tick(); }
function gameOver(){ state.running=false; state.over=true; player.alive=false; AudioKit.hit(); state.best=Math.max(state.best, Math.floor(state.score)); storage.set(SAVE.best, state.best); const earned = Math.floor((state.score * (1 + 0.1*uLevel('scoreMul')))/100); addGems(earned); finalScore.textContent = Math.floor(state.score); finalBest.textContent = state.best; earnedEl.textContent = earned; finalMissions.textContent = summarizeMissions(); show(gameoverOverlay); updateUI(); }
function updateUI(){ scoreEl.textContent = Math.floor(state.score); bestEl.textContent = state.best; comboEl.textContent = 'x'+state.combo.toFixed(1); badgesEl.textContent = `⛨ ${Math.ceil(player.shield)} · 🧲 ${Math.ceil(player.magnet)} · ⏳ ${Math.ceil(state.slowed)}`; stageEl.textContent = state.stage; walletEl.textContent = wallet.gems; }
function addScore(v){ state.score += v * (1 + 0.1*uLevel('scoreMul')) * state.combo; stats.score = Math.floor(state.score); }
function addCombo(dt){ state.combo = clamp(state.combo + dt*0.05, 1, 5); state.comboTime = 1.8; stats.maxCombo = Math.max(stats.maxCombo, state.combo); }
function doDash(){ if(player.dashT>0 || player.dashCD>0) return; player.dashT=0.4; player.dashCD=Math.max(0.8, 3.0 - uLevel('dashCD')*0.4); stats.dash++; AudioKit.dash(); toast('Dash!'); fx.shakeTime=0.12; fx.shakeAmp=4; }
function applyPower(kind){ if(kind==='shield'){ player.shield = Math.max(player.shield, 1.5 + 0.2*uLevel('startShield')); stats.shield++; toast('Shield ⛨'); }
else if(kind==='magnet'){ player.magnet = Math.max(player.magnet, 4.5 + 0.5*uLevel('magnetDur')); toast('Magnet 🧲'); }
else if(kind==='slow'){ state.slowed = Math.max(state.slowed, 2.5); toast('Slow ⏳'); }
AudioKit.power(); }
function collideCircleRect(cx,cy,cr, r){ const tx=clamp(cx, r.x, r.x+r.w); const ty=clamp(cy, r.y, r.y+r.h); const dx=cx-tx, dy=cy-ty; return dx*dx+dy*dy <= cr*cr; }
function tryFire(){ if(player.fireCD>0) return; // fire 1~3 shots based on combo
const n = (state.combo>=3.5? 3 : (state.combo>=2.0? 2:1));
for(let i=0;i<n;i++){
const off = (n===1)?0:(i-(n-1)/2)*6; pbullets.push({x:player.x+off, y:player.y-player.r-2, r:3, vy:-380});
}
player.fireCD = Math.max(0.08, 0.22 - (state.combo-1)*0.02);
AudioKit.shoot();
}
function shootOnce(){ tryFire(); }
function update(dt){
state.t += dt; stageTime += dt; const speedMul = opts.speedMul * (state.slowed>0? 0.55:1); state.speed = clamp(120 + state.t*6, 120, 540) * speedMul; spawnInt = clamp(0.9 - state.t*0.02, 0.26, 0.9); laserInt = clamp(6.0 - state.t*0.01, 3.0, 6.0);
if(!state.boss && stageTime>=nextBossAt){ enterBoss(); }
lastSpawn += dt; if(lastSpawn>=spawnInt && !state.boss){ lastSpawn=0; spawnBlockRow(-20); }
lastLaser += dt; if(lastLaser>=laserInt && !state.boss){ lastLaser=0; spawnLaser(); }
// Player movement & actions
const dir = (right?1:0) - (left?1:0);
const skinCol = skins.find(s=>s.id===currentSkin)?.color || '#eaf2ff'; player.color = skinCol;
player.vx = dir * player.speed * (player.dashT>0? 1.6:1);
player.x = clamp(player.x + player.vx * dt, player.r+2, state.worldW - player.r-2);
if(dashReq){ doDash(); dashReq=false; }
if(player.dashT>0) player.dashT-=dt; if(player.dashCD>0) player.dashCD-=dt;
if(player.fireCD>0) player.fireCD-=dt; if(shootHold) tryFire();
// Stars
for(const s of stars){ s.y += (state.speed*0.2 + s.sp) * dt; if(s.y>state.worldH) { s.y -= state.worldH; s.x = rand(0,state.worldW);} }
// Entities movement
for(const o of obstacles){ o.y += state.speed * dt; o.x += (o.vx||0) * dt; if(o.x<0){ o.x=0; o.vx=Math.abs(o.vx||0);} if(o.x+o.w>state.worldW){ o.x=state.worldW-o.w; o.vx = -Math.abs(o.vx||0);} }
for(const c of coins){ c.y += (state.speed*0.95) * dt; const ax = (player.magnet>0? (player.x - c.x)*1.6 : 0); const ay = (player.magnet>0? (player.y - c.y)*1.6 : 0); c.x += ax*dt; c.y += ay*dt; }
for(const p of particles){ p.x += p.vx*dt; p.y += p.vy*dt; p.vy += 420*dt; p.life -= dt; }
for(const l of lasers){ l.x += l.vx*dt; l.life -= dt; }
for(const pb of pbullets){ pb.y += pb.vy*dt; }
if(state.boss){ updateBoss(dt); }
// Clean
while(obstacles.length && obstacles[0].y>state.worldH+40) obstacles.shift();
while(coins.length && coins[0].y>state.worldH+40) coins.shift();
for(let i=particles.length-1;i>=0;i--) if(particles[i].life<=0) particles.splice(i,1);
for(let i=lasers.length-1;i>=0;i--) if(lasers[i].life<=0 || lasers[i].x<-160 || lasers[i].x>state.worldW+160) lasers.splice(i,1);
for(let i=pbullets.length-1;i>=0;i--) if(pbullets[i].y<-30) pbullets.splice(i,1);
for(let i=powerups.length-1;i>=0;i--) if(powerups[i].y>state.worldH+40) powerups.splice(i,1);
for(const u of powerups){ u.y += state.speed*0.9*dt; }
// Collisions with hazards
let hit=false; if(!state.boss){ for(const o of obstacles){ if(collideCircleRect(player.x,player.y,player.r, o)) { hit=true; break; } } for(const l of lasers){ const r={x:l.x-4, y:l.y-2, w:l.w+8, h:l.h+4}; if(collideCircleRect(player.x,player.y,player.r, r)) { hit=true; break; } } }
if(hit && player.dashT<=0){ if(player.shield>0){ player.shield-=0.9; emit(player.x, player.y, 14, '#6ee7ff'); fx.shakeTime=0.18; fx.shakeAmp=6; } else if(player.extra>0){ player.extra--; toast('Extra Life!'); emit(player.x,player.y,12,'#86efac'); } else { player.flash=0.18; emit(player.x, player.y, 18, '#ff7777'); return gameOver(); } }
// coins
for(let i=coins.length-1;i>=0;i--){ const c=coins[i]; const dx=player.x-c.x, dy=player.y-c.y; if(dx*dx+dy*dy < (player.r+c.r)*(player.r+c.r)){ coins.splice(i,1); addScore(10); addCombo(0.25); stats.coins++; AudioKit.coin(); emit(c.x,c.y,6,'#ffd166'); if(state.boss && boss){ damageBoss(0.3); } } }
// powerups
for(let i=powerups.length-1;i>=0;i--){ const u=powerups[i]; const dx=player.x-u.x, dy=player.y-u.y; if(dx*dx+dy*dy < (player.r+u.r)*(player.r+u.r)){ powerups.splice(i,1); applyPower(u.kind); addScore(5); } }
// player bullets vs boss
if(boss){ for(let i=pbullets.length-1;i>=0;i--){ const pb=pbullets[i]; const dx=boss.x-pb.x, dy=boss.y-pb.y; if(dx*dx+dy*dy <= (boss.r+pb.r)*(boss.r+pb.r)){ pbullets.splice(i,1); damageBoss(1); addScore(2); } } }
// Effects timers
if(player.shield>0) player.shield=Math.max(0, player.shield-dt);
if(player.magnet>0) player.magnet=Math.max(0, player.magnet-dt);
if(state.slowed>0) state.slowed=Math.max(0, state.slowed-dt);
if(player.flash>0) player.flash=Math.max(0, player.flash-0.016);
if(state.comboTime>0){ state.comboTime-=dt; if(state.comboTime<=0) state.combo = Math.max(1, state.combo-0.1); }
// Score by time
addScore(dt*3); updateUI();
}
// ===== Rendering =====
function draw(){ const w=canvas.width/dpr, h=canvas.height/dpr; const sx = (fx.shakeTime>0 && opts.shake)? (rand(-fx.shakeAmp,fx.shakeAmp)) : 0; const sy = (fx.shakeTime>0 && opts.shake)? (rand(-fx.shakeAmp,fx.shakeAmp)) : 0; if(fx.shakeTime>0) fx.shakeTime -= 1/60; ctx.save(); ctx.clearRect(0,0,w,h); ctx.translate(sx, sy);
const obCol = opts.colorblind? 'rgba(255,255,255,.9)': 'rgba(255,255,255,.14)';
ctx.save(); ctx.globalAlpha=0.9; for(const s of stars){ ctx.fillStyle = `rgba(255,255,255,${0.2 + s.s*0.2})`; ctx.fillRect(s.x, s.y, s.s, s.s); } ctx.restore();
ctx.save(); ctx.globalAlpha=0.06; ctx.lineWidth=1; const grid=20; ctx.beginPath(); for(let x=0;x<w;x+=grid){ ctx.moveTo(x,0); ctx.lineTo(x,h);} for(let y=0;y<h;y+=grid){ ctx.moveTo(0,y); ctx.lineTo(w,y);} ctx.strokeStyle='white'; ctx.stroke(); ctx.restore();
// coins
ctx.save(); for(const c of coins){ ctx.beginPath(); ctx.arc(c.x, c.y, c.r, 0, Math.PI*2); ctx.fillStyle = opts.colorblind? '#ffbf00' : 'var(--gold)'; ctx.fill(); ctx.lineWidth=1; ctx.strokeStyle='rgba(0,0,0,.25)'; ctx.stroke(); } ctx.restore();
// powerups
ctx.save(); for(const u of powerups){ ctx.beginPath(); ctx.arc(u.x, u.y, u.r, 0, Math.PI*2); ctx.fillStyle = u.kind==='shield'? '#6ee7ff' : (u.kind==='magnet'? '#9bffb7' : '#c4a7ff'); ctx.fill(); ctx.strokeStyle='rgba(0,0,0,.3)'; ctx.stroke(); ctx.font='10px system-ui'; ctx.fillStyle='#001'; const sym = u.kind==='shield'? '⛨' : (u.kind==='magnet'? '🧲' : '⏳'); ctx.fillText(sym, u.x-6, u.y+3); } ctx.restore();
// obstacles & lasers (no boss phase)
if(!state.boss){ ctx.save(); ctx.fillStyle=obCol; for(const o of obstacles){ ctx.fillRect(o.x, o.y, o.w, o.h); } ctx.restore(); ctx.save(); for(const l of lasers){ const grad=ctx.createLinearGradient(l.x, l.y, l.x+l.w, l.y+l.h); grad.addColorStop(0,'rgba(255,90,90,.85)'); grad.addColorStop(1,'rgba(255,160,160,.5)'); ctx.fillStyle=grad; ctx.fillRect(l.x, l.y, l.w, l.h); } ctx.restore(); }
// boss
if(state.boss && boss){ ctx.save(); const g=ctx.createRadialGradient(boss.x-6,boss.y-6,4, boss.x,boss.y,boss.r+6); g.addColorStop(0,'#fff'); g.addColorStop(1,'#c4a7ff'); ctx.fillStyle=g; ctx.beginPath(); ctx.arc(boss.x,boss.y,boss.r,0,Math.PI*2); ctx.fill(); ctx.fillStyle='rgba(255,255,255,.8)'; ctx.fillRect(boss.x-24,boss.y-boss.r-16,48,6); ctx.fillStyle='#ff6bcb'; const hpw = clamp((boss.hp/(6+state.stage*2))*48,0,48); ctx.fillRect(boss.x-24,boss.y-boss.r-16,hpw,6); ctx.restore(); ctx.save(); ctx.fillStyle='#ff9d9d'; for(const b of bullets){ ctx.beginPath(); ctx.arc(b.x,b.y,b.r,0,Math.PI*2); ctx.fill(); } ctx.restore(); }
// player bullets
ctx.save(); ctx.fillStyle='#aee3ff'; for(const pb of pbullets){ ctx.beginPath(); ctx.arc(pb.x,pb.y,pb.r,0,Math.PI*2); ctx.fill(); } ctx.restore();
// player
ctx.save(); if(player.flash>0){ ctx.shadowColor=getCSS('--danger', '#ff6b6b'); ctx.shadowBlur=18; }
ctx.beginPath(); ctx.arc(player.x, player.y, player.r, 0, Math.PI*2); const grad=ctx.createRadialGradient(player.x-4,player.y-6,4, player.x,player.y, player.r+6); grad.addColorStop(0, '#ffffff'); grad.addColorStop(1, player.color||'#7ee0ff'); ctx.fillStyle=grad; ctx.fill(); if(player.shield>0){ ctx.globalAlpha=0.25+0.15*Math.sin(performance.now()/120); ctx.beginPath(); ctx.arc(player.x, player.y, player.r+6, 0, Math.PI*2); ctx.strokeStyle='#8ae9ff'; ctx.lineWidth=3; ctx.stroke(); ctx.globalAlpha=1; } if(player.dashT>0){ ctx.globalAlpha=0.5; ctx.beginPath(); ctx.arc(player.x - 10, player.y, player.r*0.9, 0, Math.PI*2); ctx.fillStyle='#bde3ff'; ctx.fill(); ctx.globalAlpha=1; } ctx.restore();
// particles
ctx.save(); for(const p of particles){ ctx.globalAlpha = clamp(p.life,0,1); ctx.fillStyle=p.col||'#a8ffce'; ctx.fillRect(p.x, p.y, 2,2); } ctx.restore();
ctx.restore();
}
function getCSS(name, fallback){ return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; }
// ===== UI wires =====
const startOverlay=document.getElementById('overlayStart'); const gameoverOverlay=document.getElementById('overlayGameOver'); const bossOverlay=document.getElementById('overlayBoss');
const scoreEl=document.getElementById('score'); const bestEl=document.getElementById('best'); const comboEl=document.getElementById('combo'); const stageEl=document.getElementById('stage'); const badgesEl=document.getElementById('badges');
const btnStart=document.getElementById('btnStart'); const btnRetry=document.getElementById('btnRetry'); const btnHome=document.getElementById('btnHome'); const btnPause=document.getElementById('btnPause'); const btnSound=document.getElementById('btnSound'); const btnSettings=document.getElementById('btnSettings'); const btnShop=document.getElementById('btnShop'); const btnSkins=document.getElementById('btnSkins'); const btnBossGo=document.getElementById('btnBossGo');
const dlgSettings=document.getElementById('dlgSettings'); const rangeSpeed=document.getElementById('rangeSpeed'); const chkShake=document.getElementById('chkShake'); const chkCB=document.getElementById('chkCB'); const chkEco=document.getElementById('chkEco'); const chkHints=document.getElementById('chkHints'); const missionsEl=document.getElementById('missions'); const walletEl=document.getElementById('wallet');
const finalScore=document.getElementById('finalScore'); const finalBest=document.getElementById('finalBest'); const earnedEl=document.getElementById('earned'); const finalMissions=document.getElementById('finalMissions');
function show(el){ el.style.display='grid'; } function hide(el){ el.style.display='none'; }
btnStart.addEventListener('click',()=>{ start(); AudioKit.enable(); }); btnRetry.addEventListener('click',()=>{ reset(); start(); }); btnHome.addEventListener('click',()=>{ reset(); show(startOverlay); });
btnPause.addEventListener('click',()=>{ if(!state.running) resume(); else togglePause(); }); btnSound.addEventListener('click',()=>{ const on = AudioKit.toggle(); btnSound.textContent = on ? '🔊' : '🔇'; if(on) AudioKit.tick(); });
btnSettings.addEventListener('click',()=>{ rangeSpeed.value=opts.speedMul; chkShake.checked=opts.shake; chkCB.checked=opts.colorblind; chkEco.checked=opts.eco; chkHints.checked=opts.hints; dlgSettings.showModal(); });
document.getElementById('btnSaveSettings').addEventListener('click',(e)=>{ e.preventDefault(); opts.speedMul=parseFloat(rangeSpeed.value); opts.shake=chkShake.checked; opts.colorblind=chkCB.checked; opts.eco=chkEco.checked; opts.hints=chkHints.checked; storage.set(SAVE.opts, opts); dlgSettings.close(); toast('設定を保存しました'); });
// Shop & Skins
const dlgShop=document.getElementById('dlgShop'); const shopList=document.getElementById('shopList');
function buildShop(){ shopList.innerHTML=''; const items=[
{key:'startShield', name:'開始時シールド', desc:'+0.4〜のシールドを付与', base:40, max:3},
{key:'magnetDur', name:'マグネット延長', desc:'+0.5s/レベル', base:30, max:5},
{key:'dashCD', name:'ダッシュCD短縮', desc:'-0.4s/レベル', base:45, max:4},
{key:'scoreMul', name:'スコア倍率', desc:'+10%/レベル', base:60, max:5},
{key:'extraLife', name:'エクストラライフ', desc:'1回だけミスを無効化', base:120, max:1},
]; items.forEach(it=>{ const lv=uLevel(it.key); const cost = Math.floor(it.base * Math.pow(1.6, lv)); const can = lv<it.max && wallet.gems>=cost; const row=document.createElement('div'); row.className='shop-item'; row.innerHTML=`<div><strong>${it.name}</strong> <span class="chip">Lv.${lv}/${it.max}</span><div class="muted" style="font-size:.85rem">${it.desc}</div></div><button class="btn ${can?'primary':''}" ${can?'':'disabled'}>${lv>=it.max?'MAX':`購入 💎${cost}`}</button>`; row.querySelector('button').onclick=()=>{ if(lv>=it.max) return; if(wallet.gems<cost){ toast('💎不足'); return; } addGems(-cost); upgrades[it.key]=(upgrades[it.key]||0)+1; saveUpgrades(); toast(`${it.name} Lv.${upgrades[it.key]}`); }; shopList.appendChild(row); }); }
const dlgSkins=document.getElementById('dlgSkins'); const skinList=document.getElementById('skinList');
function buildSkins(){ skinList.innerHTML=''; skins.forEach(s=>{ const owned = (s.cost===0) || storage.get('skin-'+s.id, false); const can = wallet.gems>=s.cost && !owned; const row=document.createElement('div'); row.className='shop-item'; row.innerHTML=`<div><strong>${s.name}</strong> <span class="chip" style="background:${s.color}; color:#000">●</span> ${s.cost?`<span class='muted'>/ 💎${s.cost}</span>`:'<span class="muted">/ Free</span>'}</div><div><button class="btn ${owned?'':'primary'}" data-id="${s.id}">${owned?(currentSkin===s.id?'使用中':'使用'):('購入')}</button></div>`; row.querySelector('button').onclick=()=>{ if(!owned){ if(wallet.gems<s.cost){ toast('💎不足'); return; } addGems(-s.cost); storage.set('skin-'+s.id,true); } currentSkin=s.id; storage.set(SAVE.skin, currentSkin); buildSkins(); toast(`${s.name} を装備`); }; skinList.appendChild(row); }); }
btnShop.addEventListener('click',()=>{ buildShop(); dlgShop.showModal(); }); btnSkins.addEventListener('click',()=>{ buildSkins(); dlgSkins.showModal(); }); btnBossGo.addEventListener('click',()=>{ startBoss(); });
function togglePause(){ if(!state.running || state.over) return; state.running=false; btnPause.textContent='▶'; toast('Pause'); }
function resume(){ if(state.over) return; state.running=true; btnPause.textContent='⏸'; toast('Resume'); }
// Missions
function generateMissions(){ const pool=[
{id:'c80', text:'コインを80枚集める', test: s=>s.coins>=80},
{id:'dash4', text:'1プレイでダッシュを4回', test: s=>s.dash>=4},
{id:'combo35', text:'コンボ倍率3.5達成', test: s=>s.maxCombo>=3.5},
{id:'shield', text:'シールド取得', test: s=>s.shield>0},
{id:'score1200', text:'スコア1200到達', test: s=>s.score>=1200},
]; const chosen=[]; while(chosen.length<3){ const m=choice(pool); if(!chosen.find(c=>c.id===m.id)) chosen.push(m);} return chosen; }
let missions = storage.get(SAVE.missions, null); if(!missions){ missions=generateMissions(); storage.set(SAVE.missions, missions);} missionsEl.innerHTML = '<strong>本日のミッション</strong><br>• '+missions.map(m=>m.text).join('<br>• ');
function summarizeMissions(){ const done = missions.filter(m=>m.test(stats)).map(m=>m.text); return (done.length? done.join(' / ') : 'なし'); }
// Init
state.best = storage.get(SAVE.best, 0); walletEl.textContent=wallet.gems; updateUI(); reset(); show(startOverlay);
</script>
</body>
</html>
記憶のアップロード、ダウンロード方法
1. 現在の科学でできること
- 脳活動の記録
fMRI(機能的MRI)、EEG(脳波)、ECoG(皮質脳波)などを用いて、記憶に関連する脳の活動パターンを部分的に観測できます。 - 記憶の改変実験(動物レベル)
光遺伝学(オプトジェネティクス)を使って、マウスの「恐怖記憶」を消去したり、別のニューロン群に記憶を「転写」する実験が報告されています。 - 神経インターフェース
Elon MuskのNeuralinkやOpenBCIなど、脳とコンピュータを接続して情報をやり取りする試みが進んでいます。まだ「読み出し」や「刺激」レベルで、完全な記憶の保存・転送には程遠いです。
2. 理論上考えられている方法(SF的な構想)
- 全脳エミュレーション(Whole Brain Emulation)
- 脳をナノレベルでスキャン(例えば凍結切片や分子レベルの走査)。
- ニューロンとシナプスの接続をすべてデジタル化。
- コンピュータ上で「シミュレーション」として再現。
→ これにより「記憶のアップロード」が可能になるとする考え方。
- ニューロン・マッピングと同期化
- 人間の脳のニューロン活動をリアルタイムで記録し、人工的な神経回路(シリコンや量子コンピュータ上)に「コピー」。
- インターフェースを通じて「脳」と「デジタル記憶」を同期。
- 理論的には「記憶ダウンロード(人工脳から人間脳への書き込み)」も可能かもしれない。
- 分子レベルの記憶保存
- DNAやナノマシンに記憶パターンを保存する研究構想。
- ただし現状は「遺伝子にデータを書き込む」程度で、記憶そのものの転送には至っていません。
3. 哲学的・倫理的な限界
- コピーは「自分」か?
記憶をアップロードしたデジタル存在が「自分」なのか、それとも「別のコピー」なのか。 - 死の意味の変容
記憶や人格が保存できるなら「死」は何を意味するのか。 - 倫理問題
誰がそのデータにアクセスできるのか、人格を「編集」することは許されるのか。
📌 結論:
「記憶のアップロード/ダウンロード」を現実に行う方法は、まだ存在しません。
現在できるのは「脳活動の観測」や「部分的な記憶の操作」にとどまり、完全な「自己の保存・転送」は技術的にも哲学的にも解決できていない課題です。
X風サイト.html
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>X-like UI – Polished (Left-aligned)</title>
<style>
/* ===== Design Tokens ===== */
:root{
/* colors */
--bg: #0b0d10;
--surface: #0e1116;
--panel: rgba(16,18,24,.75);
--card: #0f1319;
--line: #1c2230; /* single-pixel separators */
--text: #e7ecf3;
--muted: #9aa7ba;
--accent: #1da1f2;
--accent-2: #7dd3fc;
--accent-3: #60a5fa;
/* radius & shadow */
--r: 14px;
--shadow-sm: 0 1px 0 rgba(255,255,255,.02) inset, 0 8px 24px rgba(0,0,0,.35);
--shadow-card: 0 10px 30px rgba(0,0,0,.25);
/* layout */
--col-left: 84px;
--col-center-min: 360px;
--col-center-max: 720px;
--col-right-min: 280px;
--col-right-max: 420px;
/* NEW: tighter left gutter for center column */
--gutter-x: 10px;
/* motion */
--e1: cubic-bezier(.2,.8,.2,1);
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
background:
radial-gradient(1200px 600px at 10% -10%, rgba(29,161,242,.15), transparent 40%),
radial-gradient(800px 500px at 110% -10%, rgba(96,165,250,.12), transparent 40%),
var(--bg);
color:var(--text);
font:14px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans JP", "Hiragino Kaku Gothic ProN", "Helvetica Neue", Arial;
}
/* focus ring */
:where(a,button,[role="button"],input,textarea,.sb-btn,.ic-btn,.plus,.btn,.btn-ghost,.btn-primary){outline:none}
:where(a,button,[role="button"],input,textarea,.sb-btn,.ic-btn,.plus,.btn,.btn-ghost,.btn-primary):focus-visible{
box-shadow:0 0 0 3px rgba(29,161,242,.35);
border-radius:10px;
}
/* ===== Layout ===== */
.app{
display:grid; gap:0;
grid-template-columns: var(--col-left) minmax(var(--col-center-min),var(--col-center-max)) minmax(var(--col-right-min),var(--col-right-max));
height:100%;
max-width:1280px;
margin:0 auto;
}
.sidebar{
position:sticky; top:0; height:100vh;
backdrop-filter: blur(10px);
background:linear-gradient(180deg, rgba(16,18,24,.7), rgba(16,18,24,.3));
/* NOTE: borders are drawn via ::after to avoid subpixel drift */
padding:10px 8px; display:flex; flex-direction:column; gap:6px;
}
.sb-btn{
border-radius:999px; padding:12px 14px; cursor:pointer;
display:flex; align-items:center; gap:14px; transition:background .2s var(--e1), transform .12s var(--e1);
}
.sb-btn:hover{background:rgba(255,255,255,.04)}
.sb-btn:active{transform:translateY(1px)}
.sb-icon{width:28px;height:28px;display:grid;place-items:center;border-radius:999px}
.sb-btn .label{font-weight:700;letter-spacing:.02em}
.compose-fab{
position:fixed; left:18px; bottom:18px; z-index:50; cursor:pointer;
border:none; color:#fff; font-weight:800; letter-spacing:.02em;
border-radius:999px; padding:12px 18px;
background:linear-gradient(135deg, var(--accent), var(--accent-3));
box-shadow:0 10px 30px rgba(29,161,242,.35);
transition: transform .12s var(--e1), filter .2s var(--e1);
}
.compose-fab:hover{filter:brightness(1.05)}
.compose-fab:active{transform:translateY(1px)}
.you-chip{
position:fixed; left:18px; bottom:84px; width:48px;height:48px;border-radius:999px;
display:grid;place-items:center;font-weight:900; background:conic-gradient(from 180deg at 50% 50%, #0f629e, #0c3c68 70%, #0f629e);
color:#fff; border:1px solid rgba(255,255,255,.08);
}
.main{
position:relative; /* for ::after separator */
min-height:100vh; background:rgba(0,0,0,.25);
backdrop-filter: blur(6px);
}
.rightcol{
padding:14px; position:sticky; top:0; height:100vh; overflow:auto;
}
.searchbar{
display:flex; align-items:center; gap:10px; padding:10px 14px;
background:rgba(255,255,255,.04); border:1px solid rgba(255,255,255,.06);
border-radius:999px;
}
/* ===== Single-pixel separators (no drift) ===== */
.sidebar::after,
.main::after{
content:"";
position:absolute; top:0; bottom:0; width:1px; background:var(--line);
pointer-events:none; z-index:10;
}
.sidebar::after{ right:0; } /* Sidebar ↔ Main */
.main::after{ right:0; } /* Main ↔ Right column */
/* ===== Header Tabs ===== */
.header-title{padding:10px var(--gutter-x); font-size:20px; font-weight:900; border-bottom:1px solid var(--line)}
.tabs{
position:sticky; top:0; z-index:6;
display:flex; gap:24px; padding:0 var(--gutter-x); /* <<< tightened left padding */
background:linear-gradient(180deg, rgba(14,17,22,.8), rgba(14,17,22,.55));
backdrop-filter: saturate(120%) blur(8px);
border-bottom:1px solid var(--line);
}
.tab{
padding:16px 6px; cursor:pointer; color:var(--muted); font-weight:800; border-bottom:3px solid transparent;
transition:color .2s var(--e1), border-color .2s var(--e1);
}
.tab.active{color:var(--text); border-color:var(--accent)}
/* ===== Composer ===== */
.composer{
padding:14px var(--gutter-x); /* <<< tightened */
border-bottom:1px solid var(--line); display:flex; gap:12px;
background:linear-gradient(180deg, rgba(17,22,29,.8), rgba(17,22,29,.4));
}
.avatar{
width:42px; height:42px; border-radius:999px; display:grid; place-items:center; font-weight:900; letter-spacing:.02em;
color:#fff; border:1px solid rgba(255,255,255,.08);
background:radial-gradient(120% 120% at 20% 15%, #1e81c5, #0f2a43 70%);
}
.composer-box{flex:1}
.composer textarea{
width:100%; min-height:76px; resize:vertical; background:transparent; border:none; color:var(--text);
outline:none; font-size:18px; caret-color:var(--accent);
}
.reply-scope{color:var(--accent);font-weight:700;font-size:13px}
.row{display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:6px}
.icons{display:flex; gap:8px}
.ic-btn{width:32px;height:32px;display:grid;place-items:center;border-radius:10px;cursor:pointer;transition:background .2s}
.ic-btn:hover{background:rgba(255,255,255,.06)}
.post-btn{
border:none; color:#0b1220; font-weight:900; letter-spacing:.02em;
padding:9px 18px; border-radius:999px; cursor:not-allowed;
background:linear-gradient(135deg, #6b7280 0%, #9aa7ba 100%);
filter:saturate(.7); opacity:.7; transition:filter .2s, transform .12s;
}
.post-btn.enabled{
cursor:pointer; opacity:1; color:#fff; filter:none;
background:linear-gradient(135deg, var(--accent), var(--accent-3));
box-shadow:0 12px 30px rgba(29,161,242,.3);
}
.post-btn.enabled:hover{filter:brightness(1.05)}
.post-btn.enabled:active{transform:translateY(1px)}
/* ===== Cards ===== */
.card{
border-bottom:1px solid var(--line); display:flex; gap:12px;
padding:14px var(--gutter-x); /* <<< tightened */
background:linear-gradient(180deg, rgba(16,20,27,.5), rgba(16,20,27,.25));
}
.meta{display:flex; gap:6px; color:var(--muted)}
.name{font-weight:900}
.handle{color:var(--muted)}
.hash a{color:var(--accent)}
.post-img{
border-radius:18px; border:1px solid rgba(255,255,255,.06); width:100%; margin-top:10px;
box-shadow:var(--shadow-card)
}
.actions{display:flex; gap:26px; margin-top:8px; color:var(--muted)}
.action{display:flex; gap:6px; align-items:center; cursor:pointer; transition:color .15s}
.action:hover{color:var(--accent)}
/* ===== Right column ===== */
.rightcol .rc-card{
background:linear-gradient(180deg, rgba(17,22,29,.65), rgba(17,22,29,.35));
border:1px solid rgba(255,255,255,.08); border-radius:18px; overflow:hidden; margin-top:12px; box-shadow:var(--shadow-sm)
}
.rc-title{font-weight:900; padding:12px 16px; border-bottom:1px solid rgba(255,255,255,.06)}
.rc-item{padding:12px 16px; border-top:1px solid rgba(255,255,255,.04)}
.chip{background:linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.02)); border:1px solid rgba(255,255,255,.08);
padding:4px 10px; border-radius:999px; color:var(--muted); font-size:12px}
/* ===== Subtabs / Explore ===== */
.subtabs{display:flex; gap:18px; padding:10px var(--gutter-x); border-bottom:1px solid var(--line); background:rgba(255,255,255,.02)} /* <<< tightened */
.subtab{padding:10px 2px; color:var(--muted); font-weight:800; cursor:pointer; border-bottom:3px solid transparent}
.subtab.active{color:var(--text); border-color:var(--accent)}
/* ===== Empties / Profile / Lists ===== */
.empty{display:grid; place-items:center; padding:72px var(--gutter-x); color:var(--muted); text-align:center}
.empty h2{margin:0 0 6px; color:var(--text)}
.cover{height:170px; background:
radial-gradient(70% 120% at 10% 0%, rgba(29,161,242,.25), transparent 40%),
radial-gradient(60% 120% at 100% 0%, rgba(96,165,250,.2), transparent 40%),
linear-gradient(180deg,#121722,#0b0f17)}
.prof-wrap{padding:0 var(--gutter-x) 16px}
.prof-row{display:flex; justify-content:space-between; align-items:end; margin-top:-36px}
.pfp{width:96px;height:96px;border-radius:999px;border:4px solid var(--surface);background:radial-gradient(120% 120% at 20% 15%, #1e81c5, #0f2a43 70%);display:grid;place-items:center;color:#fff;font-weight:900}
.btn{background:transparent; border:1px solid rgba(255,255,255,.18); color:#fff; border-radius:999px; padding:8px 14px; cursor:pointer; transition:background .2s}
.btn:hover{background:rgba(255,255,255,.05)}
.alert{background:rgba(18,42,24,.65); border:1px solid #165c36; color:#a9f2b7; border-radius:14px; padding:12px 14px; margin:12px var(--gutter-x); display:flex; gap:10px; align-items:center}
.check{width:18px;height:18px;border-radius:4px;border:2px solid #a9f2b7;display:grid;place-items:center}
.list-row{display:flex; align-items:center; justify-content:space-between; gap:12px; padding:12px 16px}
.list-pill{width:44px;height:44px;border-radius:12px; background:linear-gradient(135deg,#374151,#1f2937)}
.plus{width:28px;height:28px;border-radius:999px;border:1px solid rgba(255,255,255,.18);display:grid;place-items:center}
/* ===== Grok ===== */
.grok{display:grid; place-items:center; padding:44px var(--gutter-x)}
.grok .logo{font-size:28px; font-weight:1000; display:flex; align-items:center; gap:10px}
.grok .input{margin-top:18px; display:flex; gap:8px; width:min(680px,90vw)}
.grok .input input{flex:1; padding:14px; border-radius:12px; border:1px solid rgba(255,255,255,.18); background:#0c1118; color:#fff; transition:border .2s}
.grok .input input:focus{border-color:rgba(125,211,252,.6)}
.grok .input button{padding:12px 16px; border-radius:12px; border:1px solid rgba(255,255,255,.18); background:linear-gradient(135deg,#10151f,#0e131b); color:#fff}
.grok .tools{display:flex; gap:8px; margin-top:12px}
/* ===== E2EE Page ===== */
.e2ee{padding:56px var(--gutter-x); text-align:center}
.e2ee h1{font-size:26px; margin:0 0 12px}
.bullet{display:flex; gap:10px; align-items:flex-start; justify-content:center; color:#cfd9ea}
.e2ee .cta{display:flex; gap:12px; justify-content:center; margin-top:18px}
.btn-ghost{border:1px solid rgba(255,255,255,.18); background:transparent; color:#fff; padding:10px 14px; border-radius:999px}
.btn-primary{border:1px solid rgba(29,161,242,.55); background:linear-gradient(135deg, var(--accent), var(--accent-2)); color:#001e33; font-weight:900; padding:10px 16px; border-radius:999px}
/* ===== Modal ===== */
.modal-backdrop{
position:fixed; inset:0; background:rgba(4,8,12,.6); display:none; align-items:center; justify-content:center; z-index:100;
animation:fadeIn .2s var(--e1);
}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
.modal{
width:min(640px,92vw); background:linear-gradient(180deg, rgba(14,18,24,.95), rgba(14,18,24,.85));
border:1px solid rgba(255,255,255,.12); border-radius:20px; overflow:hidden; box-shadow:var(--shadow-card)
}
.modal .top{display:flex; align-items:center; justify-content:space-between; padding:10px 14px; border-bottom:1px solid rgba(255,255,255,.06)}
.close-x{width:32px;height:32px;border-radius:999px;display:grid;place-items:center;cursor:pointer;transition:background .2s}
.close-x:hover{background:rgba(255,255,255,.06)}
.small{font-size:12px;color:var(--muted)}
/* ===== Toast ===== */
.toast{
position:fixed; left:50%; transform:translateX(-50%) translateY(20px);
bottom:20px; background:rgba(18,22,28,.92); border:1px solid rgba(255,255,255,.12);
padding:10px 14px; border-radius:999px; display:none; gap:10px; align-items:center; z-index:120;
box-shadow:var(--shadow-card); animation:slideUp .25s var(--e1);
}
.toast .view{background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.12); padding:6px 10px; border-radius:999px}
@keyframes slideUp{from{opacity:0; transform:translateX(-50%) translateY(40px)} to{opacity:1; transform:translateX(-50%) translateY(0)}}
/* ===== Utilities ===== */
.hide{display:none !important}
/* ===== Responsive ===== */
@media (max-width:1100px){
.app{grid-template-columns: 72px 1fr}
.rightcol{display:none}
}
@media (max-width:520px){
.sb-btn .label{display:none}
.sidebar{align-items:center}
}
</style>
</head>
<body>
<div class="app">
<!-- Sidebar -->
<aside class="sidebar" aria-label="Sidebar">
<div class="sb-btn" data-nav="home"><div class="sb-icon">🏠</div><div class="label">Home</div></div>
<div class="sb-btn" data-nav="explore"><div class="sb-icon">🔎</div><div class="label">Explore</div></div>
<div class="sb-btn" data-nav="notifications"><div class="sb-icon">🔔</div><div class="label">Notifications</div></div>
<div class="sb-btn" data-nav="messages"><div class="sb-icon">✉️</div><div class="label">Messages</div></div>
<div class="sb-btn" data-nav="grok"><div class="sb-icon">⚡</div><div class="label">Grok</div></div>
<div class="sb-btn" data-nav="lists"><div class="sb-icon">🗂️</div><div class="label">Lists</div></div>
<div class="sb-btn" data-nav="profile"><div class="sb-icon">👤</div><div class="label">Profile</div></div>
<div class="sb-btn" data-nav="encrypted"><div class="sb-icon">🔒</div><div class="label">Chat</div></div>
</aside>
<!-- Main -->
<main class="main" id="main">
<!-- Home -->
<section id="view-home" role="region" aria-label="Home timeline">
<div class="tabs">
<div class="tab active" data-home-tab="foryou">For you</div>
<div class="tab" data-home-tab="following">Following</div>
</div>
<div class="composer">
<div class="avatar">裕平</div>
<div class="composer-box">
<div class="reply-scope">Everyone can reply</div>
<textarea id="compose-input" placeholder="What’s happening?"></textarea>
<div class="row">
<div class="icons" aria-label="Composer actions">
<div class="ic-btn" title="Media">🖼️</div>
<div class="ic-btn" title="GIF">🌀</div>
<div class="ic-btn" title="Poll">📊</div>
<div class="ic-btn" title="Emoji">😊</div>
<div class="ic-btn" title="Schedule">🗓️</div>
<div class="ic-btn" title="Location">📍</div>
</div>
<button id="post-btn" class="post-btn" disabled>Post</button>
</div>
</div>
</div>
<article class="card">
<div class="avatar">H</div>
<div style="flex:1">
<div class="meta"><span class="name">HANA</span><span class="handle">@HANA__BRAVE · 9h</span></div>
<div>🎂 <span class="hash"><a href="#">#HAPPYJISOODAY</a></span><br/>Happy Birthday JISOO 🤍<br/><span class="hash"><a href="#">#HANA</a> <a href="#">#JISOO</a></span></div>
<img class="post-img" alt="sample" src="https://picsum.photos/seed/jisoo/720/380" />
<div class="actions">
<div class="action">💬 <span>24</span></div>
<div class="action">🔁 <span>10</span></div>
<div class="action">❤️ <span>128</span></div>
<div class="action">↗️</div>
</div>
</div>
</article>
<article class="card">
<div class="avatar">X</div>
<div style="flex:1">
<div class="meta"><span class="name">MTV VMA · LIVE</span><span class="handle"> · now</span></div>
<div class="hash"><a href="#">#VMAs</a></div>
<img class="post-img" alt="vmas" src="https://picsum.photos/seed/vma/720/300" />
<div class="actions">
<div class="action">💬 <span>8</span></div>
<div class="action">🔁 <span>3</span></div>
<div class="action">❤️ <span>42</span></div>
<div class="action">↗️</div>
</div>
</div>
</article>
<div id="feed-anchor"></div>
</section>
<!-- Explore -->
<section id="view-explore" class="hide" role="region" aria-label="Explore">
<div class="header-title">
<div class="searchbar"><span>🔎</span><input style="flex:1;background:transparent;border:none;color:#fff;outline:none" placeholder="Search" /></div>
</div>
<div class="subtabs">
<div class="subtab active">For You</div>
<div class="subtab">Trending</div>
<div class="subtab">News</div>
<div class="subtab">Sports</div>
<div class="subtab">Entertainment</div>
</div>
<article class="card">
<div class="avatar">T</div>
<div style="flex:1">
<div class="meta"><span class="name">Tokyo 2025</span><span class="handle"> · promoted</span></div>
<img class="post-img" alt="tokyo" src="https://picsum.photos/seed/tokyo2025/720/260" />
</div>
</article>
</section>
<!-- Notifications -->
<section id="view-notifications" class="hide" role="region" aria-label="Notifications">
<div class="tabs">
<div class="tab active">All</div>
<div class="tab">Verified</div>
<div class="tab">Mentions</div>
</div>
<div class="empty">
<div>
<h2>Nothing to see here — yet</h2>
<div>From likes to reposts and a whole lot more, this is where all the action happens.</div>
</div>
</div>
</section>
<!-- Messages -->
<section id="view-messages" class="hide" role="region" aria-label="Messages">
<div class="inbox" style="min-height:60vh">
<div class="dm-left" style="border-right:1px solid var(--line)">
<div style="padding:16px">
<h3 style="margin:4px 0">Welcome to your inbox!</h3>
<div class="muted">Drop a line, share posts and more with private conversations between you and others on X.</div>
<div style="height:10px"></div>
<button class="btn">Write a message</button>
</div>
</div>
<div>
<div class="empty">
<div>
<h2>Select a message</h2>
<div class="muted">Choose from your existing conversations, start a new one, or just keep swimming.</div>
<div style="height:10px"></div>
<button class="btn">New message</button>
</div>
</div>
</div>
</div>
</section>
<!-- Lists -->
<section id="view-lists" class="hide" role="region" aria-label="Lists">
<div class="header-title">Lists</div>
<div style="padding:16px">
<div class="muted" style="padding:8px 16px">Discover new Lists</div>
<div class="rc-card" role="list">
<div class="list-row">
<div style="display:flex;gap:12px;align-items:center">
<div class="list-pill"></div>
<div>
<div>J.League · <span class="muted">60 members</span></div>
<div class="muted">2K followers including @sascha348</div>
</div>
</div>
<div class="plus">+</div>
</div>
<div class="list-row">
<div style="display:flex;gap:12px;align-items:center">
<div class="list-pill"></div>
<div>
<div>Official Accounts · <span class="muted">83 members</span></div>
<div class="muted">263 followers including @dencetuno</div>
</div>
</div>
<div class="plus">+</div>
</div>
<div class="list-row">
<div style="display:flex;gap:12px;align-items:center">
<div class="list-pill"></div>
<div>
<div>kitchen · <span class="muted">52 members</span></div>
<div class="muted">181 followers including @Carolina_3254</div>
</div>
</div>
<div class="plus">+</div>
</div>
</div>
<div class="muted" style="padding:22px 16px 8px">Your Lists</div>
<div class="empty" style="opacity:.75"><div>You haven't created or followed any Lists. When you do, they'll show up here.</div></div>
</div>
</section>
<!-- Profile -->
<section id="view-profile" class="hide" role="region" aria-label="Profile">
<div class="cover"></div>
<div class="prof-wrap">
<div class="prof-row">
<div class="pfp">裕平</div>
<button class="btn">Edit profile</button>
</div>
<h2 style="margin:10px 0 0">長留裕平</h2>
<div class="muted">@PingZhang89719 · Joined September 2025</div>
<div style="height:8px"></div>
<div class="muted">0 Following · 0 Followers</div>
</div>
<div class="alert">
<div class="check">✔</div>
<div>
<div class="name" style="font-weight:900">You aren’t verified yet</div>
<div class="muted">Get verified for boosted replies, analytics, ad-free browsing, and more.</div>
</div>
</div>
<article class="card">
<div class="avatar">裕平</div>
<div style="flex:1">
<div class="meta"><span class="name">長留裕平</span><span class="handle"> · 1m</span></div>
<div>はじめました。</div>
</div>
</article>
</section>
<!-- Grok -->
<section id="view-grok" class="hide" role="region" aria-label="Grok">
<div class="grok">
<div class="logo">⚡ <span>Grok</span></div>
<div class="muted">Ask anything</div>
<div class="input">
<input placeholder="Ask anything" />
<button>➤</button>
</div>
<div class="tools">
<button class="btn">Create Images</button>
<button class="btn">Edit Image</button>
</div>
<div class="muted" style="margin-top:16px">Fast ▾ · History</div>
</div>
</section>
<!-- Encrypted Chat -->
<section id="view-encrypted" class="hide" role="region" aria-label="Encrypted Chat">
<div class="e2ee">
<h1>Meet new Chat, now fully encrypted.</h1>
<div class="muted">X Chat are now protected with end-to-end encryption on all your devices.</div>
<div style="height:12px"></div>
<div class="bullet">🔒 <div><b>End-to-End Encryption</b><br/>Your messages are protected across devices.</div></div>
<div class="bullet">🛡️ <div><b>Uncompromising Privacy</b><br/>No one — not even X — can access or read your messages.</div></div>
<div class="cta">
<button class="btn-ghost">Maybe later</button>
<button class="btn-primary">Set up now</button>
</div>
</div>
</section>
</main>
<!-- Right -->
<aside class="rightcol" aria-label="Right column">
<div class="rc-card">
<div class="rc-title">What’s happening</div>
<div class="rc-item"><b>MTV Video Music Awards 2025</b><div class="muted">LIVE</div></div>
<div class="rc-item"><b>東京2025 世界陸上</b><div class="muted">Trending · 8,724 posts</div></div>
<div class="rc-item"><b>JISOO</b><div class="chip">K-POP · Trending</div></div>
</div>
<div class="rc-card">
<div class="rc-title">Who to follow</div>
<div class="rc-item">🅿️ <b>Product Dev</b> · <span class="muted">@buildhub</span> <button class="btn" style="float:right">Follow</button></div>
<div class="rc-item">🧠 <b>AI Lab</b> · <span class="muted">@ailab</span> <button class="btn" style="float:right">Follow</button></div>
</div>
</aside>
</div>
<!-- Floating -->
<button class="compose-fab" id="open-compose">Post</button>
<div class="you-chip">裕平</div>
<!-- Modal -->
<div class="modal-backdrop" id="composer-modal" aria-hidden="true">
<div class="modal" role="dialog" aria-modal="true" aria-label="New post">
<div class="top">
<div class="close-x" id="close-compose">✕</div>
<a class="small" href="#" id="drafts-link">Drafts</a>
</div>
<div class="composer" style="border:none">
<div class="avatar">裕平</div>
<div class="composer-box">
<div class="reply-scope">Everyone can reply</div>
<textarea id="modal-input" placeholder="What’s happening?"></textarea>
<div class="row">
<div class="icons">
<div class="ic-btn">🖼️</div><div class="ic-btn">🌀</div><div class="ic-btn">📊</div><div class="ic-btn">😊</div><div class="ic-btn">🗓️</div><div class="ic-btn">📍</div>
</div>
<button id="modal-post" class="post-btn" disabled>Post</button>
</div>
</div>
</div>
</div>
</div>
<!-- Toast -->
<div class="toast" id="toast">
<div>✅ Your post was sent.</div>
<a class="view" href="#feed-anchor">View</a>
</div>
<script>
/* ---------- Router ---------- */
const views = {
home: document.getElementById('view-home'),
explore: document.getElementById('view-explore'),
notifications: document.getElementById('view-notifications'),
messages: document.getElementById('view-messages'),
lists: document.getElementById('view-lists'),
profile: document.getElementById('view-profile'),
grok: document.getElementById('view-grok'),
encrypted: document.getElementById('view-encrypted'),
};
function show(view){
for(const k in views){ views[k].classList.add('hide'); }
(views[view]||views.home).classList.remove('hide');
window.location.hash = view;
}
document.querySelectorAll('.sb-btn').forEach(btn=>{
btn.addEventListener('click', ()=>show(btn.dataset.nav));
});
window.addEventListener('load', ()=>{
const v = location.hash.replace('#','');
if(v && views[v]) show(v);
});
/* ---------- Composer (inline) ---------- */
const composeInput = document.getElementById('compose-input');
const postBtn = document.getElementById('post-btn');
composeInput.addEventListener('input',()=>{
const on = composeInput.value.trim().length>0;
postBtn.disabled = !on; postBtn.classList.toggle('enabled', on);
});
postBtn.addEventListener('click', ()=>{
addPost(composeInput.value.trim());
composeInput.value=''; postBtn.disabled=true; postBtn.classList.remove('enabled');
showToast();
});
/* ---------- Composer (modal) ---------- */
const modal = document.getElementById('composer-modal');
const openCompose = document.getElementById('open-compose');
const closeCompose = document.getElementById('close-compose');
const modalInput = document.getElementById('modal-input');
const modalPost = document.getElementById('modal-post');
openCompose.addEventListener('click', ()=>{ modal.style.display='flex'; modalInput.focus(); });
closeCompose.addEventListener('click', ()=>{ modal.style.display='none'; });
modalInput.addEventListener('input', ()=>{
const on = modalInput.value.trim().length>0;
modalPost.disabled = !on; modalPost.classList.toggle('enabled', on);
});
modalPost.addEventListener('click', ()=>{
addPost(modalInput.value.trim());
modalInput.value=''; modalPost.disabled=true; modalPost.classList.remove('enabled');
modal.style.display='none'; showToast();
});
modal.addEventListener('click', (e)=>{ if(e.target===modal) modal.style.display='none'; });
/* ---------- Add Post ---------- */
function addPost(text){
if(!text) return;
const card = document.createElement('article');
card.className='card';
card.innerHTML = `
<div class="avatar">裕平</div>
<div style="flex:1">
<div class="meta"><span class="name">長留裕平</span><span class="handle"> · now</span></div>
<div>${escapeHTML(text)}</div>
<div class="actions">
<div class="action">💬 <span>0</span></div>
<div class="action">🔁 <span>0</span></div>
<div class="action">❤️ <span>0</span></div>
<div class="action">↗️</div>
</div>
</div>`;
const anchor = document.getElementById('feed-anchor');
anchor.parentNode.insertBefore(card, anchor);
}
function escapeHTML(s){return s.replaceAll('&','&').replaceAll('<','<').replaceAll('>','>')}
/* ---------- Toast ---------- */
const toast = document.getElementById('toast');
function showToast(){
toast.style.display='flex';
clearTimeout(showToast._t);
showToast._t = setTimeout(()=> toast.style.display='none', 2600);
}
/* ---------- Home tab visual only ---------- */
document.querySelectorAll('[data-home-tab]').forEach(t=>{
t.addEventListener('click', ()=>{
document.querySelectorAll('[data-home-tab]').forEach(x=>x.classList.remove('active'));
t.classList.add('active');
});
});
</script>
</body>
</html>
MailLite — シンプルWebメール
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MailLite — シンプルWebメール</title>
<style>
:root{
--bg:#f6f7fb;
--panel:#ffffff;
--text:#1f2937;
--muted:#6b7280;
--primary:#4f46e5;
--primary-weak:#eef2ff;
--border:#e5e7eb;
--danger:#ef4444;
--success:#10b981;
--warning:#f59e0b;
}
.dark{
--bg:#0b0e15;
--panel:#0f1623;
--text:#e5e7eb;
--muted:#9ca3af;
--primary:#8b5cf6;
--primary-weak:#221a36;
--border:#1f2937;
--danger:#f87171;
--success:#34d399;
--warning:#fbbf24;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;background:var(--bg);color:var(--text);
font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,"Noto Sans JP",sans-serif;
}
.app{
display:grid;grid-template-rows:56px 1fr;height:100%;
}
/* Topbar */
.topbar{
display:flex;align-items:center;gap:12px;
padding:0 16px;border-bottom:1px solid var(--border);background:var(--panel);
position:sticky;top:0;z-index:5;
}
.logo{
display:flex;align-items:center;gap:10px;font-weight:700;
letter-spacing:.2px;
}
.badge{font-size:10px;padding:2px 6px;border-radius:999px;background:var(--primary-weak);color:var(--primary)}
.search{
margin-left:auto;display:flex;align-items:center;gap:8px;background:var(--bg);
padding:6px 10px;border-radius:10px;border:1px solid var(--border);min-width:220px;max-width:460px;flex:1;
}
.search input{border:none;background:transparent;outline:none;color:var(--text);width:100%}
.icon{width:18px;height:18px;display:inline-block;flex:0 0 18px}
.btn{
display:inline-flex;align-items:center;gap:8px;padding:8px 12px;border-radius:10px;
border:1px solid var(--border);background:var(--panel);cursor:pointer;color:var(--text);
}
.btn.primary{background:var(--primary);border-color:var(--primary);color:#fff}
.btn.ghost{background:transparent}
.btn:disabled{opacity:.6;cursor:not-allowed}
/* Layout */
.layout{
display:grid;grid-template-columns:260px 360px 1fr;gap:12px;padding:12px;height:calc(100vh - 56px);
}
.panel{background:var(--panel);border:1px solid var(--border);border-radius:14px;overflow:hidden;display:flex;flex-direction:column;min-height:0}
.sidebar{padding:12px}
.compose-block{padding:12px}
.compose-button{width:100%;justify-content:center}
.nav-group{margin-top:8px}
.nav-item{
display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:10px;color:var(--text);text-decoration:none;cursor:pointer;
}
.nav-item:hover{background:var(--primary-weak)}
.nav-item.active{background:var(--primary);color:#fff}
.nav-item .count{margin-left:auto;opacity:.8}
/* List */
.list-toolbar{display:flex;align-items:center;gap:8px;padding:8px;border-bottom:1px solid var(--border)}
.list{overflow:auto}
.msg{
display:grid;grid-template-columns:24px 1fr auto;gap:10px;padding:12px;border-bottom:1px solid var(--border);cursor:pointer;
}
.msg:hover{background:var(--primary-weak)}
.msg.unread{background:linear-gradient(0deg,transparent,transparent), var(--panel);font-weight:600}
.msg .from{color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.msg .subject{color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.msg .snippet{color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.msg .meta{display:flex;flex-direction:column;align-items:end;gap:4px;color:var(--muted)}
.chip{display:inline-flex;align-items:center;gap:6px;padding:2px 8px;border-radius:999px;border:1px solid var(--border);font-size:11px}
.star{cursor:pointer;opacity:.7}
.star.active{opacity:1}
.avatar{
width:24px;height:24px;border-radius:50%;background:linear-gradient(135deg,var(--primary),#22c1c3);
display:grid;place-items:center;color:#fff;font-size:12px;font-weight:700;
}
/* Reader */
.reader-head{
padding:12px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;flex-wrap:wrap;
}
.reader-title{font-size:18px;font-weight:700}
.reader-meta{color:var(--muted);font-size:12px}
.reader-actions{margin-left:auto;display:flex;gap:8px}
.reader-body{padding:16px;overflow:auto}
.empty{display:grid;place-items:center;height:100%;color:var(--muted)}
/* Modal */
.modal{
position:fixed;inset:0;background:rgba(0,0,0,.4);display:none;align-items:center;justify-content:center;z-index:20;
}
.modal.open{display:flex}
.modal-card{
width:min(920px,94vw);max-height:88vh;overflow:auto;background:var(--panel);border:1px solid var(--border);
border-radius:16px;
}
.modal-head{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border)}
.modal-body{padding:16px;display:grid;gap:10px}
.field{display:grid;gap:6px}
.input, textarea{
width:100%;padding:10px 12px;border-radius:10px;border:1px solid var(--border);
background:transparent;color:var(--text);outline:none;
}
textarea{min-height:220px;resize:vertical}
.row{display:flex;gap:10px;flex-wrap:wrap}
.grow{flex:1}
/* Responsive */
@media (max-width: 1100px){
.layout{grid-template-columns:220px 1fr}
.reader{display:none}
.layout.show-reader .list{display:none}
.layout.show-reader .reader{display:flex}
}
@media (max-width: 640px){
.layout{grid-template-columns:1fr}
.sidebar{display:none}
}
</style>
</head>
<body>
<div class="app">
<!-- Topbar -->
<div class="topbar">
<div class="logo">
<svg class="icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M3 7.5 12 13l9-5.5v9A2.5 2.5 0 0 1 18.5 19h-13A2.5 2.5 0 0 1 3 16.5v-9Z" stroke="currentColor" stroke-width="1.5"/>
<path d="m3 7.5 9-5 9 5" stroke="currentColor" stroke-width="1.5"/>
</svg>
MailLite <span class="badge">beta</span>
</div>
<div class="search">
<svg class="icon" viewBox="0 0 24 24" fill="none"><path d="m21 21-4.2-4.2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="1.5"/></svg>
<input id="search" placeholder="メールを検索(差出人・件名・本文・ラベル)" />
<button class="btn ghost" id="clearSearch" title="検索クリア">クリア</button>
</div>
<button class="btn" id="toggleDark" title="ダークモード">
<svg class="icon" viewBox="0 0 24 24" fill="none"><path d="M12 3a9 9 0 1 0 9 9 7 7 0 0 1-9-9Z" stroke="currentColor" stroke-width="1.5"/></svg>
主题
</button>
<button class="btn primary" id="composeBtn">
<svg class="icon" viewBox="0 0 24 24" fill="none"><path d="M4 20h16M6 14l9.5-9.5a2.1 2.1 0 1 1 3 3L9 17l-5 1 2-4Z" stroke="#fff" stroke-width="1.5" stroke-linejoin="round"/></svg>
新規作成
</button>
</div>
<!-- Main layout -->
<div class="layout" id="layout">
<!-- Sidebar -->
<aside class="panel sidebar">
<div class="compose-block">
<button class="btn primary compose-button" id="composeBtn2">
<svg class="icon" viewBox="0 0 24 24" fill="none"><path d="M4 20h16M6 14l9.5-9.5a2.1 2.1 0 1 1 3 3L9 17l-5 1 2-4Z" stroke="#fff" stroke-width="1.5" stroke-linejoin="round"/></svg>
新規メール
</button>
</div>
<nav class="nav-group" id="folders"></nav>
</aside>
<!-- List -->
<section class="panel">
<div class="list-toolbar">
<button class="btn" id="markReadBtn" title="既読にする">既読</button>
<button class="btn" id="markUnreadBtn" title="未読にする">未読</button>
<button class="btn" id="archiveBtn" title="アーカイブ">アーカイブ</button>
<button class="btn" id="deleteBtn" title="削除">削除</button>
<div style="margin-left:auto;display:flex;align-items:center;gap:6px;">
<span class="chip"><span class="dot" style="width:8px;height:8px;border-radius:50%;background:var(--primary)"></span> ラベル</span>
</div>
</div>
<div class="list" id="list"></div>
</section>
<!-- Reader -->
<section class="panel reader" id="reader">
<div class="empty" id="emptyState">メールを選択してください</div>
<div style="display:none;flex-direction:column;height:100%;" id="readerWrap">
<div class="reader-head">
<div style="display:flex;align-items:center;gap:10px;min-width:0">
<div class="avatar" id="readerAvatar">Y</div>
<div style="min-width:0">
<div class="reader-title" id="readerSubject">件名</div>
<div class="reader-meta" id="readerMeta">From – To ・ 日付</div>
</div>
</div>
<div class="reader-actions">
<button class="btn" id="replyBtn" title="返信">返信</button>
<button class="btn" id="starBtn" title="スター">
<span id="starIcon">☆</span> スター
</button>
<button class="btn" id="archBtn" title="アーカイブ">アーカイブ</button>
<button class="btn" id="trashBtn" title="削除" style="color:var(--danger)">削除</button>
</div>
</div>
<div class="reader-body">
<div id="readerBody"></div>
</div>
</div>
</section>
</div>
</div>
<!-- Compose Modal -->
<div class="modal" id="composeModal" aria-hidden="true">
<div class="modal-card">
<div class="modal-head">
<strong>新規メッセージ</strong>
<div class="row">
<button class="btn" id="saveDraftBtn">下書き保存</button>
<button class="btn primary" id="sendBtn">送信</button>
<button class="btn" id="closeModalBtn">閉じる</button>
</div>
</div>
<div class="modal-body">
<div class="row">
<div class="field grow">
<label for="to">宛先(カンマ区切り)</label>
<input class="input" id="to" placeholder="example@example.com, someone@domain.jp" />
</div>
<div class="field" style="min-width:160px;">
<label for="label">ラベル</label>
<input class="input" id="label" placeholder="work, personal など" />
</div>
</div>
<div class="field">
<label for="subject">件名</label>
<input class="input" id="subject" placeholder="件名を入力" />
</div>
<div class="field">
<label for="body">本文</label>
<textarea id="body" placeholder="本文を入力"></textarea>
</div>
</div>
</div>
</div>
<script>
/** ======= Simple Mail App (no backend) ======= */
const DB_KEY = "maillite-db-v1";
const QS = sel => document.querySelector(sel);
const QSA = sel => [...document.querySelectorAll(sel)];
const state = {
currentFolder: "inbox",
query: "",
selectedIds: new Set(),
currentId: null,
db: { messages: [] }
};
const FOLDERS = [
{id:"inbox", name:"受信箱", icon:"📥"},
{id:"starred", name:"スター", icon:"⭐"},
{id:"sent", name:"送信済み", icon:"📤"},
{id:"drafts", name:"下書き", icon:"📝"},
{id:"archive", name:"アーカイブ",icon:"🗄️"},
{id:"spam", name:"迷惑", icon:"🚫"},
{id:"trash", name:"ゴミ箱", icon:"🗑️"},
];
function initDB(){
const saved = localStorage.getItem(DB_KEY);
if(saved){
state.db = JSON.parse(saved);
return;
}
// Seed sample messages
const now = Date.now();
const demo = [
mkMsg("suzuki@example.com","ようこそ MailLite へ","MailLite をお試しいただきありがとうございます!\n\nこのメールはデモです。",["welcome"], now-3600_000),
mkMsg("shop@ec.example.com","【お知らせ】サマーセール開催!","最大 50%OFF。今すぐチェック!",["promo"], now-7200_000),
mkMsg("boss@company.jp","明日の打合せ議題","・リリース計画\n・障害対応\n・コスト見直し",["work"], now-86400_000, true),
mkMsg("friend@chat.jp","週末の予定どう?","映画かカラオケ行かない?",["personal"], now-5400_000),
mkMsg("security@service.jp","ログイン通知","新しい端末からログインがありました。",["security"], now-9600_000),
];
demo[2].unread = false;
state.db.messages = demo;
persist();
}
function mkMsg(from, subject, body, labels=[], time=Date.now(), starred=false){
const id = crypto.randomUUID();
const initials = (from.split("@")[0][0]||"U").toUpperCase();
return {
id, box:"inbox", from, to:[], subject, body, labels, starred, unread:true,
date: time, initials
};
}
function persist(){ localStorage.setItem(DB_KEY, JSON.stringify(state.db)); }
function formatDate(ts){
const d = new Date(ts);
const pad = n => String(n).padStart(2,"0");
return `${d.getFullYear()}/${pad(d.getMonth()+1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
// Render folders
function renderFolders(){
const el = QS("#folders");
el.innerHTML = "";
for(const f of FOLDERS){
const count = countFolder(f.id);
const a = document.createElement("a");
a.className = "nav-item" + (state.currentFolder===f.id?" active":"");
a.dataset.id = f.id;
a.innerHTML = `<span>${f.icon}</span><span>${f.name}</span><span class="count">${count||""}</span>`;
a.addEventListener("click", ()=>{
state.currentFolder = f.id;
state.selectedIds.clear();
state.currentId = null;
render();
});
el.appendChild(a);
}
}
function countFolder(folder){
return filterByFolder(state.db.messages, folder).length;
}
function filterByFolder(list, folder){
switch(folder){
case "inbox": return list.filter(m=>m.box==="inbox");
case "starred": return list.filter(m=>m.starred && m.box!=="trash");
case "sent": return list.filter(m=>m.box==="sent");
case "drafts": return list.filter(m=>m.box==="drafts");
case "archive": return list.filter(m=>m.box==="archive");
case "spam": return list.filter(m=>m.box==="spam");
case "trash": return list.filter(m=>m.box==="trash");
default: return list;
}
}
// Search
function matchesQuery(m,q){
if(!q) return true;
const s = q.toLowerCase();
const hay = [
m.from, (m.to||[]).join(","), m.subject, m.body, (m.labels||[]).join(",")
].join("\n").toLowerCase();
return hay.includes(s);
}
// Render list
function renderList(){
const wrap = QS("#list");
const items = filterByFolder(state.db.messages, state.currentFolder)
.filter(m=>matchesQuery(m, state.query))
.sort((a,b)=>b.date-a.date);
wrap.innerHTML = "";
if(items.length===0){
wrap.innerHTML = `<div class="empty" style="height:100%;">${state.query? "検索結果がありません":"このフォルダは空です"}</div>`;
return;
}
for(const m of items){
const row = document.createElement("div");
row.className = "msg" + (m.unread?" unread":"");
row.dataset.id = m.id;
const starClass = m.starred? "active":"";
row.innerHTML = `
<div class="avatar" title="${m.from}">${m.initials}</div>
<div style="min-width:0">
<div class="from">${m.from}</div>
<div class="subject">${m.subject}</div>
<div class="snippet">${(m.labels?.length? m.labels.map(l=>"#"+l).join(" ")+" · ":"")}${m.body.replace(/\n/g," ").slice(0,120)}</div>
</div>
<div class="meta">
<div>${formatDate(m.date)}</div>
<div class="star ${starClass}" data-star-id="${m.id}" title="スター">${m.starred?"★":"☆"}</div>
</div>
`;
row.addEventListener("click", (e)=>{
// If star clicked, don't open
if(e.target && e.target.dataset.starId){ return; }
openMessage(m.id);
});
row.querySelector(".star").addEventListener("click",(e)=>{
e.stopPropagation();
toggleStar(m.id);
});
wrap.appendChild(row);
}
}
// Reader
function openMessage(id){
const m = state.db.messages.find(x=>x.id===id);
if(!m) return;
state.currentId = id;
m.unread = false;
persist();
QS("#emptyState").style.display = "none";
QS("#readerWrap").style.display = "flex";
QS("#readerSubject").textContent = m.subject || "(件名なし)";
QS("#readerMeta").textContent = `From: ${m.from} / To: ${(m.to||[]).join(", ")||"(なし)"} ・ ${formatDate(m.date)}`;
QS("#readerBody").innerHTML = safeHtml(m.body).replace(/\n/g,"<br>");
QS("#readerAvatar").textContent = m.initials || "U";
QS("#starIcon").textContent = m.starred ? "★" : "☆";
// Mobile: show reader
QS("#layout").classList.add("show-reader");
render();
}
function safeHtml(s=""){
return s.replace(/[&<>"']/g, ch => ({
"&":"&","<":"<",">":">",'"':""","'":"'"
}[ch]));
}
// Actions
function getSelectedOrCurrentIds(){
if(state.selectedIds.size>0) return [...state.selectedIds];
if(state.currentId) return [state.currentId];
return [];
}
function markRead(read=true){
for(const id of getSelectedOrCurrentIds()){
const m = state.db.messages.find(x=>x.id===id);
if(m) m.unread = !read;
}
persist(); render();
}
function moveTo(box){
for(const id of getSelectedOrCurrentIds()){
const m = state.db.messages.find(x=>x.id===id);
if(m) m.box = box;
}
// If current message was moved out of view, clear reader
if(state.currentId){
const cm = state.db.messages.find(x=>x.id===state.currentId);
const visible = filterByFolder([cm], state.currentFolder).length>0;
if(!visible){ state.currentId = null; QS("#readerWrap").style.display="none"; QS("#emptyState").style.display="grid"; }
}
persist(); render();
}
function toggleStar(id){
const m = state.db.messages.find(x=>x.id===id);
if(m){ m.starred = !m.starred; persist(); render(); if(state.currentId===id) QS("#starIcon").textContent=m.starred?"★":"☆"; }
}
// Compose
function openCompose(prefill={}){
QS("#composeModal").classList.add("open");
QS("#to").value = prefill.to?.join(", ") || "";
QS("#subject").value = prefill.subject || "";
QS("#body").value = prefill.body || "";
QS("#label").value = (prefill.labels||[]).join(", ");
}
function closeCompose(){ QS("#composeModal").classList.remove("open"); }
function saveDraft(){
const draft = collectForm();
draft.box = "drafts";
draft.unread = false;
draft.from = "me@local";
draft.id = crypto.randomUUID();
state.db.messages.push(draft);
persist(); closeCompose(); render();
alert("下書きを保存しました。");
}
function sendMail(){
const msg = collectForm();
if(!msg.to.length){ alert("宛先を入力してください"); return; }
msg.box = "sent";
msg.unread = false;
msg.from = "me@local";
msg.id = crypto.randomUUID();
state.db.messages.push(msg);
persist(); closeCompose(); render();
alert("送信しました(デモ動作:実送信なし)");
}
function collectForm(){
const to = QS("#to").value.split(",").map(s=>s.trim()).filter(Boolean);
const subject = QS("#subject").value.trim() || "(件名なし)";
const body = QS("#body").value;
const labels = QS("#label").value.split(",").map(s=>s.trim()).filter(Boolean);
return { to, subject, body, labels, date: Date.now(), starred:false, unread:true, initials:"M" };
}
// Selection (click+Ctrl/Shift optional simplified)
QS("#list").addEventListener("click",(e)=>{
const row = e.target.closest(".msg");
if(!row) return;
if(e.ctrlKey || e.metaKey){
const id = row.dataset.id;
if(state.selectedIds.has(id)) state.selectedIds.delete(id); else state.selectedIds.add(id);
row.classList.toggle("selected");
}
});
// Topbar controls
QS("#composeBtn").onclick = ()=>openCompose();
QS("#composeBtn2").onclick = ()=>openCompose();
QS("#closeModalBtn").onclick = closeCompose;
QS("#saveDraftBtn").onclick = saveDraft;
QS("#sendBtn").onclick = sendMail;
QS("#search").addEventListener("input",(e)=>{ state.query = e.target.value.trim(); renderList(); });
QS("#clearSearch").onclick = ()=>{ QS("#search").value=""; state.query=""; renderList(); };
QS("#toggleDark").onclick = ()=>{
document.body.classList.toggle("dark");
localStorage.setItem("maillite-theme", document.body.classList.contains("dark")? "dark":"light");
};
// List toolbar actions
QS("#markReadBtn").onclick = ()=>markRead(true);
QS("#markUnreadBtn").onclick = ()=>markRead(false);
QS("#archiveBtn").onclick = ()=>moveTo("archive");
QS("#deleteBtn").onclick = ()=>moveTo("trash");
// Reader actions
QS("#replyBtn").onclick = ()=>{
const m = state.db.messages.find(x=>x.id===state.currentId);
if(!m) return;
openCompose({ to:[m.from], subject:"Re: "+m.subject, body:`\n\n--- ${m.from} さんのメッセージ ---\n${m.body}`, labels:["reply"] });
};
QS("#starBtn").onclick = ()=>{ if(state.currentId) toggleStar(state.currentId); };
QS("#archBtn").onclick = ()=>moveTo("archive");
QS("#trashBtn").onclick = ()=>moveTo("trash");
// Clicking outside modal to close
QS("#composeModal").addEventListener("click",(e)=>{ if(e.target===QS("#composeModal")) closeCompose(); });
// Mobile back from reader by clicking empty area (or press Escape)
document.addEventListener("keydown",(e)=>{
if(e.key==="Escape"){
if(QS("#composeModal").classList.contains("open")) closeCompose();
else QS("#layout").classList.remove("show-reader");
}
});
// Initial render
function render(){
renderFolders();
renderList();
}
(function boot(){
initDB();
render();
// Theme
if(localStorage.getItem("maillite-theme")==="dark"){ document.body.classList.add("dark"); }
})();
</script>
</body>
</html>
ミニ百科.html
<!DOCTYPE html>
<html lang="ja" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ミニ百科 – シングルファイル版</title>
<meta name="description" content="検索・カテゴリ・タグ・ブックマーク対応のシングルファイル百科事典。" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" />
<!-- TailwindCSS (CDN) -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome (icons) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" integrity="sha512-5I0VnK5tQhJ0eZ5Ck1gC3b6h9fJ3k6l9FeI3K6J0q9JtO1Yw1l2Y7N5M6d2xQf8Q2F6mZ8l2s3A=" crossorigin="anonymous" referrerpolicy="no-referrer" />
<!-- Favicon (inline SVG) -->
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Cpath fill='%234f46e5' d='M32 56c0-13.3 10.7-24 24-24h144c13.3 0 24 10.7 24 24v144c0 13.3-10.7 24-24 24H56c-13.3 0-24-10.7-24-24z'/%3E%3Cpath fill='white' d='M72 80h112v16H72zM72 112h80v16H72zM72 144h112v16H72zM72 176h96v16H72z'/%3E%3C/svg%3E" />
<style>
/* 追加の細かなスタイル */
.line-clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.prose h2{scroll-margin-top:6rem}
.toc a{display:block;padding:.25rem .5rem;border-radius:.5rem}
.toc a.active{background:rgba(99,102,241,.12)}
</style>
<script>
// ダークモード初期化
(function(){
const theme=localStorage.getItem('theme');
if(theme==='dark'||(!theme&&window.matchMedia('(prefers-color-scheme: dark)').matches)){
document.documentElement.classList.add('dark');
}
})();
</script>
</head>
<body class="bg-slate-50 text-slate-800 dark:bg-slate-900 dark:text-slate-100 min-h-screen">
<!-- Skip link -->
<a href="#main" class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:bg-indigo-600 focus:text-white focus:px-3 focus:py-2 focus:rounded">本文へスキップ</a>
<!-- Header -->
<header class="sticky top-0 z-40 backdrop-blur border-b border-slate-200/60 dark:border-slate-700/60 bg-white/70 dark:bg-slate-900/70">
<div class="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8 py-3 flex items-center gap-3">
<button id="btnHome" class="shrink-0 px-2 py-1 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/40" title="ホーム">
<i class="fa-solid fa-book-open text-indigo-600"></i>
</button>
<h1 class="text-lg sm:text-2xl font-bold tracking-tight">ミニ百科 <span class="text-indigo-600">Mini Encyclopedia</span></h1>
<div class="ms-auto flex items-center gap-2">
<button id="btnRandom" class="px-3 py-2 rounded-xl bg-indigo-600 text-white text-sm hover:opacity-90"><i class="fa-solid fa-shuffle me-1"></i>ランダム</button>
<button id="btnBookmarks" class="px-3 py-2 rounded-xl bg-amber-500 text-white text-sm hover:opacity-90"><i class="fa-solid fa-star me-1"></i>ブックマーク</button>
<button id="btnDark" class="px-3 py-2 rounded-xl bg-slate-800 text-white text-sm dark:bg-slate-700 hover:opacity-90" title="ダーク/ライト切替"><i class="fa-solid fa-moon"></i></button>
</div>
</div>
</header>
<!-- Toolbar -->
<section class="border-b border-slate-200/60 dark:border-slate-700/60 bg-white/60 dark:bg-slate-900/60">
<div class="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8 py-4 grid gap-3 sm:grid-cols-12 items-end">
<div class="sm:col-span-6">
<label for="search" class="block text-sm text-slate-600 dark:text-slate-300 mb-1">記事検索</label>
<div class="relative">
<input id="search" type="search" placeholder="キーワード(例: 富士山 / 恐竜 / インターネット)" class="w-full rounded-2xl border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800/80 px-4 py-2 pe-10 outline-none focus:ring-2 focus:ring-indigo-500" />
<i class="fa-solid fa-magnifying-glass absolute right-3 top-1/2 -translate-y-1/2 text-slate-400"></i>
</div>
</div>
<div class="sm:col-span-3">
<label for="category" class="block text-sm text-slate-600 dark:text-slate-300 mb-1">カテゴリ</label>
<select id="category" class="w-full rounded-2xl border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800/80 px-4 py-2 outline-none focus:ring-2 focus:ring-indigo-500">
<option value="">すべて</option>
</select>
</div>
<div class="sm:col-span-3">
<label for="sort" class="block text-sm text-slate-600 dark:text-slate-300 mb-1">並び替え</label>
<select id="sort" class="w-full rounded-2xl border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800/80 px-4 py-2 outline-none focus:ring-2 focus:ring-indigo-500">
<option value="recent">更新が新しい順</option>
<option value="title">タイトル順</option>
</select>
</div>
<div class="sm:col-span-12" id="tagBar" aria-label="タグフィルタ" class="flex flex-wrap gap-2"></div>
</div>
</section>
<main id="main" class="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8 py-6">
<!-- Home / List View -->
<section id="view-home" class="grid gap-6">
<div class="flex items-center justify-between">
<h2 class="text-xl sm:text-2xl font-semibold">記事一覧</h2>
<div class="text-sm text-slate-500"><span id="resultCount">0</span> 件</div>
</div>
<div id="cards" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<!-- cards injected -->
</div>
<div class="flex items-center justify-center gap-2 pt-2" id="pager"></div>
</section>
<!-- Article View -->
<section id="view-article" class="hidden lg:grid lg:grid-cols-12 gap-8">
<aside class="lg:col-span-3 order-last lg:order-first">
<div class="sticky top-[6.5rem] border border-slate-200 dark:border-slate-700 rounded-2xl p-4">
<h3 class="font-semibold mb-2">目次</h3>
<nav id="toc" class="toc text-sm space-y-1"></nav>
</div>
</aside>
<article class="lg:col-span-9">
<nav class="text-sm text-slate-500 mb-3" id="breadcrumb"></nav>
<header class="mb-4">
<h1 id="articleTitle" class="text-2xl sm:text-3xl font-bold tracking-tight"></h1>
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm text-slate-500" id="articleMeta"></div>
<div class="mt-3 flex items-center gap-2">
<button id="btnCopyLink" class="px-3 py-2 rounded-xl border border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-800"><i class="fa-solid fa-link me-1"></i>リンクをコピー</button>
<button id="btnToggleBookmark" class="px-3 py-2 rounded-xl border border-amber-400 text-amber-600 hover:bg-amber-50"><i class="fa-regular fa-star me-1"></i>ブックマーク</button>
</div>
</header>
<div id="articleContent" class="prose prose-slate dark:prose-invert max-w-none"></div>
<section class="mt-8">
<h3 class="font-semibold mb-2">関連タグ</h3>
<div id="articleTags" class="flex flex-wrap gap-2"></div>
</section>
</article>
</section>
</main>
<footer class="border-t border-slate-200 dark:border-slate-700 py-8">
<div class="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8 text-sm text-slate-500 flex flex-wrap items-center gap-2">
<span>© <span id="year"></span> ミニ百科</span>
<span class="mx-1">•</span>
<button id="btnExport" class="underline underline-offset-4">データを書き出す(JSON)</button>
<span class="mx-1">•</span>
<label class="cursor-pointer">JSON読み込み <input id="fileImport" type="file" accept="application/json" class="hidden" /></label>
</div>
</footer>
<!-- Structured data placeholder (updated on article view) -->
<script id="ldjson" type="application/ld+json">{}</script>
<script>
// ======================
// サンプル記事データ
// ======================
/**
* 記事スキーマ:
* id, slug, title, category, tags[], summary, updated(ISO), author, content(HTML)
*/
const ARTICLES = [
{
id: 1,
slug: 'fuji-san',
title: '富士山',
category: '地理',
tags: ['日本','山','世界文化遺産'],
summary: '日本の象徴ともいわれる成層火山。標高3,776mで日本最高峰。',
updated: '2025-08-15',
author: 'ミニ百科編集部',
content: `
<p>富士山は本州中部に位置する<span>成層火山</span>で、標高は3,776m。2013年に世界文化遺産に登録されました。古来より信仰の対象であり、芸術や文学にも多く登場します。</p>
<h2 id="geo">地形と地質</h2>
<p>富士山は何度もの噴火活動を経て現在の美しい円錐形を形成しました。火口は山頂部にあり、外輪としてお鉢巡りが知られています。</p>
<h2 id="climb">登山と保全</h2>
<p>一般的な登山シーズンは夏。登山道の混雑やゴミ問題、低温・高山病などのリスク対策が重要です。</p>
<h2 id="culture">文化的意義</h2>
<p>葛飾北斎の『富嶽三十六景』をはじめ、絵画や和歌に頻繁に詠まれ、日本の象徴として国際的にも広く知られています。</p>
`
},
{
id: 2,
slug: 'internet-basics',
title: 'インターネットの基礎',
category: 'テクノロジー',
tags: ['ネットワーク','Web','通信'],
summary: '世界中のコンピュータを相互接続する情報ネットワークの総称。',
updated: '2025-07-01',
author: 'ミニ百科編集部',
content: `
<p>インターネットは標準化された<span>TCP/IP</span>により機器同士が通信する巨大なネットワークです。Web、メール、動画配信など多様なサービスの土台になっています。</p>
<h2 id="protocols">主要プロトコル</h2>
<p>HTTP/HTTPS、DNS、SMTP、FTPなどが代表的。セキュリティ確保には暗号化や認証が重要です。</p>
<h2 id="web">Webの仕組み</h2>
<p>ブラウザがURLを解決し、サーバからHTML/CSS/JS等のリソースを取得・表示します。</p>
<h2 id="safety">安全な利用</h2>
<p>二要素認証、ソフトウェア更新、フィッシング対策、強力なパスワード管理が基本です。</p>
`
},
{
id: 3,
slug: 'dinosaurs',
title: '恐竜',
category: '生物',
tags: ['古生物学','白亜紀','化石'],
summary: '中生代に栄えた爬虫類のグループ。鳥類は恐竜の系統に含まれると考えられている。',
updated: '2025-05-28',
author: 'ミニ百科編集部',
content: `
<p>恐竜は約2億3000万年前に出現し、中生代に多様化しました。<span>鳥類</span>は恐竜の一系統とみなされます。</p>
<h2 id="era">時代区分</h2>
<p>三畳紀・ジュラ紀・白亜紀に区分され、各時代で特徴的な種が繁栄しました。</p>
<h2 id="extinction">大量絶滅</h2>
<p>約6600万年前の大量絶滅で多くが消滅。隕石衝突や火山活動などが要因と考えられています。</p>
`
},
{
id: 4,
slug: 'ww2-overview',
title: '第二次世界大戦(概説)',
category: '歴史',
tags: ['20世紀','戦争','国際関係'],
summary: '1939年から1945年にかけて行われた世界規模の戦争。',
updated: '2025-03-10',
author: 'ミニ百科編集部',
content: `
<p>第二次世界大戦は多数の国が参戦した世界規模の戦争で、政治・経済・科学技術・社会に長期の影響を与えました。</p>
<h2 id="fronts">主要戦線</h2>
<p>ヨーロッパ、太平洋、北アフリカ、東部戦線など多くの戦域に分かれました。</p>
<h2 id="aftermath">戦後の世界</h2>
<p>国際連合の設立、冷戦構造の形成、国際秩序の再編などにつながりました。</p>
`
},
{
id: 5,
slug: 'ai-basics',
title: '人工知能の基礎',
category: 'テクノロジー',
tags: ['AI','機械学習','深層学習'],
summary: '知的な処理をコンピュータで実現する研究分野と技術群。',
updated: '2025-06-12',
author: 'ミニ百科編集部',
content: `
<p>人工知能は探索・推論から機械学習・深層学習まで多様な手法を含みます。現代では大量データと計算資源により実世界応用が拡大。</p>
<h2 id="ml">機械学習</h2>
<p>教師あり・教師なし・強化学習などの枠組みがあり、予測や分類に用いられます。</p>
<h2 id="dl">深層学習</h2>
<p>多層ニューラルネットワークにより画像・音声・自然言語処理で高精度を実現。</p>
`
},
{
id: 6,
slug: 'sakura',
title: 'サクラ(桜)',
category: '文化',
tags: ['日本文化','植物','季節'],
summary: '日本の春を象徴する花。花見は古くからの季節行事。',
updated: '2025-04-02',
author: 'ミニ百科編集部',
content: `
<p>桜はバラ科サクラ属の総称。品種が多く、花期は短いものの観賞価値が高いことで知られます。</p>
<h2 id="hanami">花見の歴史</h2>
<p>貴族文化から庶民に広がり、現在では地域の祭りや観光資源にもなっています。</p>
`
},
{
id: 7,
slug: 'japan-history-outline',
title: '日本史(概説)',
category: '歴史',
tags: ['古代','中世','近代'],
summary: '古代から現代までの日本の歴史を大まかに概観する。',
updated: '2025-01-20',
author: 'ミニ百科編集部',
content: `
<p>日本史は縄文・弥生・古墳などの古代から、中世・近世、明治以降の近代・現代に至るまで連続する多様な変化の歴史です。</p>
<h2 id="ancient">古代</h2>
<p>稲作の普及、古代国家の形成、律令制の確立など。</p>
<h2 id="modern">近代・現代</h2>
<p>近代化、戦後復興、高度経済成長、少子高齢化と新たな課題。</p>
`
},
{
id: 8,
slug: 'programming-intro',
title: 'プログラミング入門',
category: 'テクノロジー',
tags: ['コード','アルゴリズム','学習'],
summary: 'コンピュータに手順を伝えるための技術と考え方の総称。',
updated: '2025-07-22',
author: 'ミニ百科編集部',
content: `
<p>プログラミングは問題を分解し、再利用可能な手順として表現する作業です。変数、条件分岐、反復、関数などの基本を学ぶと応用が広がります。</p>
<h2 id="lang">主な言語</h2>
<p>Python、JavaScript、C#、C++ など用途に応じて選択されます。</p>
`
},
{
id: 9,
slug: 'tea-ceremony',
title: '茶道',
category: '文化',
tags: ['日本文化','礼法','芸道'],
summary: '湯を沸かし茶を点て、客をもてなす総合芸術。',
updated: '2025-05-03',
author: 'ミニ百科編集部',
content: `
<p>茶道は道具、作法、空間、季節感などが一体となる総合芸術です。<span>和敬清寂</span>の精神が重視されます。</p>
<h2 id="tools">道具</h2>
<p>茶碗、茶筅、茶杓、釜、柄杓など。取り扱いには所作と配慮が求められます。</p>
`
},
{
id: 10,
slug: 'solar-system',
title: '太陽系',
category: '天文学',
tags: ['惑星','衛星','宇宙'],
summary: '太陽とその周囲を公転する天体の集まり。',
updated: '2025-06-30',
author: 'ミニ百科編集部',
content: `
<p>太陽系は太陽を中心に、8つの惑星、準惑星、小惑星、彗星、塵やガスが重力で結びつくシステムです。</p>
<h2 id="planets">惑星</h2>
<p>水星・金星・地球・火星・木星・土星・天王星・海王星。各惑星は固有の特徴を持ちます。</p>
`
}
];
// ================
// ユーティリティ
// ================
const $ = (sel, root=document) => root.querySelector(sel);
const $$ = (sel, root=document) => Array.from(root.querySelectorAll(sel));
const fmtDate = iso => new Date(iso).toLocaleDateString('ja-JP', {year:'numeric', month:'short', day:'numeric'});
const unique = arr => [...new Set(arr)];
const slugToArticle = slug => ARTICLES.find(a=>a.slug===slug);
const STORAGE = {
bookmarks: 'mini_ency_bookmarks',
history: 'mini_ency_history'
};
function getBookmarks(){
try{return JSON.parse(localStorage.getItem(STORAGE.bookmarks)||'[]');}catch{ return []; }
}
function setBookmarks(list){ localStorage.setItem(STORAGE.bookmarks, JSON.stringify(unique(list))); }
function isBookmarked(slug){ return getBookmarks().includes(slug); }
function toggleBookmark(slug){
const list=getBookmarks();
if(list.includes(slug)) setBookmarks(list.filter(s=>s!==slug));
else setBookmarks([...list, slug]);
}
function pushHistory(slug){
try{
const now = Date.now();
const hist = JSON.parse(localStorage.getItem(STORAGE.history)||'[]');
const filtered = hist.filter(h=>h.slug!==slug);
filtered.unshift({slug, t: now});
localStorage.setItem(STORAGE.history, JSON.stringify(filtered.slice(0,50)));
}catch{}
}
// ================
// 検索・フィルタ
// ================
let state = {
q: '',
category: '',
tag: '',
sort: 'recent',
page: 1,
perPage: 9
};
function normalize(str){ return (str||'').toString().toLowerCase(); }
function filterArticles(){
let list = ARTICLES.slice();
if(state.q){
const q = normalize(state.q);
list = list.filter(a => normalize(a.title+" "+a.summary+" "+a.tags.join(' ')+" "+a.content.replace(/<[^>]+>/g,'')).includes(q));
}
if(state.category){ list = list.filter(a => a.category===state.category); }
if(state.tag){ list = list.filter(a => a.tags.includes(state.tag)); }
if(state.sort==='recent'){ list.sort((a,b)=> new Date(b.updated)-new Date(a.updated)); }
if(state.sort==='title'){ list.sort((a,b)=> a.title.localeCompare(b.title,'ja')); }
return list;
}
// =============
// 一覧描画
// =============
function renderCategories(){
const select = $('#category');
const cats = unique(ARTICLES.map(a=>a.category)).sort((a,b)=>a.localeCompare(b,'ja'));
select.innerHTML = '<option value="">すべて</option>' + cats.map(c=>`<option value="${c}">${c}</option>`).join('');
}
function renderTagsBar(){
const bar = $('#tagBar');
const tags = unique(ARTICLES.flatMap(a=>a.tags)).sort((a,b)=>a.localeCompare(b,'ja'));
bar.innerHTML = '<div class="flex flex-wrap gap-2">' + tags.map(t=>
`<button data-tag="${t}" class="tag-btn px-3 py-1 rounded-full border border-slate-300 dark:border-slate-700 text-sm hover:bg-slate-100 dark:hover:bg-slate-800 ${state.tag===t?'bg-indigo-600 text-white border-indigo-600':''}">#${t}</button>`
).join('') + '</div>';
$$('.tag-btn').forEach(b=> b.addEventListener('click',()=>{ state.tag = (state.tag===b.dataset.tag? '' : b.dataset.tag); state.page=1; syncList(); }));
}
function createCard(a){
const bookmarked = isBookmarked(a.slug);
return `
<article class="border border-slate-200 dark:border-slate-700 rounded-2xl p-4 bg-white/70 dark:bg-slate-800/70 hover:shadow transition">
<header class="flex items-start justify-between gap-3">
<h3 class="text-lg font-semibold leading-tight">${a.title}</h3>
<button class="bookmark inline-flex items-center justify-center w-9 h-9 rounded-full ${bookmarked?'text-amber-500':'text-slate-400'}" title="ブックマーク" data-slug="${a.slug}">
<i class="${bookmarked?'fa-solid':'fa-regular'} fa-star"></i>
</button>
</header>
<div class="mt-1 text-sm text-slate-500">${a.category}・更新 ${fmtDate(a.updated)}</div>
<p class="mt-2 text-sm line-clamp-3">${a.summary}</p>
<div class="mt-3 flex flex-wrap gap-2 text-xs">${a.tags.map(t=>`<span class='px-2 py-1 rounded-full bg-slate-100 dark:bg-slate-700'>#${t}</span>`).join('')}</div>
<div class="mt-4 flex gap-2">
<a href="#/a/${a.slug}" class="inline-flex items-center gap-2 px-3 py-2 rounded-xl bg-indigo-600 text-white text-sm hover:opacity-90"><i class="fa-solid fa-circle-info"></i> 詳細</a>
<button class="copy-link inline-flex items-center gap-2 px-3 py-2 rounded-xl border border-slate-300 dark:border-slate-700 text-sm hover:bg-slate-100 dark:hover:bg-slate-800" data-link="${location.origin+location.pathname}#/a/${a.slug}"><i class="fa-solid fa-link"></i>リンク</button>
</div>
</article>
`;
}
function renderPager(total){
const pager = $('#pager');
const pages = Math.max(1, Math.ceil(total/state.perPage));
state.page = Math.min(state.page, pages);
let html='';
for(let i=1;i<=pages;i++){
html += `<button class="px-3 py-1 rounded-lg border ${i===state.page?'bg-indigo-600 text-white border-indigo-600':'border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-800'}" data-page="${i}">${i}</button>`;
}
pager.innerHTML = html;
$$('#pager button').forEach(b=> b.addEventListener('click',()=>{ state.page=Number(b.dataset.page); syncList(false); }));
}
function syncList(scrollTop=true){
const list = filterArticles();
$('#resultCount').textContent = list.length;
renderPager(list.length);
const start=(state.page-1)*state.perPage;
const pageItems=list.slice(start, start+state.perPage);
$('#cards').innerHTML = pageItems.map(createCard).join('');
// events
$$('.bookmark').forEach(b=> b.addEventListener('click',()=>{ toggleBookmark(b.dataset.slug); syncList(false); }));
$$('.copy-link').forEach(b=> b.addEventListener('click',()=> copyText(b.dataset.link)));
if(scrollTop) window.scrollTo({top:0, behavior:'smooth'});
}
// =================
// 記事ページ描画
// =================
function renderArticle(slug){
const a = slugToArticle(slug);
if(!a){ location.hash = ''; return; }
// breadcrumb
$('#breadcrumb').innerHTML = `<a class="underline" href="#">ホーム</a> / <span class="text-slate-600">${a.category}</span>`;
// title & meta
$('#articleTitle').textContent = a.title;
$('#articleMeta').innerHTML = `
<span><i class="fa-regular fa-calendar"></i> 更新 ${fmtDate(a.updated)}</span>
<span class="mx-1">•</span>
<span><i class="fa-regular fa-user"></i> ${a.author}</span>
<span class="mx-1">•</span>
<span><i class="fa-solid fa-folder"></i> ${a.category}</span>
`;
// content
const container = $('#articleContent');
container.innerHTML = a.content;
// tags
$('#articleTags').innerHTML = a.tags.map(t=>`<a href="#" data-tag="${t}" class="px-3 py-1 rounded-full border border-slate-300 dark:border-slate-700 text-sm hover:bg-slate-100 dark:hover:bg-slate-800">#${t}</a>`).join('');
$$('#articleTags a').forEach(el=> el.addEventListener('click',(e)=>{ e.preventDefault(); state.tag=el.dataset.tag; location.hash=''; }));
// bookmark button
const btnBM = $('#btnToggleBookmark');
const setBM = ()=>{
const marked = isBookmarked(a.slug);
btnBM.innerHTML = `<i class="${marked?'fa-solid':'fa-regular'} fa-star me-1"></i>${marked?'保存済み':'ブックマーク'}`;
};
btnBM.onclick = ()=>{ toggleBookmark(a.slug); setBM(); };
setBM();
// copy link
$('#btnCopyLink').onclick = ()=> copyText(location.href);
// TOC
buildTOC();
// JSON-LD
updateLDJSON(a);
// history
pushHistory(a.slug);
}
function buildTOC(){
const toc = $('#toc');
const headings = $$('#articleContent h2, #articleContent h3');
if(headings.length===0){ toc.innerHTML = '<div class="text-slate-500 text-sm">見出しがありません</div>'; return; }
let html='';
headings.forEach(h=>{
if(!h.id) h.id = h.textContent.trim().toLowerCase().replace(/[^a-z0-9一-龥ぁ-んァ-ヶー]+/g,'-');
const indent = h.tagName==='H3' ? 'ms-4' : '';
html += `<a href="#${h.id}" class="${indent} hover:text-indigo-600">${h.textContent}</a>`;
});
toc.innerHTML = html;
const observer = new IntersectionObserver((entries)=>{
entries.forEach(e=>{
if(e.isIntersecting){
$$('#toc a').forEach(a=>a.classList.remove('active'));
const a = $(`#toc a[href="#${e.target.id}"]`);
if(a) a.classList.add('active');
}
});
}, {rootMargin: '0px 0px -70% 0px'});
headings.forEach(h=> observer.observe(h));
}
function updateLDJSON(a){
const obj = {
'@context':'https://schema.org',
'@type':'Article',
headline: a.title,
dateModified: a.updated,
author: { '@type':'Organization', name: a.author },
keywords: a.tags.join(','),
articleSection: a.category,
url: location.href
};
$('#ldjson').textContent = JSON.stringify(obj);
}
// ============
// ルーター
// ============
function route(){
const hash = location.hash.slice(1);
if(hash.startsWith('/a/')){
const slug = hash.split('/')[2];
$('#view-home').classList.add('hidden');
$('#view-article').classList.remove('hidden');
renderArticle(slug);
window.scrollTo({top:0, behavior:'instant'});
}else{
$('#view-article').classList.add('hidden');
$('#view-home').classList.remove('hidden');
syncList();
}
}
window.addEventListener('hashchange', route);
// ============
// 便利機能
// ============
function copyText(text){
navigator.clipboard.writeText(text).then(()=>{
toast('リンクをコピーしました');
}, ()=>{
prompt('コピーできない場合は手動で選択してコピーしてください:', text);
});
}
function toast(msg){
const t = document.createElement('div');
t.textContent = msg;
t.className = 'fixed left-1/2 -translate-x-1/2 bottom-6 z-50 bg-black/80 text-white px-4 py-2 rounded-xl text-sm';
document.body.appendChild(t);
setTimeout(()=>{ t.remove(); }, 1600);
}
function randomArticle(){
const a = ARTICLES[Math.floor(Math.random()*ARTICLES.length)];
location.hash = `#/a/${a.slug}`;
}
// ==============
// I/O (JSON)
// ==============
function exportJSON(){
const blob = new Blob([JSON.stringify(ARTICLES, null, 2)], {type:'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'mini-encyclopedia.json'; a.click();
URL.revokeObjectURL(url);
}
function importJSON(file){
const reader = new FileReader();
reader.onload = (e)=>{
try{
const data = JSON.parse(e.target.result);
if(Array.isArray(data)){
// 形式が正しければ差し替え
if(data.every(x=>x.slug && x.title && x.content)){
ARTICLES.length = 0; // 破壊的更新
data.forEach(x=> ARTICLES.push(x));
init();
toast('JSONを読み込みました');
}else{
alert('スキーマが不正です。slug/title/content は必須です。');
}
}else{
alert('配列形式のJSONが必要です');
}
}catch(err){
alert('JSONの解析に失敗しました: '+err.message);
}
};
reader.readAsText(file);
}
// ============
// 初期化
// ============
function init(){
// 年
$('#year').textContent = new Date().getFullYear();
// カテゴリ・タグバー
renderCategories();
renderTagsBar();
// イベント
$('#search').addEventListener('input', (e)=>{ state.q = e.target.value.trim(); state.page=1; syncList(); });
$('#category').addEventListener('change', (e)=>{ state.category = e.target.value; state.page=1; syncList(); });
$('#sort').addEventListener('change', (e)=>{ state.sort = e.target.value; state.page=1; syncList(); });
$('#btnRandom').addEventListener('click', randomArticle);
$('#btnBookmarks').addEventListener('click', ()=>{
const bms = getBookmarks();
if(bms.length===0){ toast('ブックマークはまだありません'); return; }
const first = slugToArticle(bms[0]);
if(first) location.hash = `#/a/${first.slug}`;
});
$('#btnHome').addEventListener('click', ()=>{ location.hash=''; });
// ダークモード切替
$('#btnDark').addEventListener('click', ()=>{
const root = document.documentElement;
const isDark = root.classList.toggle('dark');
localStorage.setItem('theme', isDark? 'dark':'light');
});
// JSON I/O
$('#btnExport').addEventListener('click', exportJSON);
$('#fileImport').addEventListener('change', (e)=>{ const f=e.target.files?.[0]; if(f) importJSON(f); e.target.value=''; });
// 初回描画
route();
}
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>
XLogpro.html(ツイートまとめサイト)
<!DOCTYPE html>
<html lang="ja" data-theme="light" style="--cols:3; --card-h:640px; --accent:#2563eb">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Xlog Pro — HTMLだけで動く自動ツイートまとめ</title>
<link rel="preconnect" href="https://platform.twitter.com" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"/>
<meta name="description" content="HTMLだけ/APIキー不要のX(Twitter)まとめボード。プロフィール・ハッシュタグ・検索・リストを好きな列で配置し、JSON/HTML書き出しや手動ランキング、ボード切替に対応。">
<style>
:root{
--bg: #0b0e14; --panel:#111827; --muted:#9aa4b2; --text:#e5e7eb; --border:#1f2937; --chip:#141a23; --card:#0f172a; --btn:#1f2937; --btn-text:#e5e7eb; --link:#60a5fa;
}
[data-theme="light"]{ --bg:#f8fafc; --panel:#ffffff; --muted:#64748b; --text:#0f172a; --border:#e2e8f0; --chip:#f1f5f9; --card:#ffffff; --btn:#0f172a; --btn-text:#ffffff; --link:#1d9bf0; }
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,"Apple Color Emoji","Segoe UI Emoji"}
header{position:sticky;top:0;z-index:10;background:var(--panel);border-bottom:1px solid var(--border)}
.wrap{max-width:1280px;margin:0 auto;padding:12px 16px}
.row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
.brand{display:flex;gap:10px;align-items:center;font-weight:800}
.brand i{color:var(--accent)}
.muted{color:var(--muted)}
.pill{display:inline-flex;gap:8px;align-items:center;background:var(--chip);border:1px solid var(--border);border-radius:999px;padding:6px 10px}
.input, select, textarea{background:transparent;border:1px solid var(--border);border-radius:10px;padding:8px 10px;color:var(--text)}
textarea{min-height:88px;width:100%;}
input[type="text"].input{min-width:220px}
button{cursor:pointer;border:none}
.btn{background:var(--btn);color:var(--btn-text);padding:9px 12px;border-radius:12px}
.btn.secondary{background:transparent;color:var(--text);border:1px solid var(--border)}
.btn.ghost{background:transparent;color:var(--text)}
.btn.badge{padding:6px 10px;border-radius:999px}
.grid{display:grid;grid-template-columns:320px 1fr;gap:16px}
@media (max-width:1080px){.grid{grid-template-columns:1fr}}
aside{background:var(--panel);border:1px solid var(--border);border-radius:16px;padding:14px;position:sticky;top:72px;height:max-content}
h2{margin:6px 0 12px 0;font-size:18px}
.list{display:flex;flex-direction:column;gap:12px}
.card{background:var(--card);border:1px solid var(--border);border-radius:16px;overflow:hidden}
.card .head{display:flex;justify-content:space-between;align-items:center;padding:12px 14px;border-bottom:1px solid var(--border)}
.card .head .title{display:flex;gap:8px;align-items:center;font-weight:700}
.card .body{padding:0;min-height:var(--card-h)}
.sources{display:flex;flex-wrap:wrap;gap:8px}
.chip{background:var(--chip);border:1px solid var(--border);border-radius:999px;padding:6px 10px;display:flex;gap:8px;align-items:center}
.chip b{color:var(--accent)}
.columns{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:16px}
@media (max-width:1200px){:root{--cols:2}}
@media (max-width:860px){:root{--cols:1}}
.drag{cursor:grab}
.toolbar{display:flex;gap:8px;flex-wrap:wrap}
.footer{padding:24px 16px;color:var(--muted);text-align:center}
.kbd{font-family:ui-monospace, Menlo, Monaco, Consolas; background:var(--chip); border:1px solid var(--border); padding:2px 6px; border-radius:6px}
.danger{color:#ef4444}
.accent{color:var(--accent)}
.section{background:var(--panel);border:1px solid var(--border);border-radius:16px;padding:14px}
.help{font-size:13px;color:var(--muted)}
.label{font-size:12px;color:var(--muted)}
.tiny{font-size:12px}
.row-wrap{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.w-100{width:100%}
.space{height:8px}
</style>
</head>
<body>
<header>
<div class="wrap row">
<div class="brand"><i class="fa-solid fa-wave-square"></i> Xlog <span class="muted">Pro</span></div>
<div class="pill">
<i class="fa-solid fa-diagram-project"></i>
<select id="boardSelect" title="ボード切替"></select>
<button id="boardNew" class="btn badge secondary" title="新規ボード"><i class="fa-solid fa-plus"></i></button>
<button id="boardRename" class="btn badge secondary" title="名前変更"><i class="fa-solid fa-pen"></i></button>
<button id="boardDelete" class="btn badge secondary danger" title="削除"><i class="fa-regular fa-trash-can"></i></button>
</div>
<div class="pill" title="テーマ切替"><i class="fa-solid fa-circle-half-stroke"></i>
<label class="row-wrap"><input id="themeToggle" type="checkbox" /> ダーク</label>
</div>
<div class="pill" title="アクセントカラー">
<i class="fa-solid fa-palette"></i>
<input id="accentPicker" type="color" value="#2563eb" />
</div>
<div class="pill" title="列数"><i class="fa-solid fa-table-columns"></i>
<input id="colsRange" type="range" min="1" max="4" step="1" value="3"/>
<span id="colsVal" class="tiny"></span>
</div>
<div class="pill" title="カード高さ"><i class="fa-solid fa-up-down"></i>
<input id="heightRange" type="range" min="360" max="1200" step="40" value="640"/>
<span id="heightVal" class="tiny"></span>
</div>
<div class="pill" title="自動再読み込み">
<label class="row-wrap"><input id="autoRefreshToggle" type="checkbox"/> 自動</label>
<select id="refreshMinutes">
<option value="3">3分</option>
<option value="5" selected>5分</option>
<option value="10">10分</option>
<option value="30">30分</option>
</select>
</div>
<div class="pill help tiny">ショートカット: <span class="kbd">N</span> 追加 / <span class="kbd">R</span> 再描画 / <span class="kbd">G</span> グリッド- / <span class="kbd">H</span> 高さ-</div>
</div>
</header>
<main class="wrap grid">
<aside>
<div class="list">
<div class="section">
<h2>ソースを追加</h2>
<div class="toolbar row-wrap">
<select id="sourceType">
<option value="profile">プロフィール</option>
<option value="hashtag">ハッシュタグ</option>
<option value="search">検索キーワード</option>
<option value="list">リストURL</option>
</select>
<input id="sourceValue" class="input" type="text" placeholder="@username / #tag / キーワード / リストURL" />
<input id="sourceLabel" class="input" type="text" placeholder="表示名(任意)" />
<button id="addBtn" class="btn"><i class="fa-solid fa-plus"></i> 追加</button>
</div>
<div class="space"></div>
<label class="label">まとめて追加(改行/カンマ区切りOK)</label>
<textarea id="bulkArea" placeholder="@OpenAI, #UnrealEngine, Unity URP, https://twitter.com/i/lists/123...\n@EpicGames"></textarea>
<div class="row-wrap">
<button id="bulkAdd" class="btn secondary"><i class="fa-solid fa-download"></i> 取り込み</button>
<button id="bulkClear" class="btn ghost"><i class="fa-solid fa-eraser"></i> クリア</button>
</div>
<p class="help" style="margin-top:8px">形式は自動判定:<span class="kbd">@id</span> → プロフィール、<span class="kbd">#tag</span> → ハッシュタグ、<span class="kbd">twitter.com/i/lists</span> → リスト、それ以外は検索。</p>
<div class="space"></div>
<div class="row-wrap help tiny">クイック追加:</div>
<div class="row-wrap">
<button class="btn badge secondary quick" data-type="hashtag" data-val="#UnrealEngine">#UnrealEngine</button>
<button class="btn badge secondary quick" data-type="hashtag" data-val="#Unity3D">#Unity3D</button>
<button class="btn badge secondary quick" data-type="search" data-val="VRM OR \"Meta Quest\"">VR/Quest</button>
<button class="btn badge secondary quick" data-type="profile" data-val="@OpenAI">@OpenAI</button>
</div>
</div>
<div class="section">
<h2>保存・書き出し</h2>
<div class="toolbar row-wrap">
<button id="exportBtn" class="btn secondary"><i class="fa-solid fa-file-export"></i> JSON</button>
<label class="btn secondary" for="importFile"><i class="fa-solid fa-file-import"></i> JSON読込</label>
<input id="importFile" type="file" accept="application/json" hidden />
<button id="exportHtmlBtn" class="btn"><i class="fa-regular fa-file-code"></i> 単一HTML</button>
<button id="clearBtn" class="btn ghost danger"><i class="fa-regular fa-trash-can"></i> すべて削除</button>
</div>
<p class="help">単一HTML: いまのレイアウトと設定を埋め込んだ自立HTMLを生成します。</p>
</div>
<div class="section">
<h2>手動ランキング</h2>
<div class="toolbar row-wrap">
<input id="tweetUrl" class="input" type="text" placeholder="ツイートURLを貼り付け" />
<button id="addTweetBtn" class="btn"><i class="fa-brands fa-x-twitter"></i> 追加</button>
</div>
<label class="label">メモ(任意・次回以降も保持)</label>
<textarea id="tweetNote" placeholder="このツイートの要点やタグ(例: #UE5 #VRM)"></textarea>
<p class="help">※HTMLのみの制約で自動集計は不可。URLをカード化して手動で順序を決められます。</p>
</div>
<div class="section">
<h2>RSS生成(ランキング→RSS)</h2>
<div class="toolbar row-wrap">
<input id="rssTitle" class="input" type="text" placeholder="RSSタイトル(例: Xlogランキング)"/>
<button id="rssExport" class="btn secondary"><i class="fa-solid fa-rss"></i> RSSを書き出し</button>
</div>
<p class="help">ランキングに登録したツイートURLから簡易RSS(XML)を生成し、ファイルとして保存します。</p>
</div>
<div class="section">
<h2>ヘルプ</h2>
<div class="help">
・列の並べ替えはカードの <span class="kbd">⋯</span> アイコンをドラッグ。<br>
・<span class="kbd">R</span> で全カラムを再描画。<br>
・URLハッシュ <span class="kbd">#data=</span> に設定をBase64で埋め込んで共有可能(メニューから自動生成予定)。
</div>
</div>
</div>
</aside>
<section>
<div class="card" style="margin-bottom:16px">
<div class="head">
<div class="title"><i class="fa-solid fa-layer-group drag"></i> マイまとめ <span class="muted tiny" id="boardInfo"></span></div>
<div class="sources" id="activeChips"></div>
</div>
<div class="body" style="padding:14px">
<div id="columns" class="columns"></div>
</div>
</div>
<div class="card">
<div class="head">
<div class="title"><i class="fa-regular fa-star"></i> 手動ランキング</div>
<div class="help">ドラッグで順序変更/🗑で削除/✎でメモ編集</div>
</div>
<div class="body" style="padding:14px">
<div id="ranking" class="columns"></div>
</div>
</div>
<div class="footer">Xlog Pro v2 — HTML Only / Embedded Timelines. No API keys. <span class="muted">Made for you.</span></div>
</section>
</main>
<script async src="https://platform.twitter.com/widgets.js"></script>
<script>
// ========== 基本ユーティリティ ==========
const $ = (s, d=document)=>d.querySelector(s);
const $$ = (s, d=document)=>Array.from(d.querySelectorAll(s));
const defaultBoard = ()=>({sources:[], tweets:[]});
const defaultState = ()=>({
version:2,
dark:false,
accent:'#2563eb',
autoRefresh:false,
minutes:5,
columns:3,
cardHeight:640,
boards:{'Default': defaultBoard()},
activeBoard:'Default'
});
const store = {
key: 'xlog-pro-v2',
load(){
try{ return JSON.parse(localStorage.getItem(this.key)) || defaultState(); }
catch(e){ return defaultState(); }
},
save(v){ localStorage.setItem(this.key, JSON.stringify(v)); }
};
function migrate(s){
const base = defaultState();
if (!s || typeof s !== 'object') return base;
// v1互換(sources/tweets直下 → boards.Default)
if (s.sources || s.tweets){
base.boards.Default.sources = s.sources||[];
base.boards.Default.tweets = s.tweets||[];
}
// 既存キー上書き
for (const k of ['dark','accent','autoRefresh','minutes','columns','cardHeight','boards','activeBoard']){
if (k in s) base[k]=s[k];
}
return base;
}
// ハッシュ (#data=BASE64) から読み込み
function loadFromHash(){
const h = location.hash || '';
if (!h.startsWith('#data=')) return null;
try{
const b64 = decodeURIComponent(h.slice(6));
const json = atob(b64);
return JSON.parse(json);
}catch(e){ return null; }
}
let embedded = (typeof window.__XLOG_INITIAL_STATE__!== 'undefined') ? window.__XLOG_INITIAL_STATE__ : null;
if (!embedded){
const el = document.getElementById('xlog-init');
if (el) { try{ embedded = JSON.parse(el.textContent); }catch(_e){} }
}
let state = migrate( embedded || loadFromHash() || store.load() );
// ========== テーマ/アクセント/レイアウト適用 ==========
function applySkin(){
document.documentElement.setAttribute('data-theme', state.dark ? 'dark' : 'light');
document.documentElement.style.setProperty('--cols', state.columns);
document.documentElement.style.setProperty('--card-h', state.cardHeight+'px');
document.documentElement.style.setProperty('--accent', state.accent || '#2563eb');
$('#themeToggle').checked = !!state.dark;
$('#colsRange').value = String(state.columns);
$('#colsVal').textContent = state.columns+'列';
$('#heightRange').value = String(state.cardHeight);
$('#heightVal').textContent = state.cardHeight+'px';
$('#accentPicker').value = state.accent || '#2563eb';
}
// ========== X埋め込み ==========
function waitTwttr(){
return new Promise(res=>{
if (window.twttr && twttr.widgets) return res();
const timer = setInterval(()=>{ if(window.twttr && twttr.widgets){ clearInterval(timer); res(); } }, 200);
});
}
function timelineOptions(){
return {
height: state.cardHeight,
theme: state.dark ? 'dark' : 'light',
chrome: 'nofooter noborders transparent',
linkColor: getComputedStyle(document.documentElement).getPropertyValue('--link').trim() || '#1d9bf0'
};
}
async function createTimeline(el, src){
await waitTwttr();
const opts = timelineOptions();
const t = (src.type||'profile');
if (t==='profile'){
const screenName = src.value.replace(/^@/,'');
return twttr.widgets.createTimeline({ sourceType:'profile', screenName }, el, opts);
}
if (t==='list'){
return twttr.widgets.createTimeline({ sourceType:'url', url: src.value }, el, opts);
}
if (t==='hashtag'){
const tag = src.value.replace(/^#/,'');
const url = `https://twitter.com/hashtag/${encodeURIComponent(tag)}?f=live`;
return twttr.widgets.createTimeline({ sourceType:'url', url }, el, opts);
}
if (t==='search'){
const url = `https://twitter.com/search?q=${encodeURIComponent(src.value)}&f=live`;
return twttr.widgets.createTimeline({ sourceType:'url', url }, el, opts);
}
}
// ========== 現在ボードの参照 ==========
function board(){ return state.boards[state.activeBoard] || (state.boards[state.activeBoard]=defaultBoard()); }
// ========== 描画 ==========
function chipNode(src, idx){
const chip = document.createElement('span');
chip.className='chip';
const kind = {profile:'@',hashtag:'#',search:'検索:',list:'リスト'}[src.type] || '';
chip.innerHTML = `<b>${kind}</b> ${src.label || src.value} <a class="muted" href="${openUrl(src)}" target="_blank" title="Xで開く"><i class="fa-solid fa-arrow-up-right-from-square"></i></a> <button title="削除" data-del="${idx}" class="muted"><i class="fa-solid fa-xmark"></i></button>`;
chip.querySelector('button').onclick = ()=>{ board().sources.splice(idx,1); store.save(state); renderAll(); };
return chip;
}
function openUrl(src){
if (src.type==='profile') return `https://twitter.com/${src.value.replace(/^@/,'')}`;
if (src.type==='hashtag') return `https://twitter.com/hashtag/${src.value.replace(/^#/,'')}`;
if (src.type==='list') return src.value;
return `https://twitter.com/search?q=${encodeURIComponent(src.value)}&f=live`;
}
function columnCard(src, idx){
const card = document.createElement('div');
card.className='card';
card.draggable=true; card.dataset.idx=idx;
card.innerHTML = `
<div class="head">
<div class="title"><i class="fa-solid fa-grip-vertical drag"></i> ${src.label || prettyLabel(src)}</div>
<div class="toolbar">
<a class="btn ghost" href="${openUrl(src)}" target="_blank" title="Xで開く"><i class="fa-solid fa-arrow-up-right-from-square"></i></a>
<button class="btn ghost" title="再読み込み" data-refresh="${idx}"><i class="fa-solid fa-rotate"></i></button>
<button class="btn ghost danger" title="削除" data-remove="${idx}"><i class="fa-regular fa-trash-can"></i></button>
</div>
</div>
<div class="body"><div class="embed" style="min-height:120px"></div></div>`;
// DnD 並べ替え
card.addEventListener('dragstart', e=>{ e.dataTransfer.setData('text/plain', idx); card.style.opacity='0.6'; });
card.addEventListener('dragend', ()=>{ card.style.opacity='1'; });
card.addEventListener('dragover', e=>{ e.preventDefault(); card.style.outline='2px dashed var(--accent)'; });
card.addEventListener('dragleave', ()=>{ card.style.outline='none'; });
card.addEventListener('drop', e=>{
e.preventDefault(); card.style.outline='none';
const from = +e.dataTransfer.getData('text/plain');
const to = +card.dataset.idx;
if (from===to) return;
const arr = board().sources;
const [moved] = arr.splice(from,1);
arr.splice(to,0,moved);
store.save(state); renderAll();
});
// 操作
card.querySelector('[data-remove]')?.addEventListener('click', ()=>{ board().sources.splice(idx,1); store.save(state); renderAll(); });
card.querySelector('[data-refresh]')?.addEventListener('click', ()=>{ mountTimeline(card, src); });
// 初回描画
mountTimeline(card, src);
return card;
}
function mountTimeline(card, src){
const holder = card.querySelector('.embed');
holder.innerHTML = '<div style="padding:14px" class="muted">読み込み中…</div>';
createTimeline(holder, src).catch(()=>{
holder.innerHTML = '<div style="padding:14px" class="danger">読み込みに失敗しました。値を確認してください。</div>';
});
}
function prettyLabel(src){
if (src.type==='profile') return '@'+src.value.replace(/^@/,'');
if (src.type==='hashtag') return '#'+src.value.replace(/^#/,'');
if (src.type==='search') return '検索: '+src.value;
if (src.type==='list') return 'リスト';
return src.value;
}
function renderAll(){
// ボード情報
const info = $('#boardInfo');
const b = board();
info.textContent = `(${state.activeBoard}|${b.sources.length}列 / ${b.tweets.length}件)`;
// チップ
const chips = $('#activeChips'); chips.innerHTML='';
b.sources.forEach((s,i)=> chips.appendChild(chipNode(s,i)) );
// カラム
const col = $('#columns'); col.innerHTML='';
b.sources.forEach((s,i)=> col.appendChild(columnCard(s,i)) );
// ランキング
renderRanking();
}
// ========== ランキング ==========
function parseTweetId(url){ const m = (url||'').match(/status\/(\d{5,})/); return m? m[1] : null; }
function tweetUrlFromId(id){ return `https://twitter.com/i/web/status/${id}`; }
async function addTweet(url, note){
const id = parseTweetId(url);
if (!id) return alert('ツイートURLが正しくありません');
board().tweets.push({id, note: (note||'')});
store.save(state); renderRanking();
}
async function renderRanking(){
await waitTwttr();
const root = $('#ranking'); root.innerHTML='';
const arr = board().tweets;
arr.forEach((t, idx)=>{
const card = document.createElement('div'); card.className='card'; card.draggable=true; card.dataset.idx=idx;
card.innerHTML = `
<div class="head">
<div class="title"><i class="fa-solid fa-grip-vertical drag"></i> エントリ #${idx+1}</div>
<div class="toolbar">
<button class="btn ghost" data-edit="${idx}" title="メモ編集"><i class="fa-regular fa-pen-to-square"></i></button>
<a class="btn ghost" href="${tweetUrlFromId(t.id)}" target="_blank" title="Xで開く"><i class="fa-solid fa-arrow-up-right-from-square"></i></a>
<button class="btn ghost danger" title="削除" data-del-rank="${idx}"><i class="fa-regular fa-trash-can"></i></button>
</div>
</div>
<div class="body">
<div class="embed"></div>
<div style="padding:10px 14px;border-top:1px solid var(--border)" class="tiny"><span class="muted">メモ:</span> <span class="note">${escapeHtml(t.note||'')}</span></div>
</div>`;
// イベント
card.querySelector('[data-del-rank]')?.addEventListener('click', ()=>{ arr.splice(idx,1); store.save(state); renderRanking(); });
card.querySelector('[data-edit]')?.addEventListener('click', ()=>{
const newNote = prompt('メモを編集', t.note||'');
if (newNote!==null){ t.note = newNote; store.save(state); renderRanking(); }
});
// DnD 並べ替え
card.addEventListener('dragstart', e=>{ e.dataTransfer.setData('text/plain', 'rank:'+idx); card.style.opacity='0.6'; });
card.addEventListener('dragend', ()=>{ card.style.opacity='1'; });
card.addEventListener('dragover', e=>{ e.preventDefault(); card.style.outline='2px dashed var(--accent)'; });
card.addEventListener('dragleave', ()=>{ card.style.outline='none'; });
card.addEventListener('drop', e=>{
e.preventDefault(); card.style.outline='none';
const data = e.dataTransfer.getData('text/plain'); if (!data.startsWith('rank:')) return;
const from = +data.split(':')[1]; const to = +card.dataset.idx;
const [moved] = arr.splice(from,1); arr.splice(to,0,moved);
store.save(state); renderRanking();
});
const holder = card.querySelector('.embed');
twttr.widgets.createTweet(t.id, holder, { theme: state.dark ? 'dark' : 'light' });
root.appendChild(card);
});
}
function escapeHtml(s){ return (s||'').replace(/[&<>"']/g, m=> ({'&':'&','<':'<','>':'>','"':'"','\'':'''}[m])); }
// ========== 自動再描画 ==========
let refreshTimer = null;
function applyAutoRefresh(){
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer=null; }
if (state.autoRefresh){
const ms = Math.max(1, +state.minutes) * 60 * 1000;
refreshTimer = setInterval(()=>{
$$('#columns .card').forEach((card, i)=>{
const src = board().sources[i]; if (src) mountTimeline(card, src);
});
}, ms);
}
}
// ========== ボード管理 ==========
function refreshBoardSelect(){
const sel = $('#boardSelect'); sel.innerHTML='';
Object.keys(state.boards).forEach(name=>{
const opt = document.createElement('option'); opt.value=name; opt.textContent=name; sel.appendChild(opt);
});
sel.value = state.activeBoard;
}
function addBoard(name){
if (!name) return;
if (state.boards[name]) return alert('同名のボードが存在します');
state.boards[name] = defaultBoard(); state.activeBoard = name; store.save(state);
refreshBoardSelect(); renderAll();
}
function renameBoard(newName){
if (!newName) return;
if (state.boards[newName]) return alert('同名のボードが存在します');
const old = state.activeBoard;
state.boards[newName] = state.boards[old];
delete state.boards[old];
state.activeBoard = newName; store.save(state);
refreshBoardSelect(); renderAll();
}
function deleteBoard(){
const names = Object.keys(state.boards);
if (names.length<=1) return alert('最後のボードは削除できません');
if (!confirm(`ボード「${state.activeBoard}」を削除しますか?`)) return;
delete state.boards[state.activeBoard];
state.activeBoard = Object.keys(state.boards)[0];
store.save(state); refreshBoardSelect(); renderAll();
}
// ========== 共有(URLハッシュ生成) ==========
function exportHashUrl(){
const cloned = JSON.parse(JSON.stringify(state));
const json = JSON.stringify(cloned);
const b64 = btoa(json);
const url = location.origin + location.pathname + '#data=' + encodeURIComponent(b64);
return url;
}
// ========== 単一HTML出力 ==========
function download(filename, text){
const blob = new Blob([text], {type:'text/html'});
const a = document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=filename; a.click(); URL.revokeObjectURL(a.href);
}
function exportSingleHtml(){
// 現在のHTMLに初期状態スクリプトを差し込む
let html = document.documentElement.outerHTML;
const idx = html.indexOf('<head>');
const inject = `<head>\n <script>window.__XLOG_INITIAL_STATE__=${JSON.stringify(state)}<\/script>`;
if (idx>=0){ html = html.replace('<head>', inject); }
download('xlog-pro.html', '<!DOCTYPE html>\n' + html);
}
// ========== RSS生成 ==========
function exportRss(){
const title = $('#rssTitle').value.trim() || 'Xlog Ranking';
const items = board().tweets.map(t=>({
title: (t.note||'Tweet '+t.id).replace(/[\r\n]+/g,' ').slice(0,120),
link: tweetUrlFromId(t.id),
guid: t.id,
description: escapeHtml(t.note||''),
pubDate: new Date().toUTCString()
}));
const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0"><channel>\n<title>${escapeXml(title)}</title>\n<link>${escapeXml(location.href)}</link>\n<description>Generated by Xlog Pro</description>\n${items.map(i=>`<item><title>${escapeXml(i.title)}</title><link>${escapeXml(i.link)}</link><guid isPermaLink=\"false\">${escapeXml(i.guid)}</guid><description>${escapeXml(i.description)}</description><pubDate>${i.pubDate}</pubDate></item>`).join('')}\n</channel></rss>`;
const blob = new Blob([xml], {type:'application/rss+xml'});
const a = document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='xlog-ranking.xml'; a.click(); URL.revokeObjectURL(a.href);
}
function escapeXml(s){ return (s||'').replace(/[<>&\"']/g, m=> ({'<':'<','>':'>','&':'&','\"':'"','\'':'''}[m])); }
// ========== 入力ヘルパ ==========
function detectType(v){
if (/^@/.test(v)) return 'profile';
if (/^#/.test(v)) return 'hashtag';
if (/twitter\.com\/i\/lists\//.test(v)) return 'list';
return 'search';
}
function addSource(type, value, label){
const src = {type, value:value.trim(), label:(label||'').trim()};
board().sources.push(src); store.save(state); renderAll();
}
function bulkAddFromText(txt){
const parts = txt.split(/[\n,]+/).map(s=>s.trim()).filter(Boolean);
let count = 0;
for (const p of parts){ addSource(detectType(p), p, ''); count++; }
return count;
}
// ========== キーイベント ==========
function setupShortcuts(){
window.addEventListener('keydown', (e)=>{
if (['INPUT','TEXTAREA','SELECT'].includes(document.activeElement.tagName)) return;
if (e.key==='n' || e.key==='N'){ $('#sourceValue').focus(); }
if (e.key==='r' || e.key==='R'){ renderAll(); }
if (e.key==='g' || e.key==='G'){ state.columns=Math.max(1,state.columns-1); applySkin(); store.save(state); }
if (e.key==='h' || e.key==='H'){ state.cardHeight=Math.max(360,state.cardHeight-40); applySkin(); store.save(state); renderAll(); }
});
}
// ========== 設定と起動 ==========
window.addEventListener('DOMContentLoaded', ()=>{
// スキン
applySkin();
// ボード選択
refreshBoardSelect();
$('#boardSelect').addEventListener('change', (e)=>{ state.activeBoard = e.target.value; store.save(state); renderAll(); });
$('#boardNew').addEventListener('click', ()=>{ const name = prompt('新しいボード名','Board '+(Object.keys(state.boards).length+1)); addBoard(name); });
$('#boardRename').addEventListener('click', ()=>{ const name = prompt('新しい名前', state.activeBoard); if (name) renameBoard(name); });
$('#boardDelete').addEventListener('click', deleteBoard);
// テーマ/アクセント/レイアウト
$('#themeToggle').addEventListener('change', e=>{ state.dark = e.target.checked; store.save(state); applySkin(); renderAll(); });
$('#accentPicker').addEventListener('input', e=>{ state.accent = e.target.value; store.save(state); applySkin(); });
$('#colsRange').addEventListener('input', e=>{ state.columns = +e.target.value; store.save(state); applySkin(); });
$('#heightRange').addEventListener('input', e=>{ state.cardHeight = +e.target.value; store.save(state); applySkin(); renderAll(); });
// ソース追加
$('#addBtn').addEventListener('click', ()=>{
const type = $('#sourceType').value;
const val = $('#sourceValue').value.trim();
const label = $('#sourceLabel').value.trim();
if (!val) return alert('値を入力してください');
addSource(type, val, label);
$('#sourceValue').value=''; $('#sourceLabel').value='';
});
$$('.quick').forEach(btn=> btn.addEventListener('click', ()=> addSource(btn.dataset.type, btn.dataset.val, '')) );
// まとめて追加
$('#bulkAdd').addEventListener('click', ()=>{ const n = bulkAddFromText($('#bulkArea').value); alert(n+'件追加しました'); $('#bulkArea').value=''; });
$('#bulkClear').addEventListener('click', ()=> $('#bulkArea').value='' );
// ランキング
$('#addTweetBtn').addEventListener('click', ()=>{
const url = $('#tweetUrl').value.trim();
const note = $('#tweetNote').value.trim();
if (!url) return;
addTweet(url, note); $('#tweetUrl').value=''; $('#tweetNote').value='';
});
// 設定
$('#autoRefreshToggle').checked = !!state.autoRefresh;
$('#refreshMinutes').value = String(state.minutes||5);
$('#autoRefreshToggle').addEventListener('change', e=>{ state.autoRefresh = e.target.checked; store.save(state); applyAutoRefresh(); });
$('#refreshMinutes').addEventListener('change', e=>{ state.minutes = +e.target.value; store.save(state); applyAutoRefresh(); });
// 書き出し/読み込み
$('#exportBtn').addEventListener('click', ()=>{
const blob = new Blob([JSON.stringify(state,null,2)], {type:'application/json'});
const a = document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='xlog-pro-config.json'; a.click(); URL.revokeObjectURL(a.href);
});
$('#importFile').addEventListener('change', e=>{
const file = e.target.files?.[0]; if (!file) return;
const fr = new FileReader();
fr.onload = () => {
try{ const obj = JSON.parse(fr.result); state = migrate(obj); store.save(state); applySkin(); refreshBoardSelect(); renderAll(); applyAutoRefresh(); }
catch(err){ alert('JSONの読み込みに失敗しました'); }
};
fr.readAsText(file);
});
$('#exportHtmlBtn').addEventListener('click', exportSingleHtml);
$('#clearBtn').addEventListener('click', ()=>{
if (!confirm('現在のボードのソースとランキングを削除しますか?')) return;
const b = board(); b.sources = []; b.tweets=[]; store.save(state); renderAll();
});
// RSS
$('#rssExport').addEventListener('click', exportRss);
// 初期描画
renderAll();
applyAutoRefresh();
setupShortcuts();
});
</script>
</body>
</html>
