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 `
+
+ `
+ }).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}
+
+
+ `
+
+ 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; }
+}