<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Crawlio Search</title>
<style>
:root {
color-scheme: light;
--text: #202124;
--muted: #5f6368;
--line: #dadce0;
--blue: #4285f4;
--red: #ea4335;
--yellow: #fbbc04;
--green: #34a853;
--shadow: 0 18px 40px rgba(60, 64, 67, 0.15);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
color: var(--text);
font-family: Arial, "Hiragino Kaku Gothic ProN", "Yu Gothic", Meiryo, sans-serif;
background:
radial-gradient(circle at top left, rgba(66, 133, 244, 0.12), transparent 32rem),
linear-gradient(180deg, #fff 0%, #f7f9fc 68%, #eef3fa 100%);
}
a {
color: inherit;
text-decoration: none;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 64px;
padding: 0 28px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 7px;
color: #3c4043;
font-size: 15px;
font-weight: 700;
}
.brand-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.nav {
display: flex;
gap: 22px;
color: #3c4043;
font-size: 14px;
}
.nav a:hover {
text-decoration: underline;
}
main {
width: min(1120px, calc(100% - 32px));
margin: 0 auto;
}
.search-shell {
position: relative;
display: grid;
place-items: center;
min-height: 430px;
padding: 38px 0 46px;
overflow: hidden;
}
.crawler-visual {
position: absolute;
inset: 12px 0 auto;
height: 320px;
pointer-events: none;
opacity: 0.92;
}
.orbit {
position: absolute;
left: 50%;
top: 50%;
border: 1px solid rgba(95, 99, 104, 0.18);
border-radius: 50%;
transform: translate(-50%, -50%);
}
.orbit-a {
width: min(640px, 86vw);
height: 210px;
}
.orbit-b {
width: min(440px, 68vw);
height: 145px;
transform: translate(-50%, -50%) rotate(-12deg);
}
.node {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
box-shadow: 0 0 0 8px rgba(66, 133, 244, 0.08);
}
.node-a {
left: calc(50% - 302px);
top: 120px;
background: var(--blue);
}
.node-b {
left: calc(50% + 250px);
top: 88px;
background: var(--green);
}
.node-c {
left: calc(50% + 72px);
top: 216px;
background: var(--red);
}
.scan-line {
position: absolute;
left: 50%;
top: 64px;
width: 3px;
height: 220px;
background: linear-gradient(180deg, transparent, rgba(66, 133, 244, 0.72), transparent);
animation: scan 3.4s ease-in-out infinite;
}
.wordmark {
position: relative;
z-index: 1;
margin: 52px 0 25px;
font-size: clamp(68px, 12vw, 112px);
font-weight: 700;
line-height: 0.95;
}
.blue { color: var(--blue); }
.red { color: var(--red); }
.yellow { color: var(--yellow); }
.green { color: var(--green); }
.search-form {
position: relative;
z-index: 1;
width: min(640px, 100%);
}
.search-box {
display: grid;
grid-template-columns: 24px 1fr 42px;
align-items: center;
min-height: 58px;
padding: 0 8px 0 21px;
background: #fff;
border: 1px solid var(--line);
border-radius: 32px;
box-shadow: 0 2px 8px rgba(60, 64, 67, 0.08);
transition: box-shadow 160ms ease, border-color 160ms ease;
}
.search-box:focus-within,
.search-box:hover {
border-color: transparent;
box-shadow: var(--shadow);
}
.search-box svg,
.icon-button svg {
width: 22px;
height: 22px;
fill: #5f6368;
}
input {
width: 100%;
border: 0;
outline: 0;
padding: 0 14px;
color: var(--text);
font-size: 17px;
background: transparent;
}
.icon-button {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border: 0;
border-radius: 50%;
background: transparent;
cursor: pointer;
}
.icon-button:hover {
background: #f1f3f4;
}
.actions {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 24px;
}
.actions button {
min-width: 112px;
min-height: 38px;
border: 1px solid #f8f9fa;
border-radius: 4px;
padding: 0 18px;
color: #3c4043;
background: #f8f9fa;
font-size: 14px;
cursor: pointer;
}
.actions button:hover {
border-color: #dadce0;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.08);
}
.results-area {
display: none;
max-width: 760px;
margin: 0 auto 46px;
}
.results-area.visible {
display: block;
}
.result-meta {
margin-bottom: 18px;
color: var(--muted);
font-size: 14px;
}
.result {
padding: 18px 0;
border-top: 1px solid #edf0f2;
}
.result-url {
color: #3c4043;
font-size: 13px;
}
.result h3 {
margin: 4px 0 6px;
color: #1a0dab;
font-size: 21px;
font-weight: 400;
}
.result p {
margin: 0;
color: #4d5156;
font-size: 14px;
line-height: 1.55;
}
.crawler-panel {
margin: 12px 0 28px;
padding: 24px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(218, 220, 224, 0.9);
border-radius: 8px;
box-shadow: 0 12px 32px rgba(60, 64, 67, 0.08);
backdrop-filter: blur(10px);
}
.panel-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 18px;
margin-bottom: 18px;
}
.eyebrow {
margin: 0 0 4px;
color: var(--blue);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
h2 {
margin: 0;
font-size: 24px;
}
.pulse {
display: inline-flex;
align-items: center;
gap: 8px;
color: #137333;
font-size: 12px;
font-weight: 700;
}
.pulse::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--green);
box-shadow: 0 0 0 8px rgba(52, 168, 83, 0.12);
}
.crawl-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.crawl-card {
min-height: 122px;
padding: 16px;
border: 1px solid #e6eaee;
border-radius: 8px;
background: #fff;
}
.crawl-card strong {
display: block;
margin-bottom: 8px;
font-size: 15px;
}
.crawl-card span {
display: block;
color: var(--muted);
font-size: 13px;
line-height: 1.45;
}
.crawl-progress {
height: 5px;
margin-top: 14px;
overflow: hidden;
border-radius: 999px;
background: #edf0f2;
}
.crawl-progress i {
display: block;
height: 100%;
width: var(--progress);
border-radius: inherit;
background: linear-gradient(90deg, var(--blue), var(--green));
}
.stats-band {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
margin-bottom: 44px;
border: 1px solid #dfe4ea;
border-radius: 8px;
background: #dfe4ea;
}
.stats-band div {
padding: 22px;
background: #fff;
}
.stats-band strong,
.stats-band span {
display: block;
}
.stats-band strong {
margin-bottom: 5px;
font-size: 27px;
}
.stats-band span {
color: var(--muted);
font-size: 13px;
}
footer {
display: flex;
flex-wrap: wrap;
gap: 22px;
padding: 18px 28px;
color: #70757a;
background: #f2f2f2;
font-size: 14px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@keyframes scan {
0%, 100% {
transform: translateX(-260px);
opacity: 0.35;
}
50% {
transform: translateX(260px);
opacity: 1;
}
}
@media (max-width: 760px) {
.topbar {
padding: 0 16px;
}
.nav {
gap: 12px;
font-size: 13px;
}
.search-shell {
min-height: 390px;
}
.crawler-visual {
height: 270px;
}
.node-a {
left: 6%;
}
.node-b {
left: 86%;
}
.node-c {
left: 58%;
}
.actions {
flex-wrap: wrap;
}
.crawl-grid,
.stats-band {
grid-template-columns: 1fr;
}
.panel-heading {
align-items: start;
flex-direction: column;
}
}
</style>
</head>
<body>
<header class="topbar">
<a class="brand" href="#" aria-label="Crawlio Search">
<span class="brand-dot blue"></span>
<span class="brand-dot red"></span>
<span class="brand-dot yellow"></span>
<span class="brand-dot green"></span>
<span>Crawlio</span>
</a>
<nav class="nav" aria-label="メイン">
<a href="#crawler">Crawler</a>
<a href="#index">Index</a>
<a href="#status">Status</a>
</nav>
</header>
<main>
<section class="search-shell" aria-labelledby="hero-title">
<div class="crawler-visual" aria-hidden="true">
<div class="orbit orbit-a"></div>
<div class="orbit orbit-b"></div>
<div class="node node-a"></div>
<div class="node node-b"></div>
<div class="node node-c"></div>
<div class="scan-line"></div>
</div>
<h1 id="hero-title" class="wordmark">
<span class="blue">C</span><span class="red">r</span><span class="yellow">a</span><span class="blue">w</span><span class="green">l</span><span class="red">i</span><span class="blue">o</span>
</h1>
<form class="search-form" id="searchForm">
<label class="sr-only" for="query">検索キーワード</label>
<div class="search-box">
<svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M10.8 18a7.2 7.2 0 1 1 5.1-12.3 7.2 7.2 0 0 1-5.1 12.3Zm0-2a5.2 5.2 0 1 0 0-10.4 5.2 5.2 0 0 0 0 10.4Zm6.3.1 4 4-1.4 1.4-4-4 1.4-1.4Z" />
</svg>
<input id="query" name="query" autocomplete="off" placeholder="URL、キーワード、サイト名を検索" />
<button class="icon-button" type="button" id="voiceButton" aria-label="音声検索">
<svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M12 14a3 3 0 0 0 3-3V6a3 3 0 1 0-6 0v5a3 3 0 0 0 3 3Zm5-3a5 5 0 0 1-10 0H5a7 7 0 0 0 6 6.9V21h2v-3.1a7 7 0 0 0 6-6.9h-2Z" />
</svg>
</button>
</div>
<div class="actions">
<button type="submit">検索</button>
<button type="button" id="crawlButton">クローラーを走らせる</button>
</div>
</form>
</section>
<section class="results-area" aria-live="polite">
<div class="result-meta" id="resultMeta">約 8,420,000 件中 0.38 秒</div>
<div class="results" id="results"></div>
</section>
<section class="crawler-panel" id="crawler" aria-labelledby="crawler-title">
<div class="panel-heading">
<div>
<p class="eyebrow">Live Crawl</p>
<h2 id="crawler-title">巡回中のページ</h2>
</div>
<span class="pulse">ONLINE</span>
</div>
<div class="crawl-grid" id="crawlGrid"></div>
</section>
<section class="stats-band" id="index" aria-label="インデックス統計">
<div>
<strong>12.8B</strong>
<span>Indexed pages</span>
</div>
<div>
<strong>94ms</strong>
<span>Median lookup</span>
</div>
<div>
<strong>37K/s</strong>
<span>Crawl rate</span>
</div>
<div>
<strong>99.98%</strong>
<span>Freshness</span>
</div>
</section>
</main>
<footer id="status">
<span>Japan</span>
<span>Privacy</span>
<span>Terms</span>
<span>Search Console</span>
</footer>
<script>
const results = [
{
title: "Crawlio Search Console - サイトのクロール状況",
url: "https://crawlio.example/search-console",
text: "サイトマップ、robots.txt、インデックス登録、検索パフォーマンスをまとめて確認できます。"
},
{
title: "高速インデックスの仕組み",
url: "https://crawlio.example/docs/indexing",
text: "分散クローラーがページを発見し、内容を解析して、新しい検索結果へ反映します。"
},
{
title: "ニュース、画像、動画を横断検索",
url: "https://crawlio.example/discover",
text: "キーワードに関連するページ、メディア、トレンドをひとつの検索画面で素早く探せます。"
},
{
title: "Web Crawler Health Report",
url: "https://status.crawlio.example/crawler",
text: "現在のクロール速度、エラー率、再訪問キュー、インデックス鮮度のライブ統計です。"
}
];
const crawlItems = [
["news.metro.jp/today", "HTML parsed / 32 links discovered", 78],
["shop.example.com/products", "Sitemap queued / canonical found", 64],
["docs.dev.local/api", "Robots allowed / snippets updated", 91],
["media.example.net/video", "Metadata extracted / thumbnail indexed", 56],
["blog.studio.jp/launch", "Fresh content detected / rank signals ready", 84],
["archive.city.jp/events", "Recrawl scheduled / duplicate checked", 43]
];
const form = document.querySelector("#searchForm");
const queryInput = document.querySelector("#query");
const resultsArea = document.querySelector(".results-area");
const resultMeta = document.querySelector("#resultMeta");
const resultList = document.querySelector("#results");
const crawlGrid = document.querySelector("#crawlGrid");
const crawlButton = document.querySelector("#crawlButton");
const voiceButton = document.querySelector("#voiceButton");
function renderResults(query = "クローラー") {
const filtered = results.map((item) => ({
...item,
title: query ? `${item.title} | ${query}` : item.title
}));
resultMeta.textContent = `約 ${(8420000 + query.length * 17321).toLocaleString("ja-JP")} 件中 ${(0.21 + Math.random() * 0.28).toFixed(2)} 秒`;
resultList.innerHTML = filtered
.map(
(item) => `
<article class="result">
<div class="result-url">${item.url}</div>
<h3>${item.title}</h3>
<p>${item.text}</p>
</article>
`
)
.join("");
resultsArea.classList.add("visible");
}
function renderCrawlGrid(offset = 0) {
crawlGrid.innerHTML = crawlItems
.map(([url, status, progress], index) => {
const shifted = Math.min(99, Math.max(24, progress + ((offset + index * 7) % 18) - 7));
return `
<article class="crawl-card">
<strong>${url}</strong>
<span>${status}</span>
<div class="crawl-progress" aria-label="クロール進捗 ${shifted}%">
<i style="--progress: ${shifted}%"></i>
</div>
</article>
`;
})
.join("");
}
form.addEventListener("submit", (event) => {
event.preventDefault();
renderResults(queryInput.value.trim() || "クローラー");
});
crawlButton.addEventListener("click", () => {
renderCrawlGrid(Math.floor(Math.random() * 20));
renderResults(queryInput.value.trim() || "live crawl");
document.querySelector("#crawler").scrollIntoView({ behavior: "smooth", block: "start" });
});
voiceButton.addEventListener("click", () => {
queryInput.value = "最新のインデックス状況";
queryInput.focus();
});
renderCrawlGrid();
</script>
</body>
</html>
カテゴリー: HTML
ネタ神AI Pro – アイデアメーカー
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ネタ神AI Pro - アイデアメーカー</title>
<style>
:root {
--bg: #070914;
--bg2: #111831;
--card: rgba(255, 255, 255, 0.08);
--card2: rgba(255, 255, 255, 0.13);
--text: #f5f7ff;
--muted: #aeb8df;
--line: rgba(255, 255, 255, 0.16);
--primary: #7c5cff;
--cyan: #00d4ff;
--green: #38ffad;
--yellow: #ffd35c;
--red: #ff5c7c;
--shadow: 0 22px 55px rgba(0, 0, 0, 0.35);
--radius: 22px;
}
body.light {
--bg: #eef2ff;
--bg2: #ffffff;
--card: rgba(255, 255, 255, 0.8);
--card2: rgba(255, 255, 255, 0.95);
--text: #151829;
--muted: #566179;
--line: rgba(20, 30, 60, 0.14);
--shadow: 0 18px 45px rgba(40, 60, 110, 0.16);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Segoe UI", "Hiragino Sans", "Yu Gothic", sans-serif;
color: var(--text);
background:
radial-gradient(circle at 20% 10%, rgba(124, 92, 255, 0.32), transparent 28%),
radial-gradient(circle at 90% 20%, rgba(0, 212, 255, 0.24), transparent 28%),
radial-gradient(circle at 50% 100%, rgba(255, 92, 124, 0.15), transparent 32%),
linear-gradient(135deg, var(--bg), var(--bg2));
transition: 0.25s;
}
button,
input,
select,
textarea {
font-family: inherit;
}
button {
border: 1px solid var(--line);
border-radius: 14px;
background: var(--card2);
color: var(--text);
padding: 11px 14px;
font-weight: 800;
cursor: pointer;
transition: 0.2s;
backdrop-filter: blur(12px);
}
button:hover {
transform: translateY(-2px);
filter: brightness(1.08);
}
.btn-main {
border: none;
background: linear-gradient(135deg, var(--primary), var(--cyan));
color: white;
box-shadow: 0 16px 35px rgba(0, 212, 255, 0.2);
}
.btn-green {
border: none;
background: linear-gradient(135deg, #13bf84, var(--green));
color: #06120d;
}
.btn-red {
border-color: rgba(255, 92, 124, 0.4);
background: rgba(255, 92, 124, 0.14);
}
.app {
width: min(1380px, 94%);
margin: 0 auto;
padding: 28px 0 70px;
}
header {
display: flex;
justify-content: space-between;
gap: 18px;
align-items: center;
margin-bottom: 22px;
}
.brand {
display: flex;
align-items: center;
gap: 14px;
}
.logo {
width: 58px;
height: 58px;
border-radius: 20px;
background: linear-gradient(135deg, var(--primary), var(--cyan));
display: grid;
place-items: center;
font-size: 30px;
box-shadow: 0 20px 45px rgba(124, 92, 255, 0.35);
}
h1, h2, h3, h4, p {
margin-top: 0;
}
.brand h1 {
margin: 0;
font-size: clamp(27px, 4vw, 46px);
letter-spacing: 0.03em;
}
.brand p {
margin: 4px 0 0;
color: var(--muted);
font-size: 14px;
}
.header-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.hero {
border: 1px solid var(--line);
background: var(--card);
border-radius: var(--radius);
box-shadow: var(--shadow);
backdrop-filter: blur(16px);
padding: 26px;
margin-bottom: 22px;
overflow: hidden;
position: relative;
}
.hero::after {
content: "";
position: absolute;
width: 320px;
height: 320px;
right: -120px;
bottom: -160px;
border-radius: 50%;
background: rgba(0, 212, 255, 0.13);
filter: blur(8px);
}
.hero h2 {
font-size: clamp(24px, 3vw, 40px);
margin-bottom: 10px;
line-height: 1.35;
}
.hero p {
color: var(--muted);
line-height: 1.8;
max-width: 900px;
margin-bottom: 0;
}
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin-bottom: 22px;
}
.stat {
border: 1px solid var(--line);
background: var(--card);
border-radius: 18px;
padding: 16px;
box-shadow: var(--shadow);
}
.stat strong {
display: block;
font-size: 24px;
margin-bottom: 3px;
}
.stat span {
color: var(--muted);
font-size: 13px;
}
.layout {
display: grid;
grid-template-columns: 420px 1fr;
gap: 22px;
align-items: start;
}
.panel {
border: 1px solid var(--line);
background: var(--card);
border-radius: var(--radius);
box-shadow: var(--shadow);
backdrop-filter: blur(16px);
overflow: hidden;
}
.panel-header {
padding: 18px 20px;
border-bottom: 1px solid var(--line);
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.panel-header h3 {
margin: 0;
font-size: 19px;
}
.panel-body {
padding: 20px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
background: linear-gradient(135deg, var(--green), var(--cyan));
color: #06121c;
font-size: 12px;
font-weight: 900;
white-space: nowrap;
}
.form-group {
margin-bottom: 16px;
}
label {
display: block;
color: var(--muted);
font-size: 13px;
font-weight: 800;
margin-bottom: 8px;
}
input,
select,
textarea {
width: 100%;
border: 1px solid var(--line);
background: rgba(0, 0, 0, 0.22);
color: var(--text);
border-radius: 14px;
padding: 12px 13px;
outline: none;
font-size: 15px;
transition: 0.2s;
}
body.light input,
body.light select,
body.light textarea {
background: rgba(255, 255, 255, 0.85);
}
select option {
background: #10162a;
color: white;
}
input:focus,
select:focus,
textarea:focus {
border-color: rgba(0, 212, 255, 0.85);
box-shadow: 0 0 0 4px rgba(0, 212, 255, 0.12);
}
textarea {
min-height: 105px;
resize: vertical;
line-height: 1.7;
}
.two {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.chip {
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.08);
color: var(--muted);
border-radius: 999px;
padding: 8px 10px;
font-size: 12px;
font-weight: 800;
cursor: pointer;
transition: 0.2s;
}
.chip:hover {
color: var(--text);
border-color: rgba(0, 212, 255, 0.7);
transform: translateY(-1px);
}
.button-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 16px;
}
.button-grid .wide-btn {
grid-column: 1 / -1;
}
.result-tools {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.empty {
text-align: center;
padding: 70px 24px;
color: var(--muted);
}
.empty .icon {
font-size: 64px;
margin-bottom: 12px;
}
.idea-list {
display: grid;
gap: 16px;
}
.idea-card {
border: 1px solid var(--line);
background: rgba(0, 0, 0, 0.18);
border-radius: 20px;
overflow: hidden;
}
body.light .idea-card {
background: rgba(255, 255, 255, 0.78);
}
.idea-top {
padding: 20px;
border-bottom: 1px solid var(--line);
display: grid;
grid-template-columns: 1fr auto;
gap: 15px;
align-items: start;
}
.idea-title {
margin: 0;
font-size: clamp(24px, 3vw, 36px);
line-height: 1.25;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.pill {
font-size: 12px;
font-weight: 900;
padding: 6px 9px;
border-radius: 999px;
border: 1px solid var(--line);
color: var(--muted);
background: rgba(255,255,255,0.07);
}
.score-box {
width: 96px;
text-align: center;
padding: 12px;
border-radius: 18px;
background: linear-gradient(135deg, rgba(124, 92, 255, 0.35), rgba(0, 212, 255, 0.24));
border: 1px solid var(--line);
}
.score-box strong {
display: block;
font-size: 26px;
}
.score-box span {
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.catch {
padding: 16px 20px;
font-size: 17px;
line-height: 1.7;
background: rgba(255, 255, 255, 0.07);
border-bottom: 1px solid var(--line);
}
.idea-body {
padding: 20px;
}
.sections {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.section {
border: 1px solid var(--line);
border-radius: 17px;
padding: 16px;
background: rgba(0, 0, 0, 0.18);
}
body.light .section {
background: rgba(255, 255, 255, 0.62);
}
.section.wide {
grid-column: 1 / -1;
}
.section h4 {
margin: 0 0 10px;
font-size: 15px;
}
.section p,
.section li {
color: var(--muted);
line-height: 1.75;
font-size: 14px;
}
.section p {
margin-bottom: 0;
}
.section ul,
.section ol {
margin: 0;
padding-left: 22px;
}
.idea-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0 20px 20px;
}
.history-controls {
display: grid;
grid-template-columns: 1fr 180px;
gap: 10px;
margin-bottom: 14px;
}
.history-list {
display: grid;
gap: 10px;
}
.history-item {
border: 1px solid var(--line);
border-radius: 16px;
padding: 13px;
cursor: pointer;
background: rgba(0, 0, 0, 0.15);
transition: 0.2s;
}
body.light .history-item {
background: rgba(255, 255, 255, 0.7);
}
.history-item:hover {
transform: translateY(-2px);
border-color: rgba(0, 212, 255, 0.6);
}
.history-item strong {
display: block;
margin-bottom: 5px;
}
.history-item small {
color: var(--muted);
}
.toast {
position: fixed;
right: 20px;
bottom: 20px;
background: rgba(10, 15, 30, 0.94);
color: white;
border: 1px solid rgba(255,255,255,0.16);
border-radius: 16px;
padding: 14px 18px;
box-shadow: var(--shadow);
opacity: 0;
transform: translateY(20px);
pointer-events: none;
transition: 0.25s;
z-index: 100;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
.footer {
margin-top: 28px;
text-align: center;
color: var(--muted);
font-size: 13px;
}
@media (max-width: 1050px) {
header {
flex-direction: column;
align-items: flex-start;
}
.layout {
grid-template-columns: 1fr;
}
.stats {
grid-template-columns: repeat(2, 1fr);
}
.sections {
grid-template-columns: 1fr;
}
.idea-top {
grid-template-columns: 1fr;
}
.score-box {
width: 100%;
}
}
@media (max-width: 620px) {
.two,
.button-grid,
.history-controls,
.stats {
grid-template-columns: 1fr;
}
.header-actions {
width: 100%;
}
.header-actions button {
flex: 1;
}
}
</style>
</head>
<body>
<div class="app">
<header>
<div class="brand">
<div class="logo">💡</div>
<div>
<h1>ネタ神AI Pro</h1>
<p>API不要。ブラウザだけで動く創作・Webサービス企画メーカー</p>
</div>
</div>
<div class="header-actions">
<button onclick="toggleTheme()">テーマ切替</button>
<button onclick="downloadText()">TXT出力</button>
<button onclick="downloadJSON()">JSON出力</button>
<button class="btn-red" onclick="clearAll()">全削除</button>
</div>
</header>
<section class="hero">
<h2>APIなしでも、かなり使える「企画書生成ツール」にする。</h2>
<p>
外部AIに接続せず、ローカルのテンプレート・ランダム生成・条件分岐だけで、Webサービス、AIツール、ゲーム、小説、SNSなどの企画案を作ります。
API料金もキー管理も不要です。まず作品として公開しやすい形です。
</p>
</section>
<section class="stats">
<div class="stat">
<strong id="statIdeas">0</strong>
<span>今回生成した案</span>
</div>
<div class="stat">
<strong id="statSaved">0</strong>
<span>保存済みアイデア</span>
</div>
<div class="stat">
<strong>0円</strong>
<span>API利用料</span>
</div>
<div class="stat">
<strong>100%</strong>
<span>ローカル動作</span>
</div>
</section>
<main class="layout">
<section class="panel">
<div class="panel-header">
<h3>生成条件</h3>
<span class="badge">NO API</span>
</div>
<div class="panel-body">
<div class="two">
<div class="form-group">
<label for="genre">ジャンル</label>
<select id="genre">
<option>Webサービス</option>
<option>AIツール</option>
<option>ゲーム</option>
<option>小説</option>
<option>SNS</option>
<option>動画サイト</option>
<option>ポートフォリオ</option>
<option>便利ツール</option>
<option>学習サービス</option>
<option>創作支援</option>
</select>
</div>
<div class="form-group">
<label for="mood">雰囲気</label>
<select id="mood">
<option>かっこいい</option>
<option>やさしい</option>
<option>近未来</option>
<option>ファンタジー</option>
<option>シンプル</option>
<option>高級感</option>
<option>かわいい</option>
<option>ダーク</option>
<option>実用的</option>
<option>ゲーム風</option>
</select>
</div>
</div>
<div class="two">
<div class="form-group">
<label for="level">開発難易度</label>
<select id="level">
<option>簡単</option>
<option>普通</option>
<option>本格</option>
<option>超本格</option>
</select>
</div>
<div class="form-group">
<label for="target">ターゲット</label>
<select id="target">
<option>個人クリエイター</option>
<option>学生</option>
<option>社会人</option>
<option>在宅ワーカー</option>
<option>ゲーム制作者</option>
<option>小説家志望</option>
<option>配信者</option>
<option>初心者</option>
<option>副業したい人</option>
</select>
</div>
</div>
<div class="two">
<div class="form-group">
<label for="amount">生成数</label>
<select id="amount">
<option value="1">1個</option>
<option value="3" selected>3個</option>
<option value="5">5個</option>
</select>
</div>
<div class="form-group">
<label for="style">出力スタイル</label>
<select id="style">
<option>企画書風</option>
<option>サービス紹介風</option>
<option>開発メモ風</option>
<option>ピッチ資料風</option>
</select>
</div>
</div>
<div class="form-group">
<label for="keywords">キーワード</label>
<textarea id="keywords" placeholder="例:AI / RPG / SNS / メモ / 仕事 / 創作 / ポートフォリオ"></textarea>
<div class="chips">
<span class="chip" onclick="addKeyword('AI')">AI</span>
<span class="chip" onclick="addKeyword('RPG')">RPG</span>
<span class="chip" onclick="addKeyword('SNS')">SNS</span>
<span class="chip" onclick="addKeyword('小説')">小説</span>
<span class="chip" onclick="addKeyword('仕事')">仕事</span>
<span class="chip" onclick="addKeyword('メモ')">メモ</span>
<span class="chip" onclick="addKeyword('ポートフォリオ')">ポートフォリオ</span>
<span class="chip" onclick="addKeyword('動画')">動画</span>
<span class="chip" onclick="addKeyword('学習')">学習</span>
<span class="chip" onclick="addKeyword('ゲーム開発')">ゲーム開発</span>
</div>
</div>
<div class="form-group">
<label for="problem">解決したい悩み</label>
<textarea id="problem" placeholder="例:何を作ればいいかわからない。作業が続かない。アイデアを整理できない。"></textarea>
</div>
<div class="button-grid">
<button class="btn-main wide-btn" onclick="generateIdeas()">アイデア生成</button>
<button onclick="randomSet()">ランダム条件</button>
<button onclick="makePractical()">現実的にする</button>
<button onclick="makeFantasy()">派手にする</button>
<button onclick="clearForm()">入力クリア</button>
</div>
</div>
</section>
<section class="panel">
<div class="panel-header">
<h3>生成結果</h3>
<div class="result-tools">
<button onclick="copyAll()">コピー</button>
<button class="btn-green" onclick="saveAll()">全部保存</button>
</div>
</div>
<div class="panel-body">
<div id="result">
<div class="empty">
<div class="icon">🧠</div>
<h2>まだアイデアはありません</h2>
<p>左の条件を入れて「アイデア生成」を押してください。</p>
</div>
</div>
</div>
</section>
</main>
<section class="panel" style="margin-top:22px;">
<div class="panel-header">
<h3>保存したアイデア</h3>
<span class="badge" id="savedCount">0件</span>
</div>
<div class="panel-body">
<div class="history-controls">
<input id="historySearch" placeholder="保存アイデアを検索" oninput="renderHistory()" />
<select id="historyGenre" onchange="renderHistory()">
<option value="all">全ジャンル</option>
<option>Webサービス</option>
<option>AIツール</option>
<option>ゲーム</option>
<option>小説</option>
<option>SNS</option>
<option>動画サイト</option>
<option>ポートフォリオ</option>
<option>便利ツール</option>
<option>学習サービス</option>
<option>創作支援</option>
</select>
</div>
<div class="history-list" id="historyList"></div>
</div>
</section>
<div class="footer">
ネタ神AI Pro / APIなしローカル版 / HTML・CSS・JavaScriptのみ
</div>
</div>
<div class="toast" id="toast">完了しました</div>
<script>
const DATA = {
titleHeads: [
"Nova", "Idea", "Neta", "Mira", "Chrono", "Elder", "Prompt", "Vision",
"Craft", "Yume", "Neo", "Astra", "Luna", "Meta", "Spark", "Quest"
],
titleTails: {
"Webサービス": ["Hub", "Works", "Base", "Cloud", "Studio", "Panel", "Link", "Board"],
"AIツール": ["AI", "Brain", "Agent", "Prompt", "Copilot", "Mind", "Assist", "Genius"],
"ゲーム": ["Quest", "Chronicle", "Saga", "Blade", "Dungeon", "Legend", "Arc", "World"],
"小説": ["Novel", "Story", "Tale", "Script", "Lore", "Ink", "Scene", "Dream"],
"SNS": ["Verse", "Circle", "Post", "Talk", "Room", "Link", "Wave", "Nest"],
"動画サイト": ["Tube", "Stream", "Clip", "Vision", "Cast", "Channel", "View", "Media"],
"ポートフォリオ": ["Portfolio", "Gallery", "Works", "Profile", "Card", "Showcase", "Archive", "Page"],
"便利ツール": ["Tool", "Memo", "Desk", "Kit", "Task", "Quick", "Utility", "Simple"],
"学習サービス": ["Learn", "Study", "Lesson", "Skill", "Academy", "Trainer", "Coach", "Note"],
"創作支援": ["Create", "Maker", "Muse", "Seed", "Craft", "Atelier", "Generator", "Factory"]
},
moodDesc: {
"かっこいい": "鋭く洗練された印象で、使うだけで制作意欲が上がる",
"やさしい": "初心者でも迷わない、安心感のある",
"近未来": "AI時代らしい自動化と先進性を感じる",
"ファンタジー": "クエストやギルドのような世界観を活かした",
"シンプル": "余計な機能を削り、すぐ使えることに集中した",
"高級感": "プロ向けツールのように落ち着いた印象の",
"かわいい": "親しみやすく、毎日開きたくなる",
"ダーク": "深い世界観と中二感を活かした",
"実用的": "仕事や制作の効率化に直結する",
"ゲーム風": "レベル、経験値、クエストのような要素を持つ"
},
features: {
"Webサービス": ["ユーザー投稿", "検索", "タグ分類", "お気に入り", "ランキング", "管理画面", "コメント", "カテゴリ管理", "共有リンク", "レスポンシブUI"],
"AIツール": ["文章生成", "テンプレート選択", "プロンプト保存", "履歴管理", "自動分類", "要約", "言い換え", "コピー", "お気に入り", "出力形式変更"],
"ゲーム": ["キャラクター管理", "クエスト", "ステージ選択", "スキル", "装備", "敵図鑑", "ストーリー分岐", "進行度保存", "称号", "実績"],
"小説": ["キャラ設定", "世界観管理", "章立て", "プロット", "セリフ案", "伏線メモ", "用語集", "文体変換", "シーン整理", "年表"],
"SNS": ["タイムライン", "投稿", "いいね", "フォロー", "通知", "プロフィール", "ハッシュタグ", "DM風UI", "おすすめ投稿", "AI投稿提案"],
"動画サイト": ["動画カード", "検索", "カテゴリ", "ランキング", "チャンネル", "視聴履歴", "コメント", "お気に入り", "おすすめ", "タグ"],
"ポートフォリオ": ["作品カード", "リンク管理", "カテゴリ分類", "紹介文生成", "スキル表示", "実績一覧", "検索", "テーマ変更", "外部リンク", "更新履歴"],
"便利ツール": ["メモ", "ToDo", "検索", "タグ", "自動整形", "コピー", "履歴", "エクスポート", "チェックリスト", "通知風表示"],
"学習サービス": ["学習記録", "復習リスト", "クイズ", "用語集", "進捗", "AI風解説", "弱点メモ", "計画作成", "達成バッジ", "問題生成"],
"創作支援": ["アイデア生成", "タイトル案", "キャッチコピー", "キャラ案", "世界観案", "企画書化", "画像プロンプト", "構成案", "メモ保存", "ネタ帳"]
},
monetization: [
"無料版+Pro版",
"広告表示",
"買い切り版",
"テンプレート販売",
"月額プレミアム",
"法人向けプラン",
"追加保存枠の課金",
"作品公開ページの有料カスタム",
"支援・投げ銭",
"素材パック販売"
],
risks: [
"機能を増やしすぎると完成しにくくなる",
"最初からログインや課金を入れると開発が重くなる",
"ターゲットが広すぎると特徴が薄くなる",
"保存機能の設計を後回しにすると作り直しが出やすい",
"見た目だけ作って実用性が弱いと使われにくい",
"スマホ対応を忘れると使い勝手が落ちる"
],
firstSteps: {
"簡単": ["1画面UIを作る", "入力欄と生成ボタンを作る", "結果表示を作る", "コピー機能を付ける", "ローカル保存を付ける"],
"普通": ["基本UIを作る", "複数パターン生成を作る", "履歴保存を作る", "検索と絞り込みを作る", "テキスト出力を作る"],
"本格": ["MVPを作る", "保存データ構造を決める", "ログインなし版を完成させる", "ユーザー登録版を検討する", "公開ページを整える"],
"超本格": ["小さいMVPを先に作る", "フロントとバックエンドを分ける", "DB設計をする", "課金やログインを後から追加する", "運用コストを確認する"]
}
};
let currentIdeas = [];
let generatedCount = 0;
function $(id) {
return document.getElementById(id);
}
function val(id) {
return $(id).value.trim();
}
function pick(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
function shuffle(arr) {
return [...arr].sort(() => Math.random() - 0.5);
}
function escapeHTML(str) {
return String(str)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function addKeyword(word) {
const box = $("keywords");
if (!box.value.includes(word)) {
box.value = box.value ? box.value + " / " + word : word;
}
}
function randomSet() {
randomSelect("genre");
randomSelect("mood");
randomSelect("level");
randomSelect("target");
randomSelect("style");
const sets = [
"AI / メモ / 作業効率",
"RPG / クエスト / 進捗管理",
"SNS / 投稿 / AI風返信",
"小説 / 世界観 / キャラクター",
"在宅ワーク / 日報 / 整理",
"ポートフォリオ / 作品 / 自動紹介",
"動画 / まとめ / ランキング",
"学習 / 復習 / クイズ",
"ゲーム開発 / アイデア / 仕様書",
"創作 / ネタ帳 / 企画書"
];
const problems = [
"何を作ればいいかわからない",
"作業が続かない",
"アイデアが散らばって整理できない",
"作品紹介文を書くのが難しい",
"学習した内容を忘れやすい",
"毎日の進捗を見える化したい",
"企画を作っても途中で止まりやすい"
];
$("keywords").value = pick(sets);
$("problem").value = pick(problems);
showToast("ランダム条件を入れました");
}
function randomSelect(id) {
const el = $(id);
el.selectedIndex = Math.floor(Math.random() * el.options.length);
}
function makePractical() {
$("mood").value = "実用的";
$("level").value = "簡単";
$("style").value = "開発メモ風";
if (!$("problem").value) {
$("problem").value = "毎日の作業やアイデアを整理して、次にやることを明確にしたい";
}
showToast("現実的な条件に寄せました");
}
function makeFantasy() {
$("mood").value = "ファンタジー";
$("style").value = "サービス紹介風";
if (!$("keywords").value.includes("ギルド")) {
addKeyword("ギルド");
addKeyword("クエスト");
}
showToast("派手な条件に寄せました");
}
function clearForm() {
$("keywords").value = "";
$("problem").value = "";
showToast("入力をクリアしました");
}
function generateIdeas() {
const amount = Number(val("amount"));
currentIdeas = [];
for (let i = 0; i < amount; i++) {
currentIdeas.push(createIdea(i));
}
generatedCount += amount;
$("statIdeas").textContent = generatedCount;
renderIdeas();
showToast(`${amount}個のアイデアを生成しました`);
}
function createIdea(index) {
const genre = val("genre");
const mood = val("mood");
const level = val("level");
const target = val("target");
const style = val("style");
const keywords = val("keywords") || "AI / 創作 / アイデア";
const problem = val("problem") || "アイデアを整理して、作り始めやすくしたい";
const title = makeTitle(genre, keywords, index);
const features = shuffle(DATA.features[genre]).slice(0, 6);
const mvp = features.slice(0, 3);
const money = shuffle(DATA.monetization).slice(0, 3);
const risks = shuffle(DATA.risks).slice(0, 3);
const steps = DATA.firstSteps[level];
const score = calcScore(genre, level);
const keywordMain = splitKeywords(keywords)[0] || "アイデア";
return {
id: Date.now() + Math.random(),
createdAt: new Date().toLocaleString("ja-JP"),
title,
genre,
mood,
level,
target,
style,
keywords,
problem,
score,
catchcopy: makeCatch(target, mood, genre, keywordMain),
overview: makeOverview(genre, mood, target, keywords, problem, style),
unique: makeUnique(genre, mood, keywordMain),
features,
mvp,
money,
risks,
steps,
devTime: makeDevTime(level),
nextAction: makeNextAction(level, genre),
design: makeDesign(mood),
pitch: makePitch(title, target, genre, problem)
};
}
function splitKeywords(text) {
return text.split(/[\/、,\s]+/).map(x => x.trim()).filter(Boolean);
}
function makeTitle(genre, keywords, index) {
const keys = splitKeywords(keywords);
const key = keys[index % Math.max(keys.length, 1)] || "Idea";
const head = pick(DATA.titleHeads);
const tail = pick(DATA.titleTails[genre] || DATA.titleTails["創作支援"]);
const patterns = [
`${head}${tail}`,
`${key}${tail}`,
`${head} ${tail}`,
`${key}メーカー`,
`${key}ギルド`,
`${key}Forge`,
`${head}ノート`,
`${key}ラボ`,
`${head}Factory`,
`${key}クエスト`
];
return pick(patterns);
}
function makeCatch(target, mood, genre, key) {
const desc = DATA.moodDesc[mood];
const patterns = [
`${target}の「作りたい」を形にする、${desc}${genre}。`,
`${key}を起点に、企画・整理・実行まで支える${genre}。`,
`思いつきを企画に変える、${target}向けの${desc}サービス。`,
`迷っている時間を減らし、制作を前に進める${genre}。`
];
return pick(patterns);
}
function makeOverview(genre, mood, target, keywords, problem, style) {
const desc = DATA.moodDesc[mood];
if (style === "ピッチ資料風") {
return `${problem}という悩みを持つ${target}に向けて、${keywords}を軸にした${genre}を提供します。${desc}体験により、ユーザーはアイデア出しから整理、実行までを短時間で進められます。`;
}
if (style === "開発メモ風") {
return `${keywords}をテーマにした${genre}。まずは小さく作る。${target}が抱える「${problem}」を解決するため、生成、保存、検索、コピーの流れを重視する。`;
}
if (style === "サービス紹介風") {
return `この${genre}は、${target}が${keywords}に関するアイデアをすばやく整理できるサービスです。${desc}デザインで、毎日開きたくなる使い心地を目指します。`;
}
return `${keywords}をテーマにした${genre}です。${target}が抱える「${problem}」を解決するため、アイデア出し、情報整理、保存、次の行動提案をまとめて行える企画にします。`;
}
function makeUnique(genre, mood, key) {
return `${key}をただ生成するだけでなく、MVP、開発手順、収益化、注意点まで同時に出せる点が特徴です。${mood}な方向性を明確にすることで、似たような${genre}との差別化もしやすくなります。`;
}
function calcScore(genre, level) {
let score = 82;
if (level === "簡単") score += 10;
if (level === "普通") score += 5;
if (level === "本格") score -= 2;
if (level === "超本格") score -= 9;
if (genre === "便利ツール") score += 4;
if (genre === "AIツール") score += 3;
if (genre === "ゲーム") score -= 4;
if (genre === "SNS") score -= 3;
score += Math.floor(Math.random() * 9) - 4;
return Math.max(55, Math.min(98, score));
}
function makeDevTime(level) {
if (level === "簡単") return "1日〜3日";
if (level === "普通") return "1週間〜2週間";
if (level === "本格") return "1か月〜3か月";
return "3か月以上";
}
function makeNextAction(level, genre) {
if (level === "簡単") {
return `まずは${genre}の1画面版を作ります。入力欄、生成ボタン、結果表示、保存だけで完成扱いにするのが安全です。`;
}
if (level === "普通") {
return `最初にUIを作り、そのあと保存・検索・出力機能を追加します。ログイン機能は後回しで大丈夫です。`;
}
if (level === "本格") {
return `MVPを公開できる状態まで作ってから、ユーザー登録やデータベースを検討します。最初から全部入れない方が完成します。`;
}
return `超本格版は重いので、まずはプロトタイプを完成させてください。完成後にサーバー、DB、課金、ログインを分割して追加する流れが安全です。`;
}
function makeDesign(mood) {
const map = {
"かっこいい": "黒背景、青紫グラデーション、カード型UI、シャープなボタン",
"やさしい": "白背景、淡い青や緑、角丸カード、大きめ文字",
"近未来": "ダーク背景、ネオン、水色アクセント、ガラス風UI",
"ファンタジー": "羊皮紙風、ギルドカード、クエストボード風UI",
"シンプル": "白背景、余白多め、入力欄と結果表示を中心にする",
"高級感": "黒と金、細い罫線、落ち着いたカードUI",
"かわいい": "パステルカラー、丸いボタン、アイコン多め",
"ダーク": "黒、赤紫、重厚な影、世界観重視",
"実用的": "管理画面風、見出し明確、コピー・保存ボタンを目立たせる",
"ゲーム風": "ステータス画面、経験値バー、クエストカード風"
};
return map[mood] || "カード型で見やすいUI";
}
function makePitch(title, target, genre, problem) {
return `${title}は、${target}が抱える「${problem}」を解決する${genre}です。複雑な作業を整理し、次にやることを明確にすることで、制作や仕事を止めずに進められるようにします。`;
}
function renderIdeas() {
const result = $("result");
if (currentIdeas.length === 0) {
result.innerHTML = `
<div class="empty">
<div class="icon">🧠</div>
<h2>まだアイデアはありません</h2>
<p>左の条件を入れて「アイデア生成」を押してください。</p>
</div>
`;
return;
}
result.innerHTML = `
<div class="idea-list">
${currentIdeas.map(renderIdeaCard).join("")}
</div>
`;
}
function renderIdeaCard(idea, index) {
return `
<article class="idea-card">
<div class="idea-top">
<div>
<h2 class="idea-title">${escapeHTML(idea.title)}</h2>
<div class="meta">
<span class="pill">${escapeHTML(idea.genre)}</span>
<span class="pill">${escapeHTML(idea.mood)}</span>
<span class="pill">${escapeHTML(idea.level)}</span>
<span class="pill">${escapeHTML(idea.target)}</span>
<span class="pill">開発目安 ${escapeHTML(idea.devTime)}</span>
</div>
</div>
<div class="score-box">
<strong>${idea.score}</strong>
<span>実現度</span>
</div>
</div>
<div class="catch">
${escapeHTML(idea.catchcopy)}
</div>
<div class="idea-body">
<div class="sections">
<div class="section wide">
<h4>📝 概要</h4>
<p>${escapeHTML(idea.overview)}</p>
</div>
<div class="section">
<h4>🎯 解決する悩み</h4>
<p>${escapeHTML(idea.problem)}</p>
</div>
<div class="section">
<h4>🎨 デザイン方針</h4>
<p>${escapeHTML(idea.design)}</p>
</div>
<div class="section">
<h4>⚙️ 主な機能</h4>
<ul>
${idea.features.map(x => `<li>${escapeHTML(x)}</li>`).join("")}
</ul>
</div>
<div class="section">
<h4>🚀 MVP機能</h4>
<ol>
${idea.mvp.map(x => `<li>${escapeHTML(x)}</li>`).join("")}
</ol>
</div>
<div class="section">
<h4>💰 収益化案</h4>
<ul>
${idea.money.map(x => `<li>${escapeHTML(x)}</li>`).join("")}
</ul>
</div>
<div class="section">
<h4>⚠️ リスク</h4>
<ul>
${idea.risks.map(x => `<li>${escapeHTML(x)}</li>`).join("")}
</ul>
</div>
<div class="section wide">
<h4>✅ 開発ステップ</h4>
<ol>
${idea.steps.map(x => `<li>${escapeHTML(x)}</li>`).join("")}
</ol>
</div>
<div class="section wide">
<h4>✨ 差別化ポイント</h4>
<p>${escapeHTML(idea.unique)}</p>
</div>
<div class="section wide">
<h4>📣 紹介文</h4>
<p>${escapeHTML(idea.pitch)}</p>
</div>
<div class="section wide">
<h4>👉 次にやること</h4>
<p>${escapeHTML(idea.nextAction)}</p>
</div>
</div>
</div>
<div class="idea-actions">
<button onclick="copyOne(${index})">この案をコピー</button>
<button onclick="saveOne(${index})">保存</button>
<button onclick="regenerateOne(${index})">この案だけ再生成</button>
</div>
</article>
`;
}
function ideaToText(idea) {
return `
【タイトル】
${idea.title}
【ジャンル】
${idea.genre}
【雰囲気】
${idea.mood}
【ターゲット】
${idea.target}
【開発難易度】
${idea.level}
【開発目安】
${idea.devTime}
【実現度】
${idea.score}点
【キャッチコピー】
${idea.catchcopy}
【解決する悩み】
${idea.problem}
【概要】
${idea.overview}
【主な機能】
${idea.features.map((x, i) => `${i + 1}. ${x}`).join("\n")}
【MVP機能】
${idea.mvp.map((x, i) => `${i + 1}. ${x}`).join("\n")}
【収益化案】
${idea.money.map((x, i) => `${i + 1}. ${x}`).join("\n")}
【リスク】
${idea.risks.map((x, i) => `${i + 1}. ${x}`).join("\n")}
【開発ステップ】
${idea.steps.map((x, i) => `${i + 1}. ${x}`).join("\n")}
【デザイン方針】
${idea.design}
【差別化ポイント】
${idea.unique}
【紹介文】
${idea.pitch}
【次にやること】
${idea.nextAction}
`.trim();
}
function copyOne(index) {
const idea = currentIdeas[index];
if (!idea) return;
copyText(ideaToText(idea));
}
function copyAll() {
if (currentIdeas.length === 0) {
showToast("先に生成してください");
return;
}
copyText(currentIdeas.map(ideaToText).join("\n\n====================\n\n"));
}
function copyText(text) {
navigator.clipboard.writeText(text)
.then(() => showToast("コピーしました"))
.catch(() => showToast("コピーに失敗しました"));
}
function regenerateOne(index) {
currentIdeas[index] = createIdea(index);
renderIdeas();
showToast("再生成しました");
}
function getSaved() {
try {
return JSON.parse(localStorage.getItem("netagami_saved")) || [];
} catch {
return [];
}
}
function setSaved(data) {
localStorage.setItem("netagami_saved", JSON.stringify(data));
updateStats();
}
function saveOne(index) {
const idea = currentIdeas[index];
if (!idea) return;
const saved = getSaved();
saved.unshift(idea);
setSaved(saved.slice(0, 100));
renderHistory();
showToast("保存しました");
}
function saveAll() {
if (currentIdeas.length === 0) {
showToast("先に生成してください");
return;
}
const saved = getSaved();
setSaved([...currentIdeas, ...saved].slice(0, 100));
renderHistory();
showToast("全部保存しました");
}
function renderHistory() {
const list = $("historyList");
const saved = getSaved();
const q = $("historySearch").value.trim().toLowerCase();
const genre = $("historyGenre").value;
let filtered = saved;
if (genre !== "all") {
filtered = filtered.filter(x => x.genre === genre);
}
if (q) {
filtered = filtered.filter(x => {
return JSON.stringify(x).toLowerCase().includes(q);
});
}
$("savedCount").textContent = `${saved.length}件`;
if (filtered.length === 0) {
list.innerHTML = `<p style="color:var(--muted);">保存アイデアはありません。</p>`;
return;
}
list.innerHTML = filtered.map(item => `
<div class="history-item" onclick="loadSaved('${item.id}')">
<strong>${escapeHTML(item.title)}</strong>
<small>${escapeHTML(item.genre)} / ${escapeHTML(item.level)} / ${escapeHTML(item.createdAt)}</small>
</div>
`).join("");
}
function loadSaved(id) {
const saved = getSaved();
const idea = saved.find(x => String(x.id) === String(id));
if (!idea) return;
currentIdeas = [idea];
renderIdeas();
window.scrollTo({ top: 0, behavior: "smooth" });
showToast("保存アイデアを表示しました");
}
function downloadText() {
if (currentIdeas.length === 0) {
showToast("先に生成してください");
return;
}
const text = currentIdeas.map(ideaToText).join("\n\n====================\n\n");
downloadFile("netagami-ideas.txt", text, "text/plain");
showToast("TXT出力しました");
}
function downloadJSON() {
const data = currentIdeas.length ? currentIdeas : getSaved();
if (data.length === 0) {
showToast("出力するデータがありません");
return;
}
downloadFile("netagami-ideas.json", JSON.stringify(data, null, 2), "application/json");
showToast("JSON出力しました");
}
function downloadFile(filename, content, type) {
const blob = new Blob([content], { type: type + ";charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function clearAll() {
if (!confirm("生成結果と保存履歴を削除しますか?")) return;
currentIdeas = [];
localStorage.removeItem("netagami_saved");
renderIdeas();
renderHistory();
updateStats();
showToast("削除しました");
}
function toggleTheme() {
document.body.classList.toggle("light");
localStorage.setItem("netagami_theme", document.body.classList.contains("light") ? "light" : "dark");
}
function updateStats() {
$("statSaved").textContent = getSaved().length;
}
function showToast(message) {
const toast = $("toast");
toast.textContent = message;
toast.classList.add("show");
setTimeout(() => {
toast.classList.remove("show");
}, 1800);
}
function init() {
if (localStorage.getItem("netagami_theme") === "light") {
document.body.classList.add("light");
}
$("keywords").value = "AI / 創作 / Webサービス / メモ";
$("problem").value = "何を作ればいいかわからない。アイデアを企画書レベルまで整理したい。";
renderHistory();
updateStats();
}
init();
</script>
</body>
</html>
AICharacter掲示板
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>AIキャラ会話掲示板 - Virtual Guild Board</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Segoe UI", "Hiragino Kaku Gothic ProN", "Meiryo", sans-serif;
background: linear-gradient(135deg, #0f172a, #1e293b, #111827);
color: #e5e7eb;
}
header {
padding: 24px;
background: rgba(0,0,0,0.35);
border-bottom: 1px solid rgba(255,255,255,0.08);
text-align: center;
}
header h1 {
margin: 0;
font-size: 32px;
color: #f8fafc;
}
header p {
margin-top: 8px;
color: #cbd5e1;
font-size: 14px;
}
.container {
max-width: 1300px;
margin: 0 auto;
padding: 20px;
display: grid;
grid-template-columns: 300px 1fr 320px;
gap: 20px;
}
.panel {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 18px;
box-shadow: 0 10px 30px rgba(0,0,0,0.25);
overflow: hidden;
backdrop-filter: blur(10px);
}
.panel-title {
padding: 16px 18px;
font-size: 18px;
font-weight: bold;
border-bottom: 1px solid rgba(255,255,255,0.08);
background: rgba(255,255,255,0.04);
}
.character-list {
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
max-height: 720px;
overflow-y: auto;
}
.character-card {
padding: 14px;
border-radius: 14px;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.06);
}
.character-card h3 {
margin: 0 0 6px 0;
font-size: 17px;
}
.character-meta {
font-size: 13px;
color: #cbd5e1;
margin-bottom: 8px;
}
.character-desc {
font-size: 13px;
color: #e2e8f0;
line-height: 1.6;
}
.main-board {
display: flex;
flex-direction: column;
min-height: 780px;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 14px;
border-bottom: 1px solid rgba(255,255,255,0.08);
background: rgba(255,255,255,0.03);
}
button, select, input, textarea {
font: inherit;
}
button {
border: none;
border-radius: 10px;
padding: 10px 14px;
cursor: pointer;
background: linear-gradient(135deg, #3b82f6, #2563eb);
color: white;
transition: 0.2s ease;
}
button:hover {
transform: translateY(-1px);
filter: brightness(1.08);
}
.danger {
background: linear-gradient(135deg, #ef4444, #dc2626);
}
.sub {
background: linear-gradient(135deg, #64748b, #475569);
}
.chat-area {
flex: 1;
padding: 18px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 14px;
min-height: 500px;
max-height: 580px;
}
.post {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 14px;
border-radius: 16px;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.06);
animation: fadeIn 0.25s ease;
}
.avatar {
width: 48px;
height: 48px;
min-width: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
background: rgba(255,255,255,0.12);
border: 1px solid rgba(255,255,255,0.12);
}
.post-content {
flex: 1;
}
.post-header {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 6px;
}
.name {
font-weight: bold;
font-size: 15px;
color: #ffffff;
}
.role {
font-size: 12px;
color: #93c5fd;
background: rgba(59,130,246,0.15);
padding: 3px 8px;
border-radius: 999px;
}
.time {
margin-left: auto;
font-size: 12px;
color: #94a3b8;
}
.message {
font-size: 15px;
line-height: 1.75;
color: #f1f5f9;
white-space: pre-wrap;
word-break: break-word;
}
.composer {
padding: 16px;
border-top: 1px solid rgba(255,255,255,0.08);
background: rgba(255,255,255,0.03);
display: flex;
flex-direction: column;
gap: 10px;
}
.composer-top {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
select, input, textarea {
width: 100%;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.1);
background: rgba(15,23,42,0.85);
color: #fff;
padding: 10px 12px;
outline: none;
}
textarea {
resize: vertical;
min-height: 100px;
}
.composer-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.right-panel-content {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.status-box, .topic-box, .memory-box {
padding: 14px;
border-radius: 14px;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.06);
}
.status-line {
margin: 8px 0;
font-size: 14px;
color: #e2e8f0;
}
.topic-tag {
display: inline-block;
margin: 6px 6px 0 0;
padding: 6px 10px;
border-radius: 999px;
background: rgba(16,185,129,0.18);
color: #bbf7d0;
font-size: 12px;
}
.memory-item {
font-size: 13px;
padding: 8px 10px;
margin-top: 8px;
border-radius: 10px;
background: rgba(255,255,255,0.04);
color: #dbeafe;
line-height: 1.6;
}
.footer-note {
text-align: center;
color: #94a3b8;
font-size: 12px;
padding: 16px;
}
.online-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 8px;
background: #22c55e;
box-shadow: 0 0 8px #22c55e;
}
@keyframes fadeIn {
from {
transform: translateY(8px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@media (max-width: 1100px) {
.container {
grid-template-columns: 1fr;
}
.main-board {
min-height: auto;
}
.chat-area {
max-height: 500px;
}
}
</style>
</head>
<body>
<header>
<h1>AIキャラ会話掲示板 - Virtual Guild Board</h1>
<p>APIなし / ローカル動作 / AIキャラ同士が自動で会話するファンタジー掲示板</p>
</header>
<div class="container">
<!-- 左 -->
<aside class="panel">
<div class="panel-title">キャラクター一覧</div>
<div class="character-list" id="characterList"></div>
</aside>
<!-- 中央 -->
<main class="panel main-board">
<div class="panel-title">ギルド広場</div>
<div class="toolbar">
<button id="toggleAutoBtn">自動会話ON/OFF</button>
<button id="manualTalkBtn" class="sub">AI会話を1回進める</button>
<button id="eventBtn" class="sub">イベント発生</button>
<button id="saveBtn" class="sub">保存</button>
<button id="clearBtn" class="danger">会話をリセット</button>
</div>
<div class="chat-area" id="chatArea"></div>
<div class="composer">
<div class="composer-top">
<select id="userName">
<option value="旅人">旅人</option>
<option value="冒険者">冒険者</option>
<option value="見習い魔法使い">見習い魔法使い</option>
<option value="傭兵">傭兵</option>
<option value="吟遊詩人">吟遊詩人</option>
</select>
</div>
<textarea id="userMessage" placeholder="メッセージを書いてください。例:今日は魔王城へ向かうべきかな?"></textarea>
<div class="composer-actions">
<button id="sendBtn">投稿する</button>
<button id="userTriggerBtn" class="sub">投稿後にAI反応</button>
</div>
</div>
</main>
<!-- 右 -->
<aside class="panel">
<div class="panel-title">ワールド情報</div>
<div class="right-panel-content">
<div class="status-box">
<div><span class="online-dot"></span>状態</div>
<div class="status-line">自動会話: <span id="autoStatus">停止中</span></div>
<div class="status-line">現在の話題: <span id="currentTopic">雑談</span></div>
<div class="status-line">投稿数: <span id="postCount">0</span></div>
</div>
<div class="topic-box">
<div><strong>話題タグ</strong></div>
<div id="topicTags"></div>
</div>
<div class="memory-box">
<div><strong>最近の話題メモ</strong></div>
<div id="memoryList"></div>
</div>
</div>
</aside>
</div>
<div class="footer-note">
HTML/CSS/JavaScriptのみで動作します。データはブラウザに保存されます。
</div>
<script>
const characters = [
{
id: "hero",
name: "セイン",
role: "勇者",
emoji: "⚔️",
personality: "まっすぐで熱血。前向き。",
desc: "世界を旅する若き勇者。困っている人を見ると放っておけない。",
styles: {
start: ["よし、", "さて、", "うーん、", "そうだな、"],
end: ["だ!", "だな。", "じゃないか?", "行くしかない!"],
flavor: ["魔王", "冒険", "仲間", "ダンジョン", "伝説"]
}
},
{
id: "mage",
name: "リリィ",
role: "魔法使い",
emoji: "🔮",
personality: "冷静で知的。少し毒舌。",
desc: "古代魔法を研究している少女。理屈で考えるタイプ。",
styles: {
start: ["理論的には、", "その話なら、", "少し気になるのは、", "魔法的に言えば、"],
end: ["ですね。", "だと思います。", "かもしれません。", "要検証です。"],
flavor: ["魔法", "精霊", "古代遺跡", "呪文", "研究"]
}
},
{
id: "knight",
name: "ガルド",
role: "騎士",
emoji: "🛡️",
personality: "真面目で忠誠心が強い。",
desc: "王国騎士団に所属する重騎士。秩序と責任を重んじる。",
styles: {
start: ["王国のためにも、", "騎士としては、", "規律を守るなら、", "任務として考えると、"],
end: ["異論はない。", "それが正しい。", "油断は禁物だ。", "準備が必要だ。"],
flavor: ["王国", "任務", "警戒", "防衛", "規律"]
}
},
{
id: "merchant",
name: "ミーナ",
role: "商人",
emoji: "💰",
personality: "明るく現実的。商売人。",
desc: "各地を巡る行商人。儲け話と珍品に目がない。",
styles: {
start: ["それより、", "商売の話をすると、", "利益で考えると、", "ふふっ、"],
end: ["儲かりそうね。", "悪くないわ。", "値段次第かな。", "面白い商機だわ。"],
flavor: ["市場", "金貨", "商品", "取引", "珍品"]
}
},
{
id: "assassin",
name: "クロウ",
role: "暗殺者",
emoji: "🗡️",
personality: "寡黙でクール。影のある口調。",
desc: "裏社会で名を知られる暗殺者。静かに本質を突く。",
styles: {
start: ["……", "無駄口は嫌いだが、", "影から見る限り、", "静かに言うが、"],
end: ["それだけだ。", "油断するな。", "匂うな。", "嫌な予感がする。"],
flavor: ["影", "敵", "罠", "裏路地", "追跡"]
}
}
];
const defaultTopics = [
"魔王討伐",
"古代遺跡",
"王国の依頼",
"森の異変",
"ギルドの噂",
"珍しいアイテム",
"危険なダンジョン",
"旅の準備",
"精霊の目撃情報",
"闇市場"
];
const eventTopics = [
"城下町で祭りが始まった",
"北の洞窟にドラゴン出現",
"謎の商人が秘宝を売っている",
"王国から緊急依頼が届いた",
"森で精霊の暴走が起きている",
"魔王軍の斥候が発見された",
"夜の港で密輸の噂が広がっている"
];
const generalPhrases = [
"最近の空気、少し変わった気がする",
"今日は何か起きそうな予感がある",
"仲間がいると旅は違う",
"静かな日ほど何かが起きるものだ",
"準備を怠ると危ない",
"噂話にも案外ヒントがある",
"この町には秘密が多い",
"力だけでは解決しないこともある",
"運だけでは生き残れない",
"今のうちに備えておくべきだ"
];
const replyRules = [
{
keywords: ["魔王", "討伐", "倒す"],
responses: {
hero: ["魔王を倒せば世界は少しは平和になるはずだ!", "ついに決戦の時かもしれないな!"],
mage: ["魔王クラスの相手なら準備不足は危険です。", "封印術式も調べておくべきですね。"],
knight: ["討伐任務なら戦力の整理が必要だ。", "王国への報告も忘れるな。"],
merchant: ["討伐の前に装備をそろえないと損するわよ。", "その話、特需が出そうね。"],
assassin: ["魔王より先に側近を潰すべきだ。", "正面から行くのは愚策かもしれない。"]
}
},
{
keywords: ["金", "お金", "金貨", "報酬"],
responses: {
hero: ["報酬も大事だけど、困っている人を助けたいな。", "金だけじゃなく名誉も欲しいところだ!"],
mage: ["研究費は必要ですからね。", "魔導書は高いので報酬は重要です。"],
knight: ["報酬より任務達成が優先だ。", "とはいえ補給費は無視できない。"],
merchant: ["その話なら私の出番ね。", "利益率の高い案件なら乗るわ。"],
assassin: ["金額次第で動く者も多い。", "報酬の匂いには裏がある。"]
}
},
{
keywords: ["遺跡", "古代", "秘宝"],
responses: {
hero: ["秘宝か……冒険心がくすぐられるな!", "遺跡には夢があるよな!"],
mage: ["古代遺跡は知識の宝庫です。", "その話、かなり興味があります。"],
knight: ["遺跡調査には護衛が必要だ。", "罠の警戒を優先しよう。"],
merchant: ["秘宝は高く売れる可能性があるわね。", "希少品なら市場が動くわ。"],
assassin: ["遺跡には死人の匂いがする。", "宝より罠を疑え。"]
}
},
{
keywords: ["森", "精霊", "自然"],
responses: {
hero: ["森の異変なら放っておけないな。", "精霊と仲良くできたら心強いな!"],
mage: ["精霊系の異常反応かもしれません。", "自然魔力の乱れを疑います。"],
knight: ["森は視界が悪い。隊列を乱すな。", "索敵役が必要だな。"],
merchant: ["森の特産品が取れなくなるのは困るわ。", "薬草の値段も上がりそう。"],
assassin: ["森では音と気配に気をつけろ。", "姿の見えない敵ほど厄介だ。"]
}
},
{
keywords: ["こんにちは", "初めまして", "はじめまして"],
responses: {
hero: ["ようこそ!一緒に冒険の話をしよう!", "よろしくな!"],
mage: ["ようこそ。この掲示板は案外にぎやかですよ。", "初めまして。興味深いですね。"],
knight: ["歓迎しよう。礼節を守ってくれれば問題ない。", "ここでは情報共有が重要だ。"],
merchant: ["いらっしゃい。いい情報があれば教えてね。", "歓迎するわ、旅人さん。"],
assassin: ["……新顔か。好きにするといい。", "静かにしていれば問題ない。"]
}
}
];
let posts = [];
let memoryTopics = [];
let currentTopic = "雑談";
let autoTalk = false;
let autoTimer = null;
const characterList = document.getElementById("characterList");
const chatArea = document.getElementById("chatArea");
const currentTopicEl = document.getElementById("currentTopic");
const postCountEl = document.getElementById("postCount");
const autoStatusEl = document.getElementById("autoStatus");
const topicTagsEl = document.getElementById("topicTags");
const memoryListEl = document.getElementById("memoryList");
function renderCharacters() {
characterList.innerHTML = "";
characters.forEach(char => {
const card = document.createElement("div");
card.className = "character-card";
card.innerHTML = `
<h3>${char.emoji} ${char.name}</h3>
<div class="character-meta">${char.role} / ${char.personality}</div>
<div class="character-desc">${char.desc}</div>
`;
characterList.appendChild(card);
});
}
function getTimeString() {
const now = new Date();
return now.toLocaleTimeString("ja-JP", {
hour: "2-digit",
minute: "2-digit"
});
}
function addPost(name, role, emoji, message, isUser = false) {
const post = {
id: Date.now() + Math.random(),
name,
role,
emoji,
message,
time: getTimeString(),
isUser
};
posts.push(post);
renderPosts();
saveData();
}
function renderPosts() {
chatArea.innerHTML = "";
posts.forEach(post => {
const el = document.createElement("div");
el.className = "post";
el.innerHTML = `
<div class="avatar">${post.emoji}</div>
<div class="post-content">
<div class="post-header">
<div class="name">${escapeHtml(post.name)}</div>
<div class="role">${escapeHtml(post.role)}</div>
<div class="time">${post.time}</div>
</div>
<div class="message">${escapeHtml(post.message)}</div>
</div>
`;
chatArea.appendChild(el);
});
chatArea.scrollTop = chatArea.scrollHeight;
postCountEl.textContent = posts.length;
}
function escapeHtml(text) {
return text
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function randomItem(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
function pickCharacter(excludeId = null) {
const pool = excludeId ? characters.filter(c => c.id !== excludeId) : characters;
return randomItem(pool);
}
function updateTopic(newTopic) {
currentTopic = newTopic;
currentTopicEl.textContent = currentTopic;
memoryTopics.unshift(newTopic);
memoryTopics = [...new Set(memoryTopics)].slice(0, 8);
renderTopics();
renderMemory();
saveData();
}
function renderTopics() {
topicTagsEl.innerHTML = "";
const mixed = [currentTopic, ...defaultTopics.slice(0, 6)];
[...new Set(mixed)].forEach(topic => {
const tag = document.createElement("span");
tag.className = "topic-tag";
tag.textContent = topic;
topicTagsEl.appendChild(tag);
});
}
function renderMemory() {
memoryListEl.innerHTML = "";
if (memoryTopics.length === 0) {
memoryListEl.innerHTML = `<div class="memory-item">まだ話題メモはありません。</div>`;
return;
}
memoryTopics.forEach(topic => {
const item = document.createElement("div");
item.className = "memory-item";
item.textContent = topic;
memoryListEl.appendChild(item);
});
}
function buildCharacterSentence(character, topic = currentTopic) {
const style = character.styles;
const start = randomItem(style.start);
const end = randomItem(style.end);
const flavor = randomItem(style.flavor);
const phrase = randomItem(generalPhrases);
const patterns = [
`${start}${topic}について言えば、${flavor}が鍵になりそう${end}`,
`${start}${phrase}。特に${flavor}が絡むなら注意${end}`,
`${start}${topic}の件は気になる。${flavor}の情報を集めたい${end}`,
`${start}${flavor}を見直した方がいい。${topic}にも繋がる${end}`,
`${start}${phrase}。${topic}と${flavor}は無関係じゃない${end}`
];
return randomItem(patterns);
}
function getRuleBasedReply(inputText, character) {
const text = inputText.toLowerCase();
for (const rule of replyRules) {
const matched = rule.keywords.some(keyword => text.includes(keyword.toLowerCase()));
if (matched) {
const responses = rule.responses[character.id];
if (responses && responses.length > 0) {
return randomItem(responses);
}
}
}
return null;
}
function extractTopicFromText(text) {
const found = defaultTopics.find(topic => text.includes(topic.replace("の", ""))) ||
eventTopics.find(topic => text.includes(topic.slice(0, 4)));
if (found) return found;
if (text.includes("魔王")) return "魔王討伐";
if (text.includes("遺跡")) return "古代遺跡";
if (text.includes("森")) return "森の異変";
if (text.includes("金") || text.includes("報酬")) return "報酬と金貨";
if (text.includes("王国")) return "王国の依頼";
if (text.includes("精霊")) return "精霊の目撃情報";
if (text.includes("ダンジョン")) return "危険なダンジョン";
return null;
}
function aiTalkOnce(previousSpeakerId = null) {
const speaker = pickCharacter(previousSpeakerId);
const msg = buildCharacterSentence(speaker, currentTopic);
addPost(speaker.name, speaker.role, speaker.emoji, msg);
}
function aiReplyToText(text, count = 2) {
const detectedTopic = extractTopicFromText(text);
if (detectedTopic) updateTopic(detectedTopic);
let usedIds = [];
for (let i = 0; i < count; i++) {
const pool = characters.filter(c => !usedIds.includes(c.id));
const speaker = randomItem(pool);
usedIds.push(speaker.id);
let reply = getRuleBasedReply(text, speaker);
if (!reply) {
reply = buildCharacterSentence(speaker, currentTopic);
}
addPost(speaker.name, speaker.role, speaker.emoji, reply);
}
}
function generateEvent() {
const eventText = randomItem(eventTopics);
updateTopic(eventText);
addPost("ワールド通知", "システム", "📢", eventText);
setTimeout(() => {
aiReplyToText(eventText, 3);
}, 300);
}
function toggleAutoTalk() {
autoTalk = !autoTalk;
autoStatusEl.textContent = autoTalk ? "稼働中" : "停止中";
if (autoTalk) {
autoTimer = setInterval(() => {
const count = Math.random() < 0.4 ? 2 : 1;
let prevId = null;
for (let i = 0; i < count; i++) {
const speaker = pickCharacter(prevId);
prevId = speaker.id;
const msg = buildCharacterSentence(speaker, currentTopic);
addPost(speaker.name, speaker.role, speaker.emoji, msg);
}
if (Math.random() < 0.28) {
updateTopic(randomItem(defaultTopics));
}
}, 4500);
} else {
clearInterval(autoTimer);
}
saveData();
}
function saveData() {
const data = {
posts,
memoryTopics,
currentTopic,
autoTalk
};
localStorage.setItem("virtualGuildBoardData", JSON.stringify(data));
}
function loadData() {
const raw = localStorage.getItem("virtualGuildBoardData");
if (!raw) return false;
try {
const data = JSON.parse(raw);
posts = data.posts || [];
memoryTopics = data.memoryTopics || [];
currentTopic = data.currentTopic || "雑談";
autoTalk = false;
currentTopicEl.textContent = currentTopic;
renderPosts();
renderTopics();
renderMemory();
autoStatusEl.textContent = "停止中";
return true;
} catch (e) {
console.error("読み込み失敗", e);
return false;
}
}
function clearBoard() {
if (!confirm("会話ログをリセットしますか?")) return;
posts = [];
memoryTopics = [];
currentTopic = "雑談";
currentTopicEl.textContent = currentTopic;
renderPosts();
renderTopics();
renderMemory();
saveData();
addWelcomePosts();
}
function addWelcomePosts() {
addPost("ワールド通知", "システム", "🌍", "Virtual Guild Boardへようこそ。ここではAIキャラたちが自由に会話します。");
addPost("セイン", "勇者", "⚔️", "よし、今日も冒険の情報を集めよう!");
addPost("リリィ", "魔法使い", "🔮", "掲示板の反応を見る限り、今日は賑やかになりそうですね。");
addPost("ミーナ", "商人", "💰", "儲け話でも危険な依頼でも、情報は早い者勝ちよ。");
}
document.getElementById("sendBtn").addEventListener("click", () => {
const userName = document.getElementById("userName").value.trim();
const userMessage = document.getElementById("userMessage").value.trim();
if (!userMessage) {
alert("メッセージを入力してください。");
return;
}
addPost(userName, "プレイヤー", "🧑", userMessage, true);
const maybeTopic = extractTopicFromText(userMessage);
if (maybeTopic) {
updateTopic(maybeTopic);
}
document.getElementById("userMessage").value = "";
});
document.getElementById("userTriggerBtn").addEventListener("click", () => {
const userName = document.getElementById("userName").value.trim();
const userMessage = document.getElementById("userMessage").value.trim();
if (!userMessage) {
alert("メッセージを入力してください。");
return;
}
addPost(userName, "プレイヤー", "🧑", userMessage, true);
aiReplyToText(userMessage, 3);
document.getElementById("userMessage").value = "";
});
document.getElementById("manualTalkBtn").addEventListener("click", () => {
aiTalkOnce();
});
document.getElementById("eventBtn").addEventListener("click", () => {
generateEvent();
});
document.getElementById("toggleAutoBtn").addEventListener("click", () => {
toggleAutoTalk();
});
document.getElementById("saveBtn").addEventListener("click", () => {
saveData();
alert("保存しました。");
});
document.getElementById("clearBtn").addEventListener("click", () => {
clearBoard();
});
renderCharacters();
const loaded = loadData();
if (!loaded || posts.length === 0) {
renderTopics();
renderMemory();
addWelcomePosts();
updateTopic("ギルドの噂");
} else {
renderTopics();
renderMemory();
}
</script>
</body>
</html>
Vooglebrowser
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>Voogle - Mini Browser</title>
<style>
:root{
--bg:#0b0f14;
--panel: rgba(255,255,255,.06);
--stroke: rgba(255,255,255,.10);
--ink:#eaf0ff;
--muted: rgba(234,240,255,.70);
--accent:#7cf0ff;
--accent2:#7ca0ff;
--danger:#ff6b6b;
--ok:#79ffa7;
--shadow: 0 18px 50px rgba(0,0,0,.35);
--radius:16px;
--radius2:22px;
--glass: blur(14px) saturate(1.2);
}
[data-theme="light"]{
--bg:#f6f7fb;
--panel: rgba(0,0,0,.05);
--stroke: rgba(0,0,0,.10);
--ink:#101828;
--muted: rgba(16,24,40,.68);
--shadow: 0 18px 50px rgba(16,24,40,.12);
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
background: radial-gradient(1200px 800px at 20% 10%, rgba(124,240,255,.15), transparent 60%),
radial-gradient(1200px 800px at 80% 20%, rgba(124,160,255,.14), transparent 60%),
var(--bg);
color:var(--ink);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans JP", Arial;
overflow:hidden;
}
.app{
height:100%;
display:grid;
grid-template-columns: 320px 1fr;
gap: 12px;
padding: 12px;
}
.card{
background: linear-gradient(180deg, rgba(255,255,255,.08), rgba(255,255,255,.03));
border: 1px solid var(--stroke);
border-radius: var(--radius2);
box-shadow: var(--shadow);
backdrop-filter: var(--glass);
overflow:hidden;
min-height:0;
}
.sidebar{display:flex; flex-direction:column; min-height:0;}
.main{display:flex; flex-direction:column; min-height:0;}
/* Topbar */
.topbar{
display:flex;
align-items:center;
gap:10px;
padding:10px;
border-bottom:1px solid var(--stroke);
background: rgba(0,0,0,.08);
}
[data-theme="light"] .topbar{ background: rgba(255,255,255,.55); }
.brand{
display:flex; align-items:center; gap:10px;
padding:10px;
border-bottom:1px solid var(--stroke);
}
.logo{
width:34px; height:34px; border-radius:12px;
background: radial-gradient(circle at 30% 30%, var(--accent), rgba(124,240,255,.0) 55%),
radial-gradient(circle at 70% 70%, var(--accent2), rgba(124,160,255,.0) 55%),
rgba(255,255,255,.06);
border:1px solid var(--stroke);
box-shadow: 0 12px 30px rgba(124,240,255,.14);
}
.brand h1{
font-size:14px; margin:0; letter-spacing:.4px;
}
.brand p{margin:0; font-size:12px; color:var(--muted)}
.btn{
appearance:none;
border:1px solid var(--stroke);
background: rgba(255,255,255,.06);
color:var(--ink);
padding:8px 10px;
border-radius: 12px;
cursor:pointer;
transition: transform .08s ease, background .15s ease, border-color .15s ease;
user-select:none;
white-space:nowrap;
}
.btn:hover{ background: rgba(255,255,255,.10); border-color: rgba(124,240,255,.28); }
.btn:active{ transform: scale(.98); }
.btn.primary{
border-color: rgba(124,240,255,.35);
background: linear-gradient(180deg, rgba(124,240,255,.18), rgba(124,160,255,.10));
}
.btn.danger{
border-color: rgba(255,107,107,.35);
background: linear-gradient(180deg, rgba(255,107,107,.16), rgba(255,107,107,.08));
}
.btn.ok{
border-color: rgba(121,255,167,.35);
background: linear-gradient(180deg, rgba(121,255,167,.14), rgba(121,255,167,.07));
}
.icon{
width:18px;height:18px;display:inline-grid;place-items:center;
font-weight:700; opacity:.9;
}
/* Address */
.addr{
flex:1;
display:flex;
gap:10px;
align-items:center;
min-width:0;
}
.addr input{
width:100%;
min-width:0;
padding:10px 12px;
border-radius: 14px;
border:1px solid var(--stroke);
background: rgba(0,0,0,.14);
color:var(--ink);
outline:none;
}
[data-theme="light"] .addr input{ background: rgba(255,255,255,.75); }
.hint{
font-size:12px;
color:var(--muted);
padding: 0 12px 10px;
}
/* Tabs */
.tabs{
display:flex;
gap:8px;
padding:10px;
border-bottom:1px solid var(--stroke);
overflow:auto;
}
.tab{
display:flex; align-items:center; gap:8px;
padding:8px 10px;
border-radius: 14px;
border:1px solid var(--stroke);
background: rgba(255,255,255,.06);
cursor:pointer;
min-width: 160px;
max-width: 260px;
flex: 0 0 auto;
}
.tab.active{
border-color: rgba(124,240,255,.45);
background: linear-gradient(180deg, rgba(124,240,255,.16), rgba(124,160,255,.10));
}
.tab .title{
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
font-size:13px;
flex:1;
}
.pill{
font-size:11px;
color:var(--muted);
border:1px solid var(--stroke);
padding:2px 8px;
border-radius:999px;
background: rgba(0,0,0,.10);
}
[data-theme="light"] .pill{ background: rgba(255,255,255,.6); }
.x{
width:24px;height:24px; border-radius:10px;
display:grid; place-items:center;
border:1px solid var(--stroke);
background: rgba(0,0,0,.10);
opacity:.9;
}
.x:hover{ border-color: rgba(255,107,107,.5); }
[data-theme="light"] .x{ background: rgba(255,255,255,.6); }
/* Viewport */
.viewport{
position:relative;
flex:1;
min-height:0;
background: rgba(0,0,0,.10);
}
[data-theme="light"] .viewport{ background: rgba(0,0,0,.03); }
.frame{
position:absolute; inset:0;
width:100%; height:100%;
border:0;
background: transparent;
}
.overlay{
position:absolute; inset: 14px;
border-radius: 18px;
border:1px dashed rgba(124,240,255,.35);
display:none;
place-items:center;
text-align:center;
padding:18px;
background: rgba(0,0,0,.35);
backdrop-filter: blur(10px);
}
[data-theme="light"] .overlay{ background: rgba(255,255,255,.78); }
.overlay.show{ display:grid; }
.overlay h2{margin:0 0 8px; font-size:16px;}
.overlay p{margin:0 0 12px; color:var(--muted); font-size:13px;}
.overlay .row{display:flex; gap:10px; flex-wrap:wrap; justify-content:center}
/* Sidebar content */
.section{
padding:12px;
border-top:1px solid var(--stroke);
min-height:0;
overflow:auto;
}
.section h3{
margin:0 0 10px;
font-size:12px;
color:var(--muted);
letter-spacing:.18em;
}
.list{
display:flex;
flex-direction:column;
gap:8px;
}
.item{
display:flex;
gap:10px;
align-items:center;
padding:10px 10px;
border-radius: 14px;
border:1px solid var(--stroke);
background: rgba(255,255,255,.05);
cursor:pointer;
}
.item:hover{ border-color: rgba(124,240,255,.28); background: rgba(255,255,255,.08); }
.item .meta{flex:1; min-width:0}
.item .meta .t{
font-size:13px;
overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
}
.item .meta .s{
font-size:12px; color:var(--muted);
overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
}
.tag{
font-size:11px;
padding:2px 8px;
border-radius: 999px;
border:1px solid var(--stroke);
color: var(--muted);
}
.footerbar{
padding:10px 12px;
border-top:1px solid var(--stroke);
font-size:12px;
color:var(--muted);
display:flex;
gap:12px;
align-items:center;
justify-content:space-between;
}
.kbd{
border:1px solid var(--stroke);
border-bottom-width:2px;
padding:2px 6px;
border-radius:8px;
background: rgba(0,0,0,.10);
font-size:11px;
color:var(--muted);
white-space:nowrap;
}
[data-theme="light"] .kbd{ background: rgba(255,255,255,.6); }
.row{
display:flex; gap:8px; flex-wrap:wrap;
}
.mini{
font-size:12px;
padding:6px 8px;
border-radius: 12px;
}
.toast{
position:fixed;
right:14px; bottom:14px;
padding:10px 12px;
border-radius: 14px;
border:1px solid var(--stroke);
background: rgba(0,0,0,.50);
backdrop-filter: blur(10px);
color:var(--ink);
box-shadow: var(--shadow);
transform: translateY(10px);
opacity:0;
transition: .22s ease;
pointer-events:none;
max-width: min(420px, calc(100vw - 28px));
}
[data-theme="light"] .toast{ background: rgba(255,255,255,.86); }
.toast.show{ transform: translateY(0); opacity:1; }
.toast .small{ font-size:12px; color:var(--muted); margin-top:2px; }
@media (max-width: 980px){
.app{ grid-template-columns: 1fr; }
.sidebar{ display:none; }
}
</style>
</head>
<body data-theme="dark">
<div class="app">
<!-- Sidebar -->
<aside class="card sidebar">
<div class="brand">
<div class="logo" aria-hidden="true"></div>
<div>
<h1>Voogle</h1>
<p>Mini Browser (1-file)</p>
</div>
</div>
<div class="section" style="border-top:none">
<div class="row">
<button class="btn mini primary" id="btnNewTab"><span class="icon">+</span>新規タブ</button>
<button class="btn mini" id="btnToggleTheme"><span class="icon">☾</span>テーマ</button>
<button class="btn mini" id="btnExport"><span class="icon">⤓</span>データ出力</button>
<label class="btn mini" style="display:inline-flex; align-items:center; gap:8px; cursor:pointer;">
<span class="icon">⤒</span>データ取込
<input id="importFile" type="file" accept="application/json" style="display:none" />
</label>
</div>
<div class="hint">※多くの外部サイトは埋め込み禁止。開けない時は「新しいタブで開く」。</div>
</div>
<div class="section">
<h3>クイック</h3>
<div class="list" id="quickList"></div>
</div>
<div class="section">
<h3>ブックマーク</h3>
<div class="list" id="bmList"></div>
</div>
<div class="section">
<h3>履歴(最新20件)</h3>
<div class="list" id="histList"></div>
</div>
<div class="footerbar">
<div class="row" style="gap:6px">
<span class="kbd">Ctrl</span>+<span class="kbd">L</span> アドレス
<span class="kbd">Ctrl</span>+<span class="kbd">T</span> 新規
<span class="kbd">Ctrl</span>+<span class="kbd">W</span> 閉じる
</div>
<span id="statusText">Ready</span>
</div>
</aside>
<!-- Main -->
<main class="card main">
<div class="topbar">
<button class="btn" id="btnBack" title="戻る"><span class="icon">←</span></button>
<button class="btn" id="btnForward" title="進む"><span class="icon">→</span></button>
<button class="btn" id="btnReload" title="更新"><span class="icon">↻</span></button>
<div class="addr">
<input id="addrInput" placeholder="URL または 検索ワード(例: https://example.com / openai)" autocomplete="off" />
</div>
<button class="btn primary" id="btnGo" title="移動"><span class="icon">⏎</span></button>
<button class="btn" id="btnBookmark" title="ブックマーク"><span class="icon">☆</span></button>
<button class="btn" id="btnOpenExternal" title="新しいタブで開く"><span class="icon">↗</span></button>
<button class="btn" id="btnPip" title="PiP(対応サイトのみ)"><span class="icon">▣</span></button>
</div>
<div class="tabs" id="tabs"></div>
<div class="viewport">
<iframe id="frame" class="frame" sandbox="allow-forms allow-modals allow-popups allow-same-origin allow-scripts allow-downloads"></iframe>
<div class="overlay" id="overlay">
<div>
<h2>このページは埋め込みを拒否してる</h2>
<p>
たいていはサイト側のセキュリティ(X-Frame-Options / CSP)です。<br>
下のボタンで外部タブとして開け。
</p>
<div class="row">
<button class="btn ok" id="overlayOpenExternal"><span class="icon">↗</span>新しいタブで開く</button>
<button class="btn" id="overlayTrySearch"><span class="icon">⌕</span>検索で開く</button>
<button class="btn danger" id="overlayClose"><span class="icon">×</span>閉じる</button>
</div>
</div>
</div>
</div>
<div class="footerbar">
<div class="row" style="gap:10px; align-items:center">
<span class="tag" id="originTag">—</span>
<span class="tag" id="secureTag">—</span>
<span class="tag" id="embedTag">—</span>
</div>
<div class="row" style="gap:8px">
<button class="btn mini" id="btnHome">ホーム</button>
<button class="btn mini" id="btnClearHistory">履歴クリア</button>
<button class="btn mini danger" id="btnResetAll">全リセット</button>
</div>
</div>
</main>
</div>
<div class="toast" id="toast"></div>
<script>
(() => {
// =========================
// Utilities
// =========================
const $ = (sel) => document.querySelector(sel);
const escapeHTML = (s) => (s ?? "").toString()
.replaceAll("&","&").replaceAll("<","<")
.replaceAll(">",">").replaceAll('"',""").replaceAll("'","'");
const nowISO = () => new Date().toISOString();
const isUrlLike = (text) => {
const t = (text || "").trim();
if (!t) return false;
if (/^https?:\/\//i.test(t)) return true;
if (/^[a-z0-9.-]+\.[a-z]{2,}([\/?#].*)?$/i.test(t)) return true;
if (/^localhost(:\d+)?(\/.*)?$/i.test(t)) return true;
if (/^\d{1,3}(\.\d{1,3}){3}(:\d+)?(\/.*)?$/.test(t)) return true;
return false;
};
const normalizeToUrl = (text) => {
let t = (text || "").trim();
if (!t) return "";
if (/^https?:\/\//i.test(t)) return t;
if (isUrlLike(t)) return "https://" + t;
return "";
};
const buildSearchUrl = (q) => "https://www.google.com/search?q=" + encodeURIComponent(q);
const toast = (title, detail="") => {
const el = $("#toast");
el.innerHTML = `<div><b>${escapeHTML(title)}</b></div>${detail ? `<div class="small">${escapeHTML(detail)}</div>` : ""}`;
el.classList.add("show");
clearTimeout(toast._t);
toast._t = setTimeout(() => el.classList.remove("show"), 2400);
};
const setStatus = (s) => { $("#statusText").textContent = s; };
// =========================
// Storage
// =========================
const KEY = "voogle.v1";
const defaultState = () => ({
theme: "dark",
tabs: [
{ id: crypto.randomUUID(), title: "Home", url: "about:home", history: ["about:home"], hIndex: 0, createdAt: nowISO() }
],
activeTabId: null,
bookmarks: [
{ title:"OpenAI", url:"https://openai.com", addedAt: nowISO() },
{ title:"Wikipedia", url:"https://ja.wikipedia.org", addedAt: nowISO() },
{ title:"GitHub", url:"https://github.com", addedAt: nowISO() },
{ title:"MDN", url:"https://developer.mozilla.org/ja/", addedAt: nowISO() },
{ title:"YouTube", url:"https://www.youtube.com", addedAt: nowISO() },
],
history: [],
quick: [
{ title:"ニュース", url:"https://news.google.com/?hl=ja&gl=JP&ceid=JP:ja" },
{ title:"X", url:"https://x.com" },
{ title:"Reddit", url:"https://www.reddit.com" },
{ title:"Qiita", url:"https://qiita.com" },
{ title:"Zenn", url:"https://zenn.dev" },
{ title:"Google", url:"https://www.google.com" },
],
settings: {
homeUrl: "about:home",
maxHistory: 200,
sidebarHistoryView: 20
}
});
const loadState = () => {
try{
const raw = localStorage.getItem(KEY);
if(!raw) return defaultState();
const s = JSON.parse(raw);
// minimal migrate
if(!s.tabs?.length) return defaultState();
return s;
}catch(e){
console.warn(e);
return defaultState();
}
};
const saveState = () => localStorage.setItem(KEY, JSON.stringify(state));
let state = loadState();
// =========================
// Tabs
// =========================
const getActiveTab = () => state.tabs.find(t => t.id === state.activeTabId) || state.tabs[0];
const setActive = (id) => {
state.activeTabId = id;
saveState();
renderAll();
loadTabToViewport(getActiveTab());
};
const newTab = (url = "about:home", title = "New Tab") => {
const tab = {
id: crypto.randomUUID(),
title,
url,
history: [url],
hIndex: 0,
createdAt: nowISO()
};
state.tabs.push(tab);
state.activeTabId = tab.id;
saveState();
renderAll();
loadTabToViewport(tab);
toast("新規タブ", title);
};
const closeTab = (id) => {
if(state.tabs.length <= 1){
toast("これ以上閉じれない", "最低1タブは残る");
return;
}
const idx = state.tabs.findIndex(t => t.id === id);
if(idx < 0) return;
const wasActive = state.activeTabId === id;
const closed = state.tabs[idx];
state.tabs.splice(idx,1);
if(wasActive){
const fallback = state.tabs[Math.max(0, idx-1)];
state.activeTabId = fallback.id;
}
saveState();
renderAll();
loadTabToViewport(getActiveTab());
toast("タブを閉じた", closed.title);
};
const setTabTitle = (tab, title) => {
tab.title = (title || "Untitled").slice(0, 60);
saveState();
renderTabs();
};
const pushHistory = (tab, url) => {
if(tab.history[tab.hIndex] === url) return;
tab.history = tab.history.slice(0, tab.hIndex + 1);
tab.history.push(url);
tab.hIndex = tab.history.length - 1;
};
// =========================
// History + Bookmarks
// =========================
const addGlobalHistory = (url, title="") => {
if(!url || url === "about:home") return;
state.history.unshift({ url, title, at: nowISO() });
// de-dupe
const seen = new Set();
state.history = state.history.filter(h => {
const k = h.url;
if(seen.has(k)) return false;
seen.add(k);
return true;
});
state.history = state.history.slice(0, state.settings.maxHistory || 200);
saveState();
renderSidebar();
};
const isBookmarked = (url) => state.bookmarks.some(b => b.url === url);
const toggleBookmark = () => {
const tab = getActiveTab();
const url = tab.url;
if(!url || url === "about:home") return toast("ホームは登録しない");
if(isBookmarked(url)){
state.bookmarks = state.bookmarks.filter(b => b.url !== url);
saveState();
renderSidebar();
toast("ブックマーク削除", url);
}else{
state.bookmarks.unshift({ title: tab.title || url, url, addedAt: nowISO() });
saveState();
renderSidebar();
toast("ブックマーク追加", tab.title || url);
}
renderIndicators();
};
// =========================
// Viewport Loader
// =========================
const frame = $("#frame");
const overlay = $("#overlay");
let embedBlockedTimer = null;
const setOverlay = (show) => {
overlay.classList.toggle("show", !!show);
};
const updateAddressBar = (tab) => {
$("#addrInput").value = tab.url === "about:home" ? "" : tab.url;
};
const renderIndicators = () => {
const tab = getActiveTab();
const url = tab.url || "";
const originTag = $("#originTag");
const secureTag = $("#secureTag");
const embedTag = $("#embedTag");
let origin = "—";
try{
if(url.startsWith("about:")) origin = "about";
else origin = (new URL(url)).hostname;
}catch(e){ origin = "—"; }
const secure = url.startsWith("https://") ? "HTTPS" : (url.startsWith("http://") ? "HTTP" : "—");
originTag.textContent = origin;
secureTag.textContent = secure;
const bm = isBookmarked(url) ? "Bookmarked" : "Not bookmarked";
embedTag.textContent = bm;
};
const loadTabToViewport = (tab) => {
setOverlay(false);
clearTimeout(embedBlockedTimer);
renderIndicators();
updateAddressBar(tab);
const url = tab.url;
if(url === "about:home"){
frame.removeAttribute("src");
frame.srcdoc = homeHTML();
setStatus("Home");
return;
}
frame.removeAttribute("srcdoc");
frame.src = url;
setStatus("Loading…");
// "埋め込みブロック" は確実に検知できないが、
// 一定時間で表示されなければ overlay を出して逃げ道を用意する。
embedBlockedTimer = setTimeout(() => {
// about:blank だったり、何も表示されないケースを想定
// ここは「保険」なので強制表示ではなく、状況を見て出す
// → タブのURLが外部なら基本出す
if(getActiveTab().url === url){
setOverlay(true);
setStatus("Embed blocked (maybe)");
}
}, 1400);
};
frame.addEventListener("load", () => {
clearTimeout(embedBlockedTimer);
const tab = getActiveTab();
// タイトルの推定は、クロスオリジンだと取れないのでURLから作る
if(tab.url.startsWith("about:")){
setStatus("Ready");
return;
}
setOverlay(false);
setStatus("Ready");
addGlobalHistory(tab.url, tab.title);
// タイトル推定
let title = tab.title;
try{
const u = new URL(tab.url);
title = u.hostname;
if(u.pathname && u.pathname !== "/") title += u.pathname.slice(0, 14) + (u.pathname.length > 14 ? "…" : "");
}catch(e){}
setTabTitle(tab, title);
renderIndicators();
});
frame.addEventListener("error", () => {
clearTimeout(embedBlockedTimer);
setOverlay(true);
setStatus("Load error");
});
const navigate = (input) => {
const tab = getActiveTab();
const raw = (input ?? $("#addrInput").value).trim();
if(!raw){
tab.url = "about:home";
pushHistory(tab, tab.url);
saveState();
loadTabToViewport(tab);
renderTabs();
return;
}
const url = normalizeToUrl(raw) || buildSearchUrl(raw);
tab.url = url;
pushHistory(tab, url);
saveState();
renderTabs();
loadTabToViewport(tab);
toast("移動", url);
};
const back = () => {
const tab = getActiveTab();
if(tab.hIndex <= 0) return toast("戻れない");
tab.hIndex -= 1;
tab.url = tab.history[tab.hIndex];
saveState();
renderTabs();
loadTabToViewport(tab);
};
const forward = () => {
const tab = getActiveTab();
if(tab.hIndex >= tab.history.length - 1) return toast("進めない");
tab.hIndex += 1;
tab.url = tab.history[tab.hIndex];
saveState();
renderTabs();
loadTabToViewport(tab);
};
const reload = () => {
const tab = getActiveTab();
if(tab.url === "about:home"){
loadTabToViewport(tab);
return;
}
try{
frame.contentWindow.location.reload();
}catch(e){
// クロスオリジンは reload 制限があるので src 再設定
frame.src = tab.url;
}
setStatus("Reloading…");
};
const openExternal = () => {
const tab = getActiveTab();
const url = tab.url === "about:home" ? buildSearchUrl($("#addrInput").value.trim() || "home") : tab.url;
window.open(url, "_blank", "noopener,noreferrer");
toast("外部で開いた", url);
};
const tryPip = async () => {
try{
const doc = frame.contentDocument;
if(!doc) throw new Error("Cross-origin");
const video = doc.querySelector("video");
if(!video) return toast("動画が見つからない", "このページに <video> がない");
if(document.pictureInPictureElement) await document.exitPictureInPicture();
await video.requestPictureInPicture();
toast("PiP", "Picture-in-Picture");
}catch(e){
toast("PiP不可", "多くの外部サイトは制限がある");
}
};
// =========================
// UI Render
// =========================
const renderTabs = () => {
const el = $("#tabs");
const activeId = state.activeTabId || state.tabs[0]?.id;
if(!state.activeTabId) state.activeTabId = activeId;
el.innerHTML = state.tabs.map(t => {
const active = t.id === activeId ? "active" : "";
const pill = t.url === "about:home" ? "HOME" : (t.url.startsWith("https://") ? "HTTPS" : (t.url.startsWith("http://") ? "HTTP" : "—"));
return `
<div class="tab ${active}" data-tab="${t.id}">
<div class="title">${escapeHTML(t.title || "Untitled")}</div>
<span class="pill">${escapeHTML(pill)}</span>
<div class="x" title="閉じる" data-close="${t.id}">×</div>
</div>
`;
}).join("");
el.querySelectorAll(".tab").forEach(tabEl => {
tabEl.addEventListener("click", (ev) => {
const closeId = ev.target?.getAttribute?.("data-close");
if(closeId){
ev.stopPropagation();
closeTab(closeId);
return;
}
const id = tabEl.getAttribute("data-tab");
setActive(id);
});
});
};
const renderSidebar = () => {
// Quick
const q = $("#quickList");
q.innerHTML = state.quick.map(x => `
<div class="item" data-url="${escapeHTML(x.url)}">
<div class="icon">⚡</div>
<div class="meta">
<div class="t">${escapeHTML(x.title)}</div>
<div class="s">${escapeHTML(x.url)}</div>
</div>
<span class="tag">OPEN</span>
</div>
`).join("");
// Bookmarks
const b = $("#bmList");
b.innerHTML = (state.bookmarks.length ? state.bookmarks : [{title:"(なし)", url:""}]).map(x => `
<div class="item" data-url="${escapeHTML(x.url || "")}" ${x.url ? "" : "style='opacity:.6; cursor:default'"}>
<div class="icon">☆</div>
<div class="meta">
<div class="t">${escapeHTML(x.title)}</div>
<div class="s">${escapeHTML(x.url)}</div>
</div>
${x.url ? `<span class="tag">OPEN</span>` : `<span class="tag">—</span>`}
</div>
`).join("");
// History
const h = $("#histList");
const max = state.settings.sidebarHistoryView || 20;
const hist = state.history.slice(0, max);
h.innerHTML = (hist.length ? hist : [{title:"(なし)", url:""}]).map(x => `
<div class="item" data-url="${escapeHTML(x.url || "")}" ${x.url ? "" : "style='opacity:.6; cursor:default'"}>
<div class="icon">⟲</div>
<div class="meta">
<div class="t">${escapeHTML(x.title || x.url || "(なし)")}</div>
<div class="s">${escapeHTML(x.url || "")}</div>
</div>
${x.url ? `<span class="tag">OPEN</span>` : `<span class="tag">—</span>`}
</div>
`).join("");
// Handlers
const bindOpen = (root) => {
root.querySelectorAll(".item").forEach(it => {
it.addEventListener("click", () => {
const url = it.getAttribute("data-url");
if(!url) return;
navigate(url);
});
});
};
bindOpen(q); bindOpen(b); bindOpen(h);
};
const renderTheme = () => {
document.body.setAttribute("data-theme", state.theme || "dark");
$("#btnToggleTheme").innerHTML = state.theme === "dark"
? `<span class="icon">☾</span>テーマ`
: `<span class="icon">☀</span>テーマ`;
};
const renderAll = () => {
renderTheme();
renderTabs();
renderSidebar();
renderIndicators();
};
// =========================
// Home page (srcdoc)
// =========================
const homeHTML = () => {
const quick = state.quick.slice(0, 6).map(x => `
<a class="card" href="${x.url}" target="_blank" rel="noopener noreferrer">
<div class="t">${escapeHTML(x.title)}</div>
<div class="s">${escapeHTML(x.url)}</div>
</a>
`).join("");
const bm = state.bookmarks.slice(0, 6).map(x => `
<a class="card" href="${x.url}" target="_blank" rel="noopener noreferrer">
<div class="t">☆ ${escapeHTML(x.title)}</div>
<div class="s">${escapeHTML(x.url)}</div>
</a>
`).join("");
return `<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Home</title>
<style>
:root{ color-scheme: dark; }
body{
margin:0;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans JP", Arial;
background: radial-gradient(1000px 600px at 20% 20%, rgba(124,240,255,.18), transparent 60%),
radial-gradient(1000px 600px at 80% 20%, rgba(124,160,255,.16), transparent 60%),
#0b0f14;
color:#eaf0ff;
}
.wrap{ padding: 18px; }
h1{ font-size:18px; margin:0 0 8px; }
p{ margin:0 0 14px; opacity:.75; font-size:13px; }
.grid{
display:grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap:10px;
}
.card{
display:block;
padding:12px 12px;
border-radius: 16px;
border:1px solid rgba(255,255,255,.10);
background: rgba(255,255,255,.06);
text-decoration:none;
color:inherit;
}
.card:hover{ border-color: rgba(124,240,255,.35); background: rgba(255,255,255,.09); }
.t{ font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.s{ font-size:12px; opacity:.70; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-top:4px; }
.row{ display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
.pill{
border:1px solid rgba(255,255,255,.10);
padding:6px 10px;
border-radius: 999px;
background: rgba(255,255,255,.06);
font-size:12px;
opacity:.85;
}
</style>
</head>
<body>
<div class="wrap">
<h1>Voogle Home</h1>
<p>アドレスバーにURLか検索ワードを入れて Enter。埋め込み不可サイトは外部タブで開く。</p>
<div class="row">
<span class="pill">Ctrl+L: アドレス</span>
<span class="pill">Ctrl+T: 新規タブ</span>
<span class="pill">Ctrl+W: タブ閉じる</span>
<span class="pill">Ctrl+R: 更新</span>
</div>
<h1 style="margin-top:18px">Quick</h1>
<div class="grid">${quick}</div>
<h1 style="margin-top:18px">Bookmarks</h1>
<div class="grid">${bm}</div>
</div>
</body>
</html>`;
};
// =========================
// Export / Import
// =========================
const exportData = () => {
const data = JSON.stringify(state, null, 2);
const blob = new Blob([data], {type:"application/json"});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "voogle-data.json";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
toast("出力した", "voogle-data.json");
};
const importData = async (file) => {
try{
const text = await file.text();
const obj = JSON.parse(text);
if(!obj || !obj.tabs || !Array.isArray(obj.tabs)) throw new Error("Invalid");
state = obj;
saveState();
renderAll();
loadTabToViewport(getActiveTab());
toast("取込完了", "データを復元した");
}catch(e){
toast("取込失敗", "JSONが壊れてるか形式が違う");
}
};
const resetAll = () => {
if(!confirm("全データを初期化する?")) return;
state = defaultState();
saveState();
renderAll();
loadTabToViewport(getActiveTab());
toast("初期化した");
};
// =========================
// Bindings
// =========================
$("#btnGo").addEventListener("click", () => navigate());
$("#addrInput").addEventListener("keydown", (e) => {
if(e.key === "Enter") navigate();
});
$("#btnBack").addEventListener("click", back);
$("#btnForward").addEventListener("click", forward);
$("#btnReload").addEventListener("click", reload);
$("#btnBookmark").addEventListener("click", toggleBookmark);
$("#btnOpenExternal").addEventListener("click", openExternal);
$("#btnPip").addEventListener("click", tryPip);
$("#btnNewTab").addEventListener("click", () => newTab("about:home", "Home"));
$("#btnToggleTheme").addEventListener("click", () => {
state.theme = (state.theme === "dark") ? "light" : "dark";
saveState();
renderTheme();
toast("テーマ", state.theme);
});
$("#btnExport").addEventListener("click", exportData);
$("#importFile").addEventListener("change", (e) => {
const f = e.target.files?.[0];
if(f) importData(f);
e.target.value = "";
});
$("#btnHome").addEventListener("click", () => navigate("about:home"));
$("#btnClearHistory").addEventListener("click", () => {
state.history = [];
saveState();
renderSidebar();
toast("履歴クリア");
});
$("#btnResetAll").addEventListener("click", resetAll);
// Overlay buttons
$("#overlayOpenExternal").addEventListener("click", openExternal);
$("#overlayTrySearch").addEventListener("click", () => {
const tab = getActiveTab();
const q = tab.url && tab.url !== "about:home" ? tab.url : ($("#addrInput").value.trim() || "home");
newTab(buildSearchUrl(q), "Search");
});
$("#overlayClose").addEventListener("click", () => {
setOverlay(false);
toast("閉じた");
});
// Keyboard shortcuts
window.addEventListener("keydown", (e) => {
const ctrl = e.ctrlKey || e.metaKey;
if(ctrl && e.key.toLowerCase() === "l"){ e.preventDefault(); $("#addrInput").focus(); $("#addrInput").select(); }
if(ctrl && e.key.toLowerCase() === "t"){ e.preventDefault(); newTab("about:home", "Home"); }
if(ctrl && e.key.toLowerCase() === "w"){ e.preventDefault(); closeTab(getActiveTab().id); }
if(ctrl && e.key.toLowerCase() === "r"){ e.preventDefault(); reload(); }
if(ctrl && e.key === "Enter"){ e.preventDefault(); openExternal(); }
});
// Init
if(!state.activeTabId) state.activeTabId = state.tabs[0].id;
renderAll();
loadTabToViewport(getActiveTab());
})();
</script>
</body>
</html>
HTMLの基礎
HTMLってなに?
- Webページの骨組みを作る言語(見出し・段落・画像・リンクなどの構造)。
- 見た目はCSS、動きはJSが担当。HTMLは“意味と構造”。
まずは雛形(コピペOK)
<!doctype html> <html lang="ja"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>はじめてのHTML</title> <meta name="description" content="このページの説明文" /> </head> <body> <h1>こんにちは!</h1> <p>これは最小構成のHTML5ページです。</p> </body> </html>
よく使う要素(超基本)
- 見出し:
<h1>~<h6>(ページに基本はh1は1つ) - 段落:
<p> - リンク:
<a href="https://example.com">リンク</a> - 画像:
<img src="img.png" alt="画像の説明">(alt必須) - リスト:
<ul><li>…</li></ul>/<ol>…</ol> - 強調:
<strong>(重要) /<em>(強調) - 区切り:
<br>(改行は最小限)、<hr>(区切り線) - まとまり:
<div>(汎用ブロック)、<span>(汎用インライン)
セマンティック要素(構造をわかりやすく)
header(ヘッダー)nav(ナビ)main(主内容は1ページ1つ)section(章)article(単体で完結する記事)aside(補足)footer(フッター)
属性のキホン
id(一意な識別子)/class(グループ化)href(リンク先)/src(画像・スクリプト元)alt(画像代替文)/title(補足ヒント)target="_blank"はrel="noopener noreferrer"とセットで
フォーム最小例
<form action="/search" method="get"> <label for="q">検索:</label> <input id="q" name="q" type="search" required> <button type="submit">送信</button> </form>
テーブル最小例(表)
<table>
<thead><tr><th>商品</th><th>価格</th></tr></thead>
<tbody>
<tr><td>りんご</td><td>120</td></tr>
<tr><td>みかん</td><td>100</td></tr>
</tbody>
</table>
CSS / JS の読み込み
<link rel="stylesheet" href="styles.css"> <script src="app.js" defer></script>
deferはHTML解析後に実行(推奨)。
ちょっとだけ“実践的”なサンプル
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ミニサイト</title>
<style>
body { font-family: system-ui, sans-serif; line-height: 1.7; margin: 0; }
header, footer { padding: 16px; background: #f5f5f5; }
nav a { margin-right: 12px; }
main { max-width: 920px; margin: 24px auto; padding: 0 16px; }
img { max-width: 100%; height: auto; }
.card { border: 1px solid #ddd; border-radius: 8px; padding: 16px; }
</style>
</head>
<body>
<header>
<h1>ミニサイト</h1>
<nav>
<a href="#about">概要</a>
<a href="#gallery">ギャラリー</a>
<a href="#contact">お問い合わせ</a>
</nav>
</header>
<main id="content">
<section id="about">
<h2>概要</h2>
<p>これはHTMLの基本で作ったミニページです。</p>
</section>
<section id="gallery">
<h2>ギャラリー</h2>
<div class="card">
<img src="sample.jpg" alt="サンプル画像">
<p>レスポンシブに画像が縮みます。</p>
</div>
<ul>
<li>箇条書き1</li>
<li>箇条書き2</li>
</ul>
</section>
<section id="contact">
<h2>お問い合わせ</h2>
<form>
<label for="name">お名前</label><br>
<input id="name" name="name" required><br><br>
<label for="msg">メッセージ</label><br>
<textarea id="msg" name="msg" rows="4"></textarea><br><br>
<button type="submit">送信</button>
</form>
</section>
</main>
<footer>
<small>© 2025 MiniSite</small>
</footer>
<script>
// ごく簡単なJS:ナビをクリックしたらスムーズスクロール
document.querySelectorAll('nav a').forEach(a => {
a.addEventListener('click', e => {
const id = a.getAttribute('href');
if (id.startsWith('#')) {
e.preventDefault();
document.querySelector(id)?.scrollIntoView({ behavior: 'smooth' });
}
});
});
</script>
</body>
</html>
初心者がつまずきやすいポイント
- 文字化け→
<meta charset="utf-8">を必ず入れる。 - スマホで拡大縮小が変→
<meta name="viewport" …>を入れる。 - 画像が大きすぎる→CSSで
img { max-width: 100%; height: auto; } - 見出し乱用→
h1はページの主題に1回、階層は順序を守る。 - altなし→スクリーンリーダー/SEO的にマイナス。必ず書く。
もっと深掘り(フォームのバリデーション、SEO、アクセシビリティ、Flex/Gridレイアウト、コンポーネント化など)もまとめられます。どこから強化したい?(例:フォームをしっかり、レイアウトを学ぶ、CSS設計、JS連携 など)
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>
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>
推しログ – 推し活情報共有サービス
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>推しログ - 推し活情報共有サービス</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body class="bg-gradient-to-r from-purple-200 to-indigo-200">
<!-- ヘッダー -->
<header class="bg-purple-600 text-white p-4 shadow-lg">
<div class="container mx-auto flex justify-between items-center">
<h1 class="text-3xl font-bold"><i class="fa-solid fa-heart mr-2"></i>推しログ</h1>
<nav class="space-x-4">
<a href="#" class="hover:text-gray-200">ホーム</a>
<a href="#" class="hover:text-gray-200">スケジュール</a>
<a href="#" class="hover:text-gray-200">ニュース</a>
<a href="#" class="hover:text-gray-200">ログイン</a>
</nav>
</div>
</header>
<!-- メインコンテンツ -->
<main class="container mx-auto my-8 px-4">
<!-- 推しスケジュール -->
<section class="bg-white p-8 rounded-xl shadow-xl mb-8">
<h2 class="text-2xl font-bold border-b pb-2 mb-4"><i class="fa-regular fa-calendar mr-2"></i>今月の推しスケジュール</h2>
<ul class="space-y-3">
<li class="flex justify-between items-center p-4 bg-gray-50 rounded-lg shadow">
<span class="font-semibold">ライブ配信「推しの部屋」</span>
<span class="text-gray-500">8/10(木) 20:00〜</span>
</li>
<li class="flex justify-between items-center p-4 bg-gray-50 rounded-lg shadow">
<span class="font-semibold">ニューシングル発売日!</span>
<span class="text-gray-500">8/15(火)</span>
</li>
<li class="flex justify-between items-center p-4 bg-gray-50 rounded-lg shadow">
<span class="font-semibold">ファンクラブ限定イベント</span>
<span class="text-gray-500">8/25(金) 18:30〜</span>
</li>
</ul>
<button class="mt-4 bg-purple-600 hover:bg-purple-700 text-white py-2 px-4 rounded">もっと見る</button>
</section>
<!-- 最新推しニュース -->
<section class="bg-white p-8 rounded-xl shadow-xl mb-8">
<h2 class="text-2xl font-bold border-b pb-2 mb-4"><i class="fa-solid fa-newspaper mr-2"></i>最新推しニュース</h2>
<article class="mb-6 border-b pb-4">
<h3 class="font-semibold text-purple-700 mb-1">推しの新曲MVが公開!</h3>
<p class="text-gray-600">待望の新曲MVが公式YouTubeチャンネルにて公開されました!視聴数も急上昇中!</p>
<a href="#" class="text-blue-500 hover:underline">詳しく見る <i class="fa-solid fa-arrow-right ml-1"></i></a>
</article>
<article>
<h3 class="font-semibold text-purple-700 mb-1">推し、テレビ番組に出演決定!</h3>
<p class="text-gray-600">8/12(土)の「音楽バズ」にゲスト出演予定です。特別トークもお楽しみに!</p>
<a href="#" class="text-blue-500 hover:underline">詳しく見る <i class="fa-solid fa-arrow-right ml-1"></i></a>
</article>
<button class="mt-4 bg-purple-600 hover:bg-purple-700 text-white py-2 px-4 rounded">もっと見る</button>
</section>
<!-- 推しメンバー紹介 -->
<section class="bg-white p-8 rounded-xl shadow-xl">
<h2 class="text-2xl font-bold border-b pb-2 mb-4"><i class="fa-solid fa-user-group mr-2"></i>推しメンバー紹介</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="text-center">
<img src="https://via.placeholder.com/150" class="rounded-full mx-auto mb-2" alt="推し1">
<h3 class="font-semibold">推しメン1</h3>
</div>
<div class="text-center">
<img src="https://via.placeholder.com/150" class="rounded-full mx-auto mb-2" alt="推し2">
<h3 class="font-semibold">推しメン2</h3>
</div>
<div class="text-center">
<img src="https://via.placeholder.com/150" class="rounded-full mx-auto mb-2" alt="推し3">
<h3 class="font-semibold">推しメン3</h3>
</div>
<div class="text-center">
<img src="https://via.placeholder.com/150" class="rounded-full mx-auto mb-2" alt="推し4">
<h3 class="font-semibold">推しメン4</h3>
</div>
</div>
</section>
</main>
<!-- フッター -->
<footer class="bg-gray-900 text-white text-center py-4 mt-8">
<p class="text-sm">© 2024 推しログ. All rights reserved.</p>
</footer>
</body>
</html>
