rspace-online/modules/rnotes/components/folk-notes-app.ts

633 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* <folk-notes-app> — vault browser for rNotes.
*
* Replaces the old TipTap editor with a read-only vault sync browser.
* Obsidian / Logseq vaults are uploaded as ZIP files, parsed server-side,
* and stored in Automerge + on-disk. This component is the browse UI.
*
* Left sidebar : vault list (fetches /api/vault/list?space=)
* Center panel : file tree (fetches /api/vault/:id/notes?space=)
* Right panel : MD preview (fetches /api/vault/:id/note/:path?space=)
*/
import type { VaultMeta, VaultNoteMeta } from '../schemas';
// ── Tiny markdown → HTML converter (no dependencies) ─────────────────────────
function renderMarkdown(md: string): string {
// Escape HTML in code blocks first, then restore placeholders
const codeBlocks: string[] = [];
let html = md.replace(/```[\s\S]*?```/g, (m) => {
const lang = m.match(/^```(\w*)/)?.[1] ?? '';
const code = m.replace(/^```\w*\n?/, '').replace(/\n?```$/, '');
const escaped = code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
codeBlocks.push(`<pre><code class="language-${lang}">${escaped}</code></pre>`);
return `\x00CB${codeBlocks.length - 1}\x00`;
});
// Inline code
const inlineCodes: string[] = [];
html = html.replace(/`([^`\n]+)`/g, (_, c) => {
const escaped = c.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
inlineCodes.push(`<code>${escaped}</code>`);
return `\x00IC${inlineCodes.length - 1}\x00`;
});
// Wikilinks [[target]] or [[target|alias]]
html = html.replace(/\[\[([^\]]+)\]\]/g, (_, inner) => {
const [target, alias] = inner.split('|');
const label = alias ?? target;
return `<a class="wikilink" data-target="${target.trim()}" href="#">${label.trim()}</a>`;
});
// Images ![alt](url)
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img alt="$1" src="$2" style="max-width:100%">');
// Links [text](url)
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
// Process line-by-line
const lines = html.split('\n');
const out: string[] = [];
let inUl = false;
let inOl = false;
let inBlockquote = false;
function closeList() {
if (inUl) { out.push('</ul>'); inUl = false; }
if (inOl) { out.push('</ol>'); inOl = false; }
}
function closeBlockquote() {
if (inBlockquote) { out.push('</blockquote>'); inBlockquote = false; }
}
for (const raw of lines) {
const line = raw;
// Headings
const hm = line.match(/^(#{1,6})\s+(.+)/);
if (hm) {
closeList(); closeBlockquote();
const level = hm[1].length;
out.push(`<h${level}>${inlineFormat(hm[2])}</h${level}>`);
continue;
}
// Blockquote
if (line.startsWith('> ')) {
closeList();
if (!inBlockquote) { out.push('<blockquote>'); inBlockquote = true; }
out.push(`<p>${inlineFormat(line.slice(2))}</p>`);
continue;
} else if (inBlockquote && line.trim() === '') {
closeBlockquote();
}
// HR
if (/^[-*_]{3,}$/.test(line.trim())) {
closeList(); closeBlockquote();
out.push('<hr>');
continue;
}
// Unordered list
const ulm = line.match(/^(\s*)[-*+]\s+(.+)/);
if (ulm) {
closeBlockquote();
if (!inUl) { out.push('<ul>'); inUl = true; }
out.push(`<li>${inlineFormat(ulm[2])}</li>`);
continue;
}
// Ordered list
const olm = line.match(/^\d+\.\s+(.+)/);
if (olm) {
closeBlockquote();
if (!inOl) { out.push('<ol>'); inOl = true; }
out.push(`<li>${inlineFormat(olm[1])}</li>`);
continue;
}
// Blank line
if (line.trim() === '') {
closeList(); closeBlockquote();
out.push('<br>');
continue;
}
// Paragraph
closeList(); closeBlockquote();
out.push(`<p>${inlineFormat(line)}</p>`);
}
closeList(); closeBlockquote();
let result = out.join('\n');
// Restore placeholders
result = result.replace(/\x00CB(\d+)\x00/g, (_, i) => codeBlocks[Number(i)]);
result = result.replace(/\x00IC(\d+)\x00/g, (_, i) => inlineCodes[Number(i)]);
return result;
}
function inlineFormat(s: string): string {
return s
.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/__(.+?)__/g, '<strong>$1</strong>')
.replace(/_(.+?)_/g, '<em>$1</em>')
.replace(/~~(.+?)~~/g, '<del>$1</del>');
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function timeAgo(ts: number): string {
const sec = Math.floor((Date.now() - ts) / 1000);
if (sec < 60) return 'just now';
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
return `${Math.floor(sec / 86400)}d ago`;
}
function escHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── Icons (inline SVG, 16×16, stroke-based) ──────────────────────────────────
const ICON_OBSIDIAN = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><polygon points="7,1 13,4 13,10 7,13 1,10 1,4" stroke="#a78bfa" stroke-width="1.5" fill="rgba(167,139,250,0.15)"/><polygon points="7,3 10,5 9,9 7,11 5,9 4,5" fill="#a78bfa" opacity="0.6"/></svg>`;
const ICON_LOGSEQ = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="5.5" stroke="#60a5fa" stroke-width="1.5"/><circle cx="7" cy="7" r="2.5" fill="#60a5fa" opacity="0.7"/></svg>`;
const ICON_FOLDER = `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h6a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>`;
const ICON_FILE = `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 1h6l4 4v10H2V1zM10 1v4h4"/></svg>`;
const ICON_UPLOAD = `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><polyline points="4 6 8 2 12 6"/><line x1="8" y1="2" x2="8" y2="11"/><path d="M2 14h12"/></svg>`;
const ICON_SEARCH = `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="6.5" cy="6.5" r="4"/><line x1="10" y1="10" x2="14" y2="14"/></svg>`;
const ICON_CHEVRON = `<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><polyline points="3 4.5 6 7.5 9 4.5"/></svg>`;
const ICON_SYNC = `<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2 8a6 6 0 1 1 1.5 4"/><polyline points="2 13 2 8 7 8"/></svg>`;
// ── Web Component ─────────────────────────────────────────────────────────────
export class FolkNotesApp extends HTMLElement {
// Observed attributes
static get observedAttributes() { return ['space', 'module-id']; }
// State
private _space = '';
private _moduleId = 'rnotes';
private _vaults: VaultMeta[] = [];
private _notes: VaultNoteMeta[] = [];
private _selectedVaultId: string | null = null;
private _selectedNotePath: string | null = null;
private _noteContent = '';
private _searchQuery = '';
private _folderOpen: Record<string, boolean> = {};
private _loading = false;
private _uploadOpen = false;
private _uploadStatus = '';
private _shadow: ShadowRoot;
constructor() {
super();
this._shadow = this.attachShadow({ mode: 'open' });
}
get space() { return this._space; }
set space(v: string) { this._space = v; this._fetchVaults(); }
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (name === 'space' && val) { this._space = val; this._fetchVaults(); }
if (name === 'module-id' && val) { this._moduleId = val; }
}
connectedCallback() {
this._space = this.getAttribute('space') ?? '';
this._moduleId = this.getAttribute('module-id') ?? 'rnotes';
this._render();
if (this._space) this._fetchVaults();
}
// ── API base ─────────────────────────────────────────────────────────────
private _getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^(\/[^/]+)?\/rnotes/);
return match ? match[0] : '';
}
// ── Data fetching ─────────────────────────────────────────────────────────
private async _fetchVaults() {
if (!this._space) return;
this._loading = true; this._render();
try {
const base = this._getApiBase();
const res = await fetch(`${base}/api/vault/list`);
if (res.ok) {
const data = await res.json() as { vaults: VaultMeta[] };
this._vaults = data.vaults ?? [];
}
} catch { /* ignore */ }
this._loading = false; this._render();
}
private async _selectVault(id: string) {
this._selectedVaultId = id;
this._selectedNotePath = null;
this._noteContent = '';
this._notes = [];
this._render();
try {
const base = this._getApiBase();
const res = await fetch(`${base}/api/vault/${id}/notes`);
if (res.ok) {
const data = await res.json() as { notes: VaultNoteMeta[] };
this._notes = data.notes ?? [];
}
} catch { /* ignore */ }
this._render();
}
private async _selectNote(path: string) {
if (!this._selectedVaultId) return;
this._selectedNotePath = path;
this._noteContent = '';
this._render();
try {
const base = this._getApiBase();
const res = await fetch(
`${base}/api/vault/${this._selectedVaultId}/note/${encodeURIComponent(path)}`
);
if (res.ok) {
this._noteContent = await res.text();
}
} catch { /* ignore */ }
this._render();
}
private async _uploadVault(form: HTMLFormElement) {
const fd = new FormData(form);
this._uploadStatus = 'Uploading…';
this._render();
try {
const base = this._getApiBase();
const token = localStorage.getItem('encryptid-token');
const res = await fetch(`${base}/api/vault/upload`, {
method: 'POST',
body: fd,
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
});
const data = await res.json();
if (res.ok) {
this._uploadStatus = `Vault uploaded: ${data.name ?? 'done'}`;
this._uploadOpen = false;
await this._fetchVaults();
} else {
this._uploadStatus = `Error: ${data.error ?? 'upload failed'}`;
}
} catch (e: any) {
this._uploadStatus = `Error: ${e.message}`;
}
this._render();
}
// ── Rendering ─────────────────────────────────────────────────────────────
private _render() {
this._shadow.innerHTML = `<style>${this._css()}</style>${this._html()}`;
this._attachEvents();
}
private _css(): string {
return `
:host { display: flex; flex-direction: column; height: 100%; font-family: system-ui, sans-serif; background: #0a0a0a; color: #e0e0e0; font-size: 13px; }
* { box-sizing: border-box; margin: 0; padding: 0; }
/* Toolbar */
.toolbar { display: flex; align-items: center; gap: 10px; padding: 8px 14px; background: #111; border-bottom: 1px solid #222; flex-shrink: 0; }
.toolbar-title { font-size: 12px; color: #888; margin-right: auto; }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border-radius: 6px; border: 1px solid #333; background: #1a1a1a; color: #e0e0e0; cursor: pointer; font-size: 12px; white-space: nowrap; transition: background 0.15s; }
.btn:hover { background: #252525; border-color: #444; }
.btn.primary { background: #14b8a6; border-color: #0d9488; color: #fff; }
.btn.primary:hover { background: #0d9488; }
.search-input { background: #1a1a1a; border: 1px solid #333; border-radius: 6px; color: #e0e0e0; font-size: 12px; padding: 5px 10px; outline: none; width: 200px; }
.search-input:focus { border-color: #14b8a6; }
/* Layout */
.layout { display: flex; flex: 1; overflow: hidden; }
/* Left sidebar */
.sidebar { width: 250px; flex-shrink: 0; background: #111; border-right: 1px solid #222; display: flex; flex-direction: column; overflow: hidden; }
.sidebar-header { padding: 10px 14px; font-size: 11px; font-weight: 600; color: #888; text-transform: uppercase; letter-spacing: 0.05em; border-bottom: 1px solid #1e1e1e; flex-shrink: 0; }
.vault-list { flex: 1; overflow-y: auto; padding: 6px 0; }
.vault-item { padding: 8px 14px; cursor: pointer; border-left: 3px solid transparent; transition: background 0.1s; }
.vault-item:hover { background: #1a1a1a; }
.vault-item.active { background: rgba(20,184,166,0.08); border-left-color: #14b8a6; }
.vault-name { font-size: 13px; color: #e0e0e0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vault-meta { display: flex; align-items: center; gap: 6px; margin-top: 3px; font-size: 11px; color: #666; }
.source-badge { display: inline-flex; align-items: center; gap: 3px; }
.sync-dot { width: 6px; height: 6px; border-radius: 50%; background: #14b8a6; flex-shrink: 0; }
.empty-state { padding: 20px 14px; color: #555; font-size: 12px; line-height: 1.5; }
/* Center file tree */
.file-tree { width: 400px; flex-shrink: 0; background: #0d0d0d; border-right: 1px solid #222; display: flex; flex-direction: column; overflow: hidden; }
.tree-search { padding: 8px 10px; border-bottom: 1px solid #1e1e1e; flex-shrink: 0; }
.tree-search .search-input { width: 100%; }
.tree-body { flex: 1; overflow-y: auto; padding: 4px 0; }
.folder-header { display: flex; align-items: center; gap: 6px; padding: 5px 10px; cursor: pointer; color: #888; font-size: 12px; font-weight: 500; user-select: none; }
.folder-header:hover { color: #ccc; }
.folder-header .chevron { transition: transform 0.15s; }
.folder-header .chevron.open { transform: rotate(0deg); }
.folder-header .chevron.closed { transform: rotate(-90deg); }
.folder-files { padding-left: 8px; }
.note-item { display: flex; flex-direction: column; padding: 6px 10px; cursor: pointer; border-left: 3px solid transparent; transition: background 0.1s; }
.note-item:hover { background: #1a1a1a; }
.note-item.active { background: rgba(20,184,166,0.08); border-left-color: #14b8a6; }
.note-title { font-size: 12px; color: #ddd; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.note-footer { display: flex; align-items: center; gap: 4px; margin-top: 3px; flex-wrap: wrap; }
.tag { display: inline-block; padding: 1px 5px; border-radius: 3px; background: rgba(20,184,166,0.12); color: #14b8a6; font-size: 10px; border: 1px solid rgba(20,184,166,0.2); }
.note-time { font-size: 10px; color: #555; margin-left: auto; white-space: nowrap; }
.no-vault { padding: 30px 16px; color: #555; font-size: 12px; text-align: center; line-height: 1.6; }
/* Preview panel */
.preview { flex: 1; display: flex; flex-direction: column; overflow: hidden; background: #0a0a0a; }
.preview-header { padding: 10px 16px; background: #111; border-bottom: 1px solid #222; flex-shrink: 0; }
.preview-path { font-size: 11px; color: #555; margin-bottom: 4px; word-break: break-all; }
.preview-tags { display: flex; flex-wrap: wrap; gap: 4px; }
.preview-body { flex: 1; overflow-y: auto; padding: 24px 28px; line-height: 1.7; }
.preview-body h1, .preview-body h2, .preview-body h3, .preview-body h4, .preview-body h5, .preview-body h6 { color: #f0f0f0; margin: 1.2em 0 0.4em; font-weight: 600; line-height: 1.3; }
.preview-body h1 { font-size: 1.6em; border-bottom: 1px solid #222; padding-bottom: 0.3em; }
.preview-body h2 { font-size: 1.3em; }
.preview-body h3 { font-size: 1.1em; color: #ddd; }
.preview-body p { color: #c8c8c8; margin: 0.5em 0; }
.preview-body a { color: #14b8a6; text-decoration: none; }
.preview-body a:hover { text-decoration: underline; }
.preview-body a.wikilink { color: #60a5fa; border-bottom: 1px dashed rgba(96,165,250,0.5); }
.preview-body code { background: #1a1a1a; border: 1px solid #333; border-radius: 3px; padding: 1px 4px; font-family: 'Fira Code', monospace; font-size: 12px; color: #c8f; }
.preview-body pre { background: #131313; border: 1px solid #2a2a2a; border-radius: 6px; padding: 14px 16px; overflow-x: auto; margin: 1em 0; }
.preview-body pre code { background: none; border: none; padding: 0; color: #e0e0e0; font-size: 12px; }
.preview-body blockquote { border-left: 3px solid #333; padding-left: 14px; margin: 0.8em 0; color: #888; font-style: italic; }
.preview-body ul, .preview-body ol { padding-left: 1.5em; margin: 0.5em 0; color: #c0c0c0; }
.preview-body li { margin: 0.2em 0; }
.preview-body hr { border: none; border-top: 1px solid #2a2a2a; margin: 1.5em 0; }
.preview-body img { max-width: 100%; border-radius: 4px; }
.preview-body strong { color: #f0f0f0; }
.preview-body em { color: #c8a0f0; }
.preview-body del { color: #666; }
.no-preview { padding: 40px; color: #444; font-size: 13px; text-align: center; line-height: 1.8; }
/* Upload dialog */
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 100; backdrop-filter: blur(2px); }
.dialog { background: #1a1a1a; border: 1px solid #333; border-radius: 10px; padding: 24px; width: 380px; max-width: 90vw; }
.dialog-title { font-size: 15px; font-weight: 600; color: #f0f0f0; margin-bottom: 18px; }
.field { margin-bottom: 14px; }
.field label { display: block; font-size: 11px; color: #888; margin-bottom: 5px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.04em; }
.field input, .field select { width: 100%; background: #111; border: 1px solid #333; border-radius: 6px; color: #e0e0e0; font-size: 13px; padding: 7px 10px; outline: none; }
.field input:focus, .field select:focus { border-color: #14b8a6; }
.field select option { background: #1a1a1a; }
.dialog-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
.status-msg { margin-top: 10px; font-size: 12px; color: #14b8a6; min-height: 18px; }
/* Scrollbar */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #444; }
/* Loading spinner */
.spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid #333; border-top-color: #14b8a6; border-radius: 50%; animation: spin 0.7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
`;
}
private _html(): string {
const vault = this._vaults.find(v => v.id === this._selectedVaultId);
return `
<div class="toolbar">
<span class="toolbar-title">${ICON_SEARCH} ${escHtml(this._space || 'rNotes')}</span>
<button class="btn primary" id="btn-upload">${ICON_UPLOAD} Upload Vault</button>
</div>
<div class="layout">
${this._renderSidebar()}
${this._renderFileTree()}
${this._renderPreview(vault)}
</div>
${this._uploadOpen ? this._renderUploadDialog() : ''}
`;
}
private _renderSidebar(): string {
if (this._loading) {
return `<div class="sidebar"><div class="sidebar-header">Vaults</div><div class="empty-state"><div class="spinner"></div></div></div>`;
}
const items = this._vaults.map(v => {
const active = v.id === this._selectedVaultId ? ' active' : '';
const icon = v.source === 'logseq' ? ICON_LOGSEQ : ICON_OBSIDIAN;
const label = v.source === 'logseq' ? 'Logseq' : 'Obsidian';
const ago = timeAgo(v.lastSyncedAt);
return `
<div class="vault-item${active}" data-vault-id="${escHtml(v.id)}">
<div class="vault-name">${escHtml(v.name)}</div>
<div class="vault-meta">
<span class="source-badge">${icon} ${label}</span>
<span class="sync-dot" title="Synced"></span>
${escHtml(String(v.totalNotes))} notes
· ${ago}
</div>
</div>`;
}).join('');
return `
<div class="sidebar">
<div class="sidebar-header">${ICON_SYNC} Vaults</div>
<div class="vault-list">
${items || '<div class="empty-state">No vaults yet.<br>Upload a vault ZIP to get started.</div>'}
</div>
</div>`;
}
private _renderFileTree(): string {
if (!this._selectedVaultId) {
return `<div class="file-tree"><div class="no-vault">Select a vault<br>to browse notes</div></div>`;
}
// Filter by search
const q = this._searchQuery.toLowerCase();
const filtered = q
? this._notes.filter(n => n.title.toLowerCase().includes(q) || n.path.toLowerCase().includes(q))
: this._notes;
// Group by folder
const folders: Record<string, VaultNoteMeta[]> = {};
for (const n of filtered) {
const parts = n.path.split('/');
const folder = parts.length > 1 ? parts.slice(0, -1).join('/') : '(root)';
(folders[folder] ??= []).push(n);
}
const folderHtml = Object.entries(folders).sort(([a], [b]) => a.localeCompare(b)).map(([folder, notes]) => {
const isOpen = this._folderOpen[folder] !== false; // default open
const chevronClass = isOpen ? 'open' : 'closed';
const notesHtml = isOpen ? notes.sort((a, b) => a.title.localeCompare(b.title)).map(n => {
const active = n.path === this._selectedNotePath ? ' active' : '';
const tags = (n.tags ?? []).slice(0, 3).map(t => `<span class="tag">${escHtml(t)}</span>`).join('');
const ago = timeAgo(n.lastModifiedAt);
return `
<div class="note-item${active}" data-note-path="${escHtml(n.path)}">
<div class="note-title">${ICON_FILE} ${escHtml(n.title || n.path.split('/').pop() || n.path)}</div>
<div class="note-footer">${tags}<span class="note-time">${ago}</span></div>
</div>`;
}).join('') : '';
return `
<div class="folder-header" data-folder="${escHtml(folder)}">
<span class="chevron ${chevronClass}">${ICON_CHEVRON}</span>
${ICON_FOLDER} ${escHtml(folder)} <span style="color:#555;font-size:11px;margin-left:auto">${notes.length}</span>
</div>
<div class="folder-files">${notesHtml}</div>`;
}).join('');
return `
<div class="file-tree">
<div class="tree-search">
<input class="search-input" type="search" id="tree-search" placeholder="Search notes…" value="${escHtml(this._searchQuery)}">
</div>
<div class="tree-body">
${folderHtml || '<div class="no-vault">No notes found</div>'}
</div>
</div>`;
}
private _renderPreview(vault?: VaultMeta): string {
if (!this._selectedNotePath) {
return `<div class="preview"><div class="no-preview">Select a note<br>to preview it here</div></div>`;
}
const note = this._notes.find(n => n.path === this._selectedNotePath);
const tags = (note?.tags ?? []).map(t => `<span class="tag">${escHtml(t)}</span>`).join('');
const modTime = note ? `Last modified ${timeAgo(note.lastModifiedAt)}` : '';
let bodyHtml: string;
if (!this._noteContent) {
bodyHtml = `<div style="display:flex;justify-content:center;padding:40px"><div class="spinner"></div></div>`;
} else {
bodyHtml = renderMarkdown(this._noteContent);
}
return `
<div class="preview">
<div class="preview-header">
<div class="preview-path">${escHtml(this._selectedNotePath)} ${modTime ? `· ${modTime}` : ''}</div>
${tags ? `<div class="preview-tags">${tags}</div>` : ''}
</div>
<div class="preview-body">${bodyHtml}</div>
</div>`;
}
private _renderUploadDialog(): string {
return `
<div class="overlay" id="overlay">
<div class="dialog">
<div class="dialog-title">${ICON_UPLOAD} Upload Vault</div>
<form id="upload-form">
<div class="field">
<label>Vault Name</label>
<input type="text" name="name" placeholder="My Obsidian Vault" required>
</div>
<div class="field">
<label>Source</label>
<select name="source">
<option value="obsidian">Obsidian</option>
<option value="logseq">Logseq</option>
</select>
</div>
<div class="field">
<label>Vault ZIP File</label>
<input type="file" name="file" accept=".zip" required>
</div>
<div class="status-msg" id="upload-status">${escHtml(this._uploadStatus)}</div>
<div class="dialog-actions">
<button type="button" class="btn" id="btn-cancel-upload">Cancel</button>
<button type="submit" class="btn primary">${ICON_UPLOAD} Upload</button>
</div>
</form>
</div>
</div>`;
}
// ── Event wiring ──────────────────────────────────────────────────────────
private _attachEvents() {
const $ = (id: string) => this._shadow.getElementById(id);
// Upload button
$('btn-upload')?.addEventListener('click', () => {
this._uploadOpen = true; this._uploadStatus = ''; this._render();
});
// Close overlay
$('btn-cancel-upload')?.addEventListener('click', () => {
this._uploadOpen = false; this._render();
});
$('overlay')?.addEventListener('click', (e) => {
if ((e.target as HTMLElement).id === 'overlay') { this._uploadOpen = false; this._render(); }
});
// Upload form submit
const uploadForm = $('upload-form') as HTMLFormElement | null;
uploadForm?.addEventListener('submit', async (e) => {
e.preventDefault();
await this._uploadVault(uploadForm);
});
// Vault selection
this._shadow.querySelectorAll<HTMLElement>('.vault-item').forEach(el => {
el.addEventListener('click', () => {
const id = el.dataset.vaultId;
if (id) this._selectVault(id);
});
});
// Folder toggle
this._shadow.querySelectorAll<HTMLElement>('.folder-header').forEach(el => {
el.addEventListener('click', () => {
const f = el.dataset.folder;
if (f !== undefined) {
this._folderOpen[f] = !(this._folderOpen[f] !== false);
this._render();
}
});
});
// Note selection
this._shadow.querySelectorAll<HTMLElement>('.note-item').forEach(el => {
el.addEventListener('click', () => {
const path = el.dataset.notePath;
if (path) this._selectNote(path);
});
});
// Tree search
const treeSearch = $('tree-search') as HTMLInputElement | null;
treeSearch?.addEventListener('input', (e) => {
this._searchQuery = (e.target as HTMLInputElement).value;
this._render();
});
// Wikilinks in preview
this._shadow.querySelectorAll<HTMLAnchorElement>('a.wikilink').forEach(a => {
a.addEventListener('click', (e) => {
e.preventDefault();
const target = a.dataset.target ?? '';
const match = this._notes.find(n =>
n.title.toLowerCase() === target.toLowerCase() ||
n.path.toLowerCase().includes(target.toLowerCase())
);
if (match) this._selectNote(match.path);
});
});
}
}
// ── Register custom element ───────────────────────────────────────────────────
if (!customElements.get('folk-notes-app')) {
customElements.define('folk-notes-app', FolkNotesApp);
}