633 lines
26 KiB
TypeScript
633 lines
26 KiB
TypeScript
/**
|
||
* <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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
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 
|
||
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
// ── 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);
|
||
}
|