1
0
This commit is contained in:
2026-03-13 03:46:22 +09:00
parent db2cc542b3
commit 80025a5515
5 changed files with 325 additions and 2 deletions

View File

@@ -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 = `
<a href="/" class="tab">/</a>
<a href="/@${handle}" class="tab ${activeTab === 'blog' ? 'active' : ''}">${handle}</a>
@@ -33,6 +33,11 @@ export function renderModeTabs(handle: string, activeTab: 'blog' | 'browser' | '
tabs += `<a href="/@${handle}/at/chat" class="tab ${activeTab === 'chat' ? 'active' : ''}">chat</a>`
}
// Note tab only for local user (admin)
if (isLocalUser) {
tabs += `<a href="/@${handle}/at/note" class="tab ${activeTab === 'note' ? 'active' : ''}">note</a>`
}
if (isLoggedIn()) {
tabs += `<a href="/@${handle}/at/post" class="tab ${activeTab === 'post' ? 'active' : ''}">post</a>`
tabs += `<a href="/@${handle}/at/link" class="tab ${activeTab === 'link' ? 'active' : ''}">link</a>`

232
src/web/components/note.ts Normal file
View File

@@ -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 `<div class="note-empty">No note articles yet.</div>`
}
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 => `<span class="note-tag">${t}</span>`).join('') || ''
return `
<div class="note-item">
<a href="/@${handle}/at/note/${rkey}" class="note-link">
<span class="note-date">${date}</span>
<span class="note-title">${escapeHtml(post.value.title)}</span>
</a>
${tags ? `<div class="note-tags">${tags}</div>` : ''}
</div>
`
}).join('')
return `<div class="note-list">${items}</div>`
}
// 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 += `
<div class="note-actions">
<button type="button" class="note-copy-btn" id="note-copy-title">Copy Title</button>
<button type="button" class="note-copy-btn" id="note-copy-all">Copy 全文</button>
<button type="button" class="note-copy-btn" id="note-edit-btn">Edit</button>
<span id="note-copy-status" class="note-copy-status"></span>
</div>
`
}
html += `
<div class="note-detail" id="note-display">
<h2 class="note-detail-title">${escapeHtml(post.value.title)}</h2>
<div class="note-detail-meta">${date}</div>
<div class="note-section">
<div class="note-section-label">本文(無料)</div>
<div class="note-content">${freeHtml}</div>
</div>
`
if (memberText) {
html += `
<div class="note-paywall">── 有料ライン ──</div>
<div class="note-section">
<div class="note-section-label">答えと核心(有料)</div>
<div class="note-content">${memberHtml}</div>
</div>
`
}
if (bonusText) {
html += `
<div class="note-section">
<div class="note-section-label">今日のひとこま(おまけ)</div>
<div class="note-content">${bonusHtml}</div>
</div>
`
}
html += `</div>`
// Edit form (below content)
if (localOnly) {
html += `
<div class="note-edit" id="note-edit-form" style="display:none">
<input type="text" id="note-edit-title" class="note-edit-input" value="${escapeAttr(post.value.title)}" placeholder="Title">
<label class="note-edit-label">本文(無料)</label>
<textarea id="note-edit-free" class="note-edit-textarea" rows="10">${escapeHtml(freeText)}</textarea>
<label class="note-edit-label">答えと核心(有料)</label>
<textarea id="note-edit-member" class="note-edit-textarea" rows="8">${escapeHtml(memberText)}</textarea>
<label class="note-edit-label">今日のひとこま(おまけ)</label>
<textarea id="note-edit-bonus" class="note-edit-textarea" rows="5">${escapeHtml(bonusText)}</textarea>
<div class="note-edit-actions">
<button type="button" id="note-edit-save" data-rkey="${rkey}">Copy JSON</button>
<button type="button" id="note-edit-cancel">Cancel</button>
</div>
<div id="note-edit-status"></div>
</div>
`
}
return html
}
// Setup note detail page (copy + edit handlers)
export function setupNoteDetail(
post: NotePost,
_onSave?: (rkey: string, record: Record<string, unknown>) => Promise<void>
): 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<string, unknown> = {
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<string, unknown>,
}
if (member || bonus) {
const memberObj: Record<string, string> = {}
if (member) memberObj.text = member
if (bonus) memberObj.bonus = bonus
;(record.value as Record<string, unknown>).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 = `<span class="note-copy-status">JSON copied! Paste to: <code>${filePath}</code></span>`
}
})
})
}
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
function escapeAttr(s: string): string {
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}

View File

@@ -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)

View File

@@ -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<void> {
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<void> {
html += `<div id="content">${renderVrmPage(vrmData, handle)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else if (route.type === 'note') {
// Note list page
const noteCollection = 'ai.syui.note.post'
const notePosts = await getPosts(did, noteCollection, localOnly)
html += `<div id="content">${renderNoteListPage(notePosts, handle)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} 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 += `<div id="content">${renderNoteDetailPage(notePost, handle, localOnly)}</div>`
} else {
html += `<div id="content" class="error">Note not found</div>`
}
html += `<nav class="back-nav"><a href="/@${handle}/at/note">note</a></nav>`
} 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<void> {
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()

View File

@@ -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; }
}