posimai-log/index.html

713 lines
27 KiB
HTML
Raw Normal View History

<!DOCTYPE html>
<html lang="ja" data-app-id="posimai-log">
<head>
<meta charset="UTF-8">
<meta name="robots" content="noindex, nofollow">
<script>
(function () {
var t = localStorage.getItem('posimai-log-theme') || 'system';
var dark = t === 'dark' || (t === 'system' && matchMedia('(prefers-color-scheme:dark)').matches);
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme-pref', t);
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="description" content="Architect without Code — AI駆動開発の軌跡">
<meta name="color-scheme" content="dark light">
<meta name="theme-color" content="#0D0D0D" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#F9FAFB" media="(prefers-color-scheme: light)">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="posimai log">
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" href="/logo.png">
<link rel="apple-touch-icon" href="/logo.png">
<title>posimai log</title>
<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=Geist:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://posimai-ui.vercel.app/v1/base.css">
<script src="https://unpkg.com/lucide@0.344.0/dist/umd/lucide.min.js" integrity="sha384-tTkFttkBclaU1cloKwOi9xk3pbao3VZxTjLNBt8iFABWDBQibbAbWpVmO28zMuxq" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/marked@4.3.0/marked.min.js" integrity="sha384-QsSpx6a0USazT7nK7w8qXDgpSAPhFsb2XtpoLFQ5+X2yFN6hvCKnwEzN8M5FWaJb" crossorigin="anonymous"></script>
<style>
/* ── Layout ── */
body { overflow: hidden; }
.app-shell {
display: grid;
grid-template-rows: 48px 1fr;
height: 100dvh;
}
.pane-wrap {
display: grid;
grid-template-columns: 260px 1fr;
overflow: hidden;
}
/* ── Sidebar ── */
.sidebar {
border-right: 1px solid var(--border);
overflow-y: auto;
padding: 12px 0;
}
.sidebar-label {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 500;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--text3);
padding: 0 16px 8px;
}
.post-item {
display: block;
padding: 10px 16px;
cursor: pointer;
border-left: 2px solid transparent;
transition: background .12s, border-color .12s;
text-align: left;
width: 100%;
background: none;
border-top: none;
border-right: none;
border-bottom: none;
}
.post-item:hover { background: var(--surface2); }
.post-item.active {
border-left-color: var(--accent);
background: var(--surface2);
}
.post-item-date {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--accent);
margin-bottom: 3px;
}
.post-item-title {
font-size: 13px;
font-weight: 500;
color: var(--text1);
line-height: 1.4;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.sidebar-empty {
padding: 24px 16px;
font-size: 13px;
color: var(--text3);
line-height: 1.6;
}
.sidebar-empty code {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
background: var(--surface2);
padding: 1px 5px;
border-radius: 4px;
color: var(--accent);
}
/* ── Reader ── */
.reader {
overflow-y: auto;
padding: 40px 48px;
}
.reader-empty {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text3);
}
.reader-empty-label {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
letter-spacing: .06em;
}
.post-meta {
margin-bottom: 32px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border);
}
.post-meta-date {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--accent);
margin-bottom: 8px;
}
.post-meta-title {
font-size: 22px;
font-weight: 600;
color: var(--text1);
line-height: 1.35;
}
/* hidden 属性が display:flex に上書きされるのを防ぐ */
[hidden] { display: none !important; }
/* ── Markdown content ── */
.md-body { max-width: 680px; }
.md-body h1 { display: none; } /* h1 is shown in post-meta */
.md-body h2 {
font-size: 17px;
font-weight: 600;
color: var(--text1);
margin: 32px 0 12px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
}
.md-body h3 {
font-size: 15px;
font-weight: 600;
color: var(--text1);
margin: 24px 0 8px;
}
.md-body p {
font-size: 14px;
line-height: 1.8;
color: var(--text2);
margin-bottom: 16px;
}
.md-body ul, .md-body ol {
font-size: 14px;
line-height: 1.8;
color: var(--text2);
margin-bottom: 16px;
padding-left: 20px;
}
.md-body li { margin-bottom: 4px; }
.md-body strong { color: var(--text1); font-weight: 600; }
.md-body em { color: var(--text2); }
.md-body code {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
background: var(--surface2);
color: var(--accent);
padding: 2px 6px;
border-radius: 4px;
}
.md-body pre {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
overflow-x: auto;
margin-bottom: 16px;
}
.md-body pre code {
background: none;
padding: 0;
color: var(--text2);
font-size: 12px;
}
.md-body blockquote {
border-left: 3px solid var(--accent);
margin: 0 0 16px;
padding: 8px 16px;
background: var(--surface2);
border-radius: 0 6px 6px 0;
}
.md-body blockquote p { margin-bottom: 0; color: var(--text2); }
.md-body hr {
border: none;
border-top: 1px solid var(--border);
margin: 24px 0;
}
.md-body a {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 3px;
}
.md-body a:hover { opacity: .8; }
/* ── Diary panel ── */
.diary-panel {
position: fixed;
top: 0; right: 0;
width: 380px;
height: 100dvh;
background: var(--surface);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
z-index: 200;
transform: translateX(100%);
transition: transform .22s cubic-bezier(.4,0,.2,1);
}
.diary-panel.open { transform: translateX(0); }
.diary-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
height: 48px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.diary-panel-title {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 500;
letter-spacing: .1em;
color: var(--text1);
display: flex;
align-items: center;
gap: 8px;
}
.diary-status-dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--text3);
transition: background .3s;
flex-shrink: 0;
}
.diary-status-dot.connected { background: var(--accent); }
.diary-status-dot.pending { background: #FCD34D; }
.diary-textarea {
flex: 1;
width: 100%;
resize: none;
border: none;
outline: none;
background: transparent;
color: var(--text1);
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
line-height: 1.75;
padding: 20px;
box-sizing: border-box;
}
.diary-textarea::placeholder { color: var(--text3); }
.diary-footer {
display: flex;
flex-direction: column;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.diary-apikey-row {
display: none;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-bottom: 1px solid var(--border);
}
.diary-apikey-row.visible { display: flex; }
.diary-apikey-input {
flex: 1;
background: transparent;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text1);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
padding: 4px 8px;
outline: none;
}
.diary-apikey-input:focus { border-color: var(--accent); }
.diary-footer-main {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
}
.diary-sync-status {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--text3);
}
.diary-sync-status.saved { color: var(--accent); }
.diary-sync-status.pending { color: #FCD34D; }
.diary-action-btn {
display: none;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 4px;
border: 1px solid var(--border);
background: transparent;
color: var(--text3);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
cursor: pointer;
transition: color .2s, border-color .2s;
}
.diary-action-btn:hover { color: var(--text1); border-color: var(--text2); }
.diary-action-btn.visible { display: inline-flex; }
@media (max-width: 640px) {
.diary-panel { width: 100%; }
}
/* ── Mobile ── */
@media (max-width: 640px) {
.pane-wrap { grid-template-columns: 1fr; }
.sidebar { display: block; }
.reader { padding: 24px 20px; }
.reader.hidden-mobile { display: none; }
.sidebar.hidden-mobile { display: none; }
.back-btn {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--accent);
background: none;
border: none;
padding: 0;
margin-bottom: 20px;
cursor: pointer;
}
}
@media (min-width: 641px) {
.back-btn { display: none; }
}
</style>
</head>
<body>
<a href="#main-content" class="skip-link" tabindex="0" style="position:absolute;top:-100%;left:8px;background:var(--accent);color:#0D0D0D;padding:8px 16px;border-radius:8px;font-weight:600;font-size:13px;z-index:10000;text-decoration:none">コンテンツへスキップ</a>
<!-- Settings panel -->
<aside class="settings-panel" id="settingsPanel" role="complementary">
<div class="settings-panel-header">
<span class="settings-panel-title">設定</span>
<button class="icon-btn" id="settingsCloseBtn" aria-label="設定を閉じる">
<i data-lucide="x" style="width:18px;height:18px;stroke-width:1.75"></i>
</button>
</div>
<div class="settings-panel-body">
<div>
<div class="settings-group-label">外観</div>
<div class="settings-item">
<div class="settings-item-label">テーマ</div>
<div class="theme-selector">
<button class="theme-btn" data-theme-val="dark"><i data-lucide="moon" style="width:12px;height:12px;stroke-width:1.75"></i>ダーク</button>
<button class="theme-btn" data-theme-val="light"><i data-lucide="sun" style="width:12px;height:12px;stroke-width:1.75"></i>ライト</button>
<button class="theme-btn" data-theme-val="system"><i data-lucide="monitor" style="width:12px;height:12px;stroke-width:1.75"></i>自動</button>
</div>
</div>
</div>
</div>
</aside>
<div class="overlay" id="overlay" aria-hidden="true"></div>
<div class="app-shell">
<header class="header">
<div class="header-brand">
<div class="header-dot" aria-hidden="true"></div>
<span class="header-title">posimai log</span>
</div>
<div style="display:flex;gap:4px;align-items:center;">
<button class="icon-btn" id="diaryBtn" aria-label="ダイアリー" aria-expanded="false">
<i data-lucide="pencil-line" style="width:18px;height:18px;stroke-width:1.5"></i>
</button>
<button class="icon-btn" id="settingsBtn" aria-label="設定" aria-expanded="false">
<i data-lucide="settings" style="width:18px;height:18px;stroke-width:1.5"></i>
</button>
</div>
</header>
<div class="pane-wrap" id="main-content">
<!-- Sidebar: post list -->
<nav class="sidebar" id="sidebar" aria-label="記事一覧">
<div class="sidebar-label">log entries</div>
<div id="postList">
<div class="sidebar-empty">
ログを読み込み中...
</div>
</div>
</nav>
<!-- Main: reader -->
<main class="reader" id="reader">
<div class="reader-empty" id="readerEmpty">
<i data-lucide="terminal" style="width:32px;height:32px;stroke-width:1.25;opacity:.4"></i>
<span class="reader-empty-label">select a log entry</span>
</div>
<article id="readerContent" hidden>
<button class="back-btn" id="backBtn" aria-label="一覧に戻る">
<i data-lucide="arrow-left" style="width:14px;height:14px;stroke-width:2"></i>
一覧に戻る
</button>
<div class="post-meta">
<div class="post-meta-date" id="postDate"></div>
<div class="post-meta-title" id="postTitle"></div>
</div>
<div class="md-body" id="postBody"></div>
</article>
</main>
</div>
</div>
<!-- Diary panel -->
<aside class="diary-panel" id="diaryPanel" aria-label="ダイアリー">
<div class="diary-panel-header">
<div class="diary-panel-title">
<span class="diary-status-dot" id="diaryDot"></span>
diary
</div>
<button class="icon-btn" id="diaryCloseBtn" aria-label="ダイアリーを閉じる">
<i data-lucide="x" style="width:18px;height:18px;stroke-width:1.75"></i>
</button>
</div>
<textarea
class="diary-textarea"
id="diaryTextarea"
placeholder="今日の一言をどうぞ。generate の前に書けば記事に反映されます。"
spellcheck="false"
></textarea>
<div class="diary-footer">
<div class="diary-apikey-row" id="diaryApiKeyRow">
<input type="password" class="diary-apikey-input" id="diaryApiKeyInput" placeholder="posimai API key">
<button class="diary-action-btn visible" id="diaryApiKeySave" style="display:inline-flex;">set</button>
</div>
<div class="diary-footer-main">
<span class="diary-sync-status" id="diarySyncStatus">---</span>
<div style="display:flex;align-items:center;gap:8px;">
<button class="diary-action-btn" id="diaryReconnectBtn" aria-label="再試行">
<i data-lucide="refresh-cw" style="width:10px;height:10px;stroke-width:2"></i>
retry
</button>
<span style="font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--text3);" id="diaryCharCount">0 chars</span>
</div>
</div>
</div>
</aside>
<div id="toast" role="status" aria-live="polite"></div>
<script src="https://posimai-ui.vercel.app/v1/base.js" defer></script>
<script>
(function () {
const postList = document.getElementById('postList');
const readerEmpty = document.getElementById('readerEmpty');
const readerContent = document.getElementById('readerContent');
const postDate = document.getElementById('postDate');
const postTitle = document.getElementById('postTitle');
const postBody = document.getElementById('postBody');
const sidebar = document.getElementById('sidebar');
const reader = document.getElementById('reader');
const backBtn = document.getElementById('backBtn');
let posts = [];
// --- Fetch index ---
fetch('/posts/index.json')
.then(r => r.ok ? r.json() : [])
.then(data => {
posts = data;
renderList(posts);
})
.catch(() => {
postList.innerHTML = '<div class="sidebar-empty">記事がまだありません。<br><code>npm run generate</code> で生成してください。</div>';
});
function renderList(items) {
if (!items.length) {
postList.innerHTML = '<div class="sidebar-empty">記事がまだありません。<br><code>npm run generate</code> で生成後、<code>posts/</code> に配置してください。</div>';
return;
}
postList.innerHTML = '';
items.forEach(p => {
const btn = document.createElement('button');
btn.className = 'post-item';
btn.dataset.slug = p.slug;
btn.innerHTML = `<div class="post-item-date">${p.date}</div><div class="post-item-title">${p.title}</div>`;
btn.addEventListener('click', () => loadPost(p));
postList.appendChild(btn);
});
}
function loadPost(p) {
// Update active state
document.querySelectorAll('.post-item').forEach(el => el.classList.toggle('active', el.dataset.slug === p.slug));
// Mobile: hide sidebar, show reader
sidebar.classList.add('hidden-mobile');
reader.classList.remove('hidden-mobile');
// Show loading state
readerEmpty.hidden = true;
readerContent.hidden = false;
postDate.textContent = p.date;
postTitle.textContent = p.title || '';
postBody.innerHTML = '<p style="color:var(--text3);font-size:13px;">読み込み中...</p>';
fetch(`/posts/${p.slug}.md`)
.then(r => r.ok ? r.text() : Promise.reject('not found'))
.then(md => {
// Extract title from first h1 if present, use it to update postTitle
const h1Match = md.match(/^#\s+(.+)$/m);
if (h1Match) postTitle.textContent = h1Match[1];
postBody.innerHTML = marked.parse(md);
// Security: ensure external links are safe
postBody.querySelectorAll('a[href^="http"]').forEach(a => {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
});
})
.catch(() => {
postBody.innerHTML = '<p style="color:var(--text3);font-size:13px;">記事の読み込みに失敗しました。</p>';
});
}
// Back button (mobile)
backBtn.addEventListener('click', () => {
sidebar.classList.remove('hidden-mobile');
reader.classList.add('hidden-mobile');
});
// Register SW
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
// ── Diary panel ──────────────────────────────────────────
const DIARY_GET_URL = 'https://api.soar-enrich.com/brain/api/site/config/public?user=maita';
const DIARY_POST_URL = 'https://api.soar-enrich.com/brain/api/site/config/diary_content';
const DIARY_LS_KEY = 'posimai-log-diary';
const APIKEY_LS_KEY = 'posimai_api_key';
const diaryPanel = document.getElementById('diaryPanel');
const diaryBtn = document.getElementById('diaryBtn');
const diaryCloseBtn = document.getElementById('diaryCloseBtn');
const diaryTextarea = document.getElementById('diaryTextarea');
const diaryDot = document.getElementById('diaryDot');
const diarySyncStatus = document.getElementById('diarySyncStatus');
const diaryCharCount = document.getElementById('diaryCharCount');
const diaryReconnectBtn = document.getElementById('diaryReconnectBtn');
const diaryApiKeyRow = document.getElementById('diaryApiKeyRow');
const diaryApiKeyInput = document.getElementById('diaryApiKeyInput');
const diaryApiKeySave = document.getElementById('diaryApiKeySave');
let saveTimer = null;
function getApiKey() { return localStorage.getItem(APIKEY_LS_KEY) || ''; }
function setDiaryStatus(state, text) {
diarySyncStatus.className = 'diary-sync-status ' + state;
diarySyncStatus.textContent = text;
diaryDot.className = 'diary-status-dot' +
(state === 'saved' ? ' connected' : state === 'pending' ? ' pending' : '');
diaryReconnectBtn.classList.toggle('visible', state === 'error');
if (!getApiKey()) diaryApiKeyRow.classList.add('visible');
}
async function fetchDiary() {
const res = await fetch(DIARY_GET_URL, { signal: AbortSignal.timeout(4000) });
if (!res.ok) throw new Error('fetch failed');
const data = await res.json();
return data.config?.diary_content || '';
}
async function saveDiary(content) {
const key = getApiKey();
if (!key) throw Object.assign(new Error('no key'), { code: 'nokey' });
const res = await fetch(DIARY_POST_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + key },
body: JSON.stringify({ value: content }),
signal: AbortSignal.timeout(5000)
});
if (!res.ok) throw new Error('save failed');
}
async function openDiary() {
diaryPanel.classList.add('open');
diaryBtn.setAttribute('aria-expanded', 'true');
diaryApiKeyRow.classList.toggle('visible', !getApiKey());
setDiaryStatus('', 'loading...');
try {
const content = await fetchDiary();
diaryTextarea.value = content;
localStorage.setItem(DIARY_LS_KEY, content);
setDiaryStatus('saved', 'synced');
} catch {
diaryTextarea.value = localStorage.getItem(DIARY_LS_KEY) || '';
setDiaryStatus('pending', 'offline — saved locally');
}
diaryCharCount.textContent = diaryTextarea.value.length + ' chars';
diaryTextarea.focus();
}
function closeDiary() {
diaryPanel.classList.remove('open');
diaryBtn.setAttribute('aria-expanded', 'false');
}
diaryTextarea.addEventListener('input', () => {
const content = diaryTextarea.value;
diaryCharCount.textContent = content.length + ' chars';
localStorage.setItem(DIARY_LS_KEY, content);
setDiaryStatus('pending', 'saving...');
clearTimeout(saveTimer);
saveTimer = setTimeout(async () => {
try {
await saveDiary(content);
setDiaryStatus('saved', 'saved');
} catch (e) {
if (e.code === 'nokey') {
setDiaryStatus('', 'set API key to sync');
diaryApiKeyRow.classList.add('visible');
} else {
setDiaryStatus('pending', 'offline — saved locally');
}
}
}, 1000);
});
diaryReconnectBtn.addEventListener('click', async () => {
setDiaryStatus('', 'retrying...');
try {
const content = await fetchDiary();
diaryTextarea.value = content;
localStorage.setItem(DIARY_LS_KEY, content);
setDiaryStatus('saved', 'synced');
diaryCharCount.textContent = content.length + ' chars';
} catch {
setDiaryStatus('error', 'still offline');
}
});
diaryApiKeySave.addEventListener('click', async () => {
const val = diaryApiKeyInput.value.trim();
if (!val) return;
localStorage.setItem(APIKEY_LS_KEY, val);
diaryApiKeyInput.value = '';
diaryApiKeyRow.classList.remove('visible');
setDiaryStatus('', 'connecting...');
try {
const content = await fetchDiary();
diaryTextarea.value = content;
localStorage.setItem(DIARY_LS_KEY, content);
setDiaryStatus('saved', 'synced');
diaryCharCount.textContent = content.length + ' chars';
} catch {
setDiaryStatus('pending', 'offline — saved locally');
}
});
diaryBtn.addEventListener('click', openDiary);
diaryCloseBtn.addEventListener('click', closeDiary);
document.getElementById('overlay').addEventListener('click', closeDiary);
})();
</script>
</body>
</html>