From 80025a5515e6d23361152a9b9e34fc3a10b51a3e Mon Sep 17 00:00:00 2001 From: syui Date: Fri, 13 Mar 2026 03:46:22 +0900 Subject: [PATCH] add note --- src/web/components/mode-tabs.ts | 7 +- src/web/components/note.ts | 232 ++++++++++++++++++++++++++++++++ src/web/lib/router.ts | 18 ++- src/web/main.ts | 29 ++++ src/web/styles/main.css | 41 ++++++ 5 files changed, 325 insertions(+), 2 deletions(-) create mode 100644 src/web/components/note.ts diff --git a/src/web/components/mode-tabs.ts b/src/web/components/mode-tabs.ts index 1aadb46..fd9f56c 100644 --- a/src/web/components/mode-tabs.ts +++ b/src/web/components/mode-tabs.ts @@ -21,7 +21,7 @@ export function setCurrentLang(lang: string): void { localStorage.setItem('preferred-lang', lang) } -export function renderModeTabs(handle: string, activeTab: 'blog' | 'browser' | 'post' | 'chat' | 'link' = 'blog', isLocalUser: boolean = false): string { +export function renderModeTabs(handle: string, activeTab: 'blog' | 'browser' | 'post' | 'chat' | 'link' | 'note' = 'blog', isLocalUser: boolean = false): string { let tabs = ` / ${handle} @@ -33,6 +33,11 @@ export function renderModeTabs(handle: string, activeTab: 'blog' | 'browser' | ' tabs += `chat` } + // Note tab only for local user (admin) + if (isLocalUser) { + tabs += `note` + } + if (isLoggedIn()) { tabs += `post` tabs += `link` diff --git a/src/web/components/note.ts b/src/web/components/note.ts new file mode 100644 index 0000000..044da8f --- /dev/null +++ b/src/web/components/note.ts @@ -0,0 +1,232 @@ +import { renderMarkdown } from '../lib/markdown' +import type { Post } from '../types' + +// Note post has extra fields for member content +interface NotePost extends Post { + value: Post['value'] & { + member?: { + text: string + bonus?: string + } + } +} + +// Render note list page +export function renderNoteListPage(posts: NotePost[], handle: string): string { + if (posts.length === 0) { + return `
No note articles yet.
` + } + + const items = posts.map(post => { + const rkey = post.uri.split('/').pop() || '' + const date = new Date(post.value.publishedAt).toLocaleDateString('ja-JP', { + year: 'numeric', month: '2-digit', day: '2-digit' + }) + const tags = post.value.tags?.map(t => `${t}`).join('') || '' + + return ` +
+ + ${date} + ${escapeHtml(post.value.title)} + + ${tags ? `
${tags}
` : ''} +
+ ` + }).join('') + + return `
${items}
` +} + +// Render single note detail with preview + copy +export function renderNoteDetailPage( + post: NotePost, + _handle: string, + localOnly: boolean +): string { + const rkey = post.uri.split('/').pop() || '' + const date = new Date(post.value.publishedAt).toLocaleDateString('ja-JP', { + year: 'numeric', month: '2-digit', day: '2-digit' + }) + const freeText = post.value.content?.text || '' + const memberText = post.value.member?.text || '' + const bonusText = post.value.member?.bonus || '' + + const freeHtml = renderMarkdown(freeText) + const memberHtml = memberText ? renderMarkdown(memberText) : '' + const bonusHtml = bonusText ? renderMarkdown(bonusText) : '' + + let html = '' + + // Action buttons at top + if (localOnly) { + html += ` +
+ + + + +
+ ` + } + + html += ` +
+

${escapeHtml(post.value.title)}

+
${date}
+ +
+ +
${freeHtml}
+
+ ` + + if (memberText) { + html += ` +
── 有料ライン ──
+
+ +
${memberHtml}
+
+ ` + } + + if (bonusText) { + html += ` +
+ +
${bonusHtml}
+
+ ` + } + + html += `
` + + // Edit form (below content) + if (localOnly) { + html += ` + + ` + } + + return html +} + +// Setup note detail page (copy + edit handlers) +export function setupNoteDetail( + post: NotePost, + _onSave?: (rkey: string, record: Record) => Promise +): void { + const freeText = post.value.content?.text || '' + const memberText = post.value.member?.text || '' + const bonusText = post.value.member?.bonus || '' + + // Copy buttons + const copyTitle = document.getElementById('note-copy-title') + const copyAll = document.getElementById('note-copy-all') + const copyStatus = document.getElementById('note-copy-status') + + function copyToClipboard(text: string) { + navigator.clipboard.writeText(text).then(() => { + if (copyStatus) { + copyStatus.textContent = 'Copied!' + setTimeout(() => { copyStatus.textContent = '' }, 2000) + } + }) + } + + copyTitle?.addEventListener('click', () => copyToClipboard(post.value.title)) + copyAll?.addEventListener('click', () => { + const parts = [freeText, memberText, bonusText].filter(Boolean) + copyToClipboard(parts.join('\n\n')) + }) + + // Edit toggle + const editBtn = document.getElementById('note-edit-btn') + const editForm = document.getElementById('note-edit-form') + const display = document.getElementById('note-display') + const cancelBtn = document.getElementById('note-edit-cancel') + const saveBtn = document.getElementById('note-edit-save') + + editBtn?.addEventListener('click', () => { + if (display) display.style.display = 'none' + if (editForm) editForm.style.display = 'block' + if (editBtn) editBtn.style.display = 'none' + }) + + cancelBtn?.addEventListener('click', () => { + if (editForm) editForm.style.display = 'none' + if (display) display.style.display = '' + if (editBtn) editBtn.style.display = '' + }) + + saveBtn?.addEventListener('click', () => { + const rkey = saveBtn.getAttribute('data-rkey') + if (!rkey) return + + const title = (document.getElementById('note-edit-title') as HTMLInputElement)?.value.trim() + const free = (document.getElementById('note-edit-free') as HTMLTextAreaElement)?.value.trim() + const member = (document.getElementById('note-edit-member') as HTMLTextAreaElement)?.value.trim() + const bonus = (document.getElementById('note-edit-bonus') as HTMLTextAreaElement)?.value.trim() + + if (!title || !free) { + alert('Title and content are required') + return + } + + const did = post.uri.split('/')[2] + const record: Record = { + cid: '', + uri: post.uri, + value: { + $type: 'ai.syui.note.post', + site: post.value.site, + title, + content: { + $type: 'ai.syui.note.post#markdown', + text: free, + }, + publishedAt: post.value.publishedAt, + langs: post.value.langs || ['ja'], + tags: post.value.tags || [], + } as Record, + } + + if (member || bonus) { + const memberObj: Record = {} + if (member) memberObj.text = member + if (bonus) memberObj.bonus = bonus + ;(record.value as Record).member = memberObj + } + + const json = JSON.stringify(record, null, 2) + navigator.clipboard.writeText(json).then(() => { + const statusEl = document.getElementById('note-edit-status') + const filePath = `public/at/${did}/ai.syui.note.post/${rkey}.json` + if (statusEl) { + statusEl.innerHTML = `JSON copied! Paste to: ${filePath}` + } + }) + }) +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +} + +function escapeAttr(s: string): string { + return s.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') +} diff --git a/src/web/lib/router.ts b/src/web/lib/router.ts index 17b339c..3262def 100644 --- a/src/web/lib/router.ts +++ b/src/web/lib/router.ts @@ -1,5 +1,5 @@ export interface Route { - type: 'home' | 'user' | 'post' | 'postpage' | 'atbrowser' | 'service' | 'collection' | 'record' | 'chat' | 'chat-thread' | 'chat-edit' | 'card' | 'card-old' | 'rse' | 'link' | 'vrm' + type: 'home' | 'user' | 'post' | 'postpage' | 'atbrowser' | 'service' | 'collection' | 'record' | 'chat' | 'chat-thread' | 'chat-edit' | 'card' | 'card-old' | 'rse' | 'link' | 'vrm' | 'note' | 'note-detail' handle?: string rkey?: string service?: string @@ -81,6 +81,18 @@ export function parseRoute(): Route { return { type: 'vrm', handle: vrmMatch[1] } } + // Note detail: /@handle/at/note/{rkey} + const noteDetailMatch = path.match(/^\/@([^/]+)\/at\/note\/([^/]+)$/) + if (noteDetailMatch) { + return { type: 'note-detail', handle: noteDetailMatch[1], rkey: noteDetailMatch[2] } + } + + // Note list: /@handle/at/note + const noteMatch = path.match(/^\/@([^/]+)\/at\/note\/?$/) + if (noteMatch) { + return { type: 'note', handle: noteMatch[1] } + } + // Chat edit: /@handle/at/chat/{type}/{rkey}/edit const chatEditMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)\/([^/]+)\/edit$/) if (chatEditMatch) { @@ -141,6 +153,10 @@ export function navigate(route: Route): void { path = `/@${route.handle}/at/link` } else if (route.type === 'vrm' && route.handle) { path = `/@${route.handle}/at/vrm` + } else if (route.type === 'note' && route.handle) { + path = `/@${route.handle}/at/note` + } else if (route.type === 'note-detail' && route.handle && route.rkey) { + path = `/@${route.handle}/at/note/${route.rkey}` } window.history.pushState({}, '', path) diff --git a/src/web/main.ts b/src/web/main.ts index 4003ba4..e63196b 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -19,6 +19,7 @@ import { renderRsePage } from './components/rse' import { renderLinkPage, renderLinkButtons } from './components/link' import { renderVrmPage, setupVrmPage } from './components/vrm' import { checkMigrationStatus, renderMigrationPage, setupMigrationButton } from './components/card-migrate' +import { renderNoteListPage, renderNoteDetailPage, setupNoteDetail } from './components/note' import { showLoading, hideLoading } from './components/loading' const app = document.getElementById('app')! @@ -185,6 +186,7 @@ async function render(route: Route): Promise { const activeTab = route.type === 'postpage' ? 'post' : (route.type === 'chat' || route.type === 'chat-thread' || route.type === 'chat-edit') ? 'chat' : route.type === 'link' ? 'link' : + (route.type === 'note' || route.type === 'note-detail') ? 'note' : (route.type === 'atbrowser' || route.type === 'service' || route.type === 'collection' || route.type === 'record' ? 'browser' : 'blog') html += renderModeTabs(handle, activeTab, localOnly) @@ -290,6 +292,24 @@ async function render(route: Route): Promise { html += `
${renderVrmPage(vrmData, handle)}
` html += `` + } else if (route.type === 'note') { + // Note list page + const noteCollection = 'ai.syui.note.post' + const notePosts = await getPosts(did, noteCollection, localOnly) + html += `
${renderNoteListPage(notePosts, handle)}
` + html += `` + + } else if (route.type === 'note-detail' && route.rkey) { + // Note detail page + const noteCollection = 'ai.syui.note.post' + const notePost = await getPost(did, noteCollection, route.rkey, localOnly) + if (notePost) { + html += `
${renderNoteDetailPage(notePost, handle, localOnly)}
` + } else { + html += `
Note not found
` + } + html += `` + } else if (route.type === 'chat') { // Chat list page - show all chat collections if (!config.bot) { @@ -464,6 +484,15 @@ async function render(route: Route): Promise { setupRecordMerge() } + // Setup note detail page + if (route.type === 'note-detail' && route.rkey) { + const noteCollection = 'ai.syui.note.post' + const notePost = await getPost(did, noteCollection, route.rkey, localOnly) + if (notePost && localOnly) { + setupNoteDetail(notePost) + } + } + // Setup VRM page audio controls if (route.type === 'vrm') { setupVrmPage() diff --git a/src/web/styles/main.css b/src/web/styles/main.css index 7a51daf..44f1276 100644 --- a/src/web/styles/main.css +++ b/src/web/styles/main.css @@ -3053,3 +3053,44 @@ button.tab { border-top-color: #444; } } + +/* Note styles */ +.note-list { margin: 1rem 0; } +.note-item { padding: 0.5rem 0; border-bottom: 1px solid #eee; } +.note-link { display: flex; gap: 1rem; text-decoration: none; color: inherit; } +.note-link:hover { opacity: 0.7; } +.note-date { color: #888; font-size: 0.85rem; white-space: nowrap; } +.note-title { font-weight: 500; } +.note-tags { margin-top: 0.25rem; } +.note-tag { font-size: 0.75rem; color: #666; background: #f0f0f0; padding: 0.1rem 0.4rem; border-radius: 3px; margin-right: 0.25rem; } +.note-empty { color: #888; padding: 2rem 0; text-align: center; } + +.note-detail { margin: 1rem 0; } +.note-detail-title { font-size: 1.4rem; margin-bottom: 0.25rem; } +.note-detail-meta { color: #888; font-size: 0.85rem; margin-bottom: 1.5rem; } +.note-section { margin-bottom: 1.5rem; } +.note-section-label { font-size: 0.8rem; color: #999; border-bottom: 1px solid #eee; padding-bottom: 0.25rem; margin-bottom: 0.75rem; } +.note-content { line-height: 1.8; } +.note-paywall { text-align: center; color: #c0a060; margin: 2rem 0; font-size: 0.9rem; } + +.note-actions { display: flex; gap: 0.5rem; margin: 1rem 0; flex-wrap: wrap; align-items: center; } +.note-copy-btn { font-size: 0.8rem; padding: 0.3rem 0.8rem; background: #f5f5f5; border: 1px solid #ddd; border-radius: 4px; cursor: pointer; } +.note-copy-btn:hover { background: #e8e8e8; } +.note-copy-status { font-size: 0.8rem; color: #4a4; } + +.note-edit { margin: 1rem 0; } +.note-edit-input { width: 100%; padding: 0.5rem; font-size: 1rem; border: 1px solid #ddd; border-radius: 4px; margin-bottom: 0.5rem; box-sizing: border-box; } +.note-edit-label { display: block; font-size: 0.8rem; color: #888; margin: 0.75rem 0 0.25rem; } +.note-edit-textarea { width: 100%; padding: 0.5rem; font-family: monospace; font-size: 0.85rem; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box; } +.note-edit-actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; } +.note-edit-actions button { padding: 0.3rem 1rem; border: 1px solid #ddd; border-radius: 4px; cursor: pointer; } + +@media (prefers-color-scheme: dark) { + .note-item { border-bottom-color: #333; } + .note-tag { background: #333; color: #aaa; } + .note-section-label { border-bottom-color: #333; } + .note-copy-btn { background: #2a2a2a; border-color: #444; color: #ccc; } + .note-copy-btn:hover { background: #333; } + .note-edit-input, .note-edit-textarea { background: #1a1a1a; color: #eee; border-color: #444; } + .note-edit-actions button { background: #2a2a2a; border-color: #444; color: #ccc; } +}