feat: add frontend UI for SimpleNote Web

- Vanilla JS frontend with dark theme
- Dashboard with sidebar (libraries tree, tags), document grid, search
- Document viewer with markdown rendering and metadata panel
- Document editor with split write/preview and formatting toolbar
- Login screen with token authentication
- All styled according to UI/UX specs (dark theme, accent #00d4aa)
- API client for all endpoints
- Responsive design
This commit is contained in:
Hiro
2026-03-28 11:44:42 +00:00
parent c3e48596f3
commit c4921c8e73
12 changed files with 2280 additions and 0 deletions

View File

@@ -0,0 +1,161 @@
// Dashboard View
import { api } from '../api.js';
import { renderSidebar } from '../components/sidebar.js';
export async function renderDashboard(app) {
let documents = [];
let libraries = [];
let tags = [];
let searchQuery = '';
let selectedTag = null;
let selectedLibrary = null;
try {
[documents, libraries, tags] = await Promise.all([
api.getDocuments(),
api.getLibraries(),
api.getTags()
]);
} catch (e) {
app.showToast('Failed to load data', 'error');
}
const appEl = document.getElementById('app');
function render() {
let filteredDocs = documents;
if (searchQuery) {
const q = searchQuery.toLowerCase();
filteredDocs = filteredDocs.filter(d =>
d.title.toLowerCase().includes(q) ||
(d.content && d.content.toLowerCase().includes(q))
);
}
if (selectedTag) {
filteredDocs = filteredDocs.filter(d =>
d.tags && d.tags.includes(selectedTag)
);
}
if (selectedLibrary) {
filteredDocs = filteredDocs.filter(d =>
d.libraryId === selectedLibrary
);
}
appEl.innerHTML = `
<header class="app-header">
<div class="logo">📝 SimpleNote</div>
<div class="search-box">
<span class="icon">🔍</span>
<input type="text" id="search-input" placeholder="Search documents..." value="${searchQuery}">
</div>
<div class="header-actions">
<button class="btn btn-ghost btn-icon-only" onclick="window.app.navigate('editor')" title="New Document">+</button>
<button class="btn btn-ghost btn-icon-only" onclick="window.app.navigate('editor', {libraryId: 'new'})" title="New Library">📁</button>
</div>
</header>
<div class="app-layout">
${renderSidebar({
libraries,
tags,
selectedLibrary,
selectedTag,
onSelectLibrary: (id) => {
selectedLibrary = id;
selectedTag = null;
render();
},
onSelectTag: (tag) => {
selectedTag = tag;
selectedLibrary = null;
render();
},
onHome: () => {
selectedTag = null;
selectedLibrary = null;
render();
}
})}
<main class="main-content">
<div class="content-header">
<h1>${selectedLibrary ? getLibraryName(libraries, selectedLibrary) : selectedTag ? `#${selectedTag}` : 'All Documents'}</h1>
<div class="header-actions">
<button class="btn btn-primary" onclick="window.app.navigate('editor')">+ New Document</button>
</div>
</div>
<div class="content-body">
${filteredDocs.length === 0 ? `
<div class="empty-state">
<div class="icon">📄</div>
<h3>No documents found</h3>
<p>${searchQuery || selectedTag ? 'Try adjusting your filters' : 'Create your first document'}</p>
<button class="btn btn-primary" onclick="window.app.navigate('editor')">+ Create Document</button>
</div>
` : `
<div class="doc-grid">
${filteredDocs.map(doc => renderDocCard(doc)).join('')}
</div>
`}
</div>
</main>
</div>
`;
// Event listeners
const searchInput = document.getElementById('search-input');
searchInput.oninput = (e) => {
searchQuery = e.target.value;
render();
};
}
render();
}
function getLibraryName(libraries, id) {
const lib = libraries.find(l => l.id === id);
return lib ? lib.name : 'Unknown';
}
function renderDocCard(doc) {
const priorityEmoji = { high: '🔴', medium: '🟡', low: '🟢' };
const priority = doc.priority || 'medium';
return `
<div class="doc-card" onclick="window.app.navigate('document', {id: '${doc.id}'})">
<div class="doc-card-header">
<span class="doc-id">${doc.id}</span>
<span class="type-badge ${doc.type || 'general'}">${doc.type || 'general'}</span>
</div>
<h3 class="doc-title">${escapeHtml(doc.title)}</h3>
${doc.tags && doc.tags.length ? `
<div class="doc-tags">
${doc.tags.map(t => `<span class="tag-pill">${escapeHtml(t)}</span>`).join('')}
</div>
` : ''}
<div class="doc-meta">
<span class="doc-meta-item">📅 ${formatDate(doc.createdAt)}</span>
<span class="doc-meta-item">👤 ${escapeHtml(doc.author || 'unknown')}</span>
<span class="status-badge ${doc.status || 'draft'}">${doc.status || 'draft'}</span>
<span class="priority-indicator">${priorityEmoji[priority]}</span>
</div>
</div>
`;
}
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}

154
public/js/views/document.js Normal file
View File

@@ -0,0 +1,154 @@
// Document View
import { api } from '../api.js';
export async function renderDocument(app) {
const { id } = app.state.params;
let doc;
try {
doc = await api.getDocument(id);
} catch (e) {
app.showToast('Failed to load document', 'error');
app.navigate('dashboard');
return;
}
const appEl = document.getElementById('app');
function render() {
const priorityEmoji = { high: '🔴', medium: '🟡', low: '🟢' };
const priority = doc.priority || 'medium';
const renderedContent = renderMarkdown(doc.content || '');
appEl.innerHTML = `
<header class="app-header">
<button class="btn btn-ghost" onclick="window.app.navigate('dashboard')">← Back</button>
<div class="header-actions">
<button class="btn btn-ghost" onclick="window.app.navigate('editor', {id: '${doc.id}'})">✏️ Edit</button>
<button class="btn btn-ghost" onclick="exportDoc()">📥 Export</button>
<button class="btn btn-ghost danger" onclick="deleteDoc()">🗑️ Delete</button>
</div>
</header>
<main class="main-content">
<div class="content-body">
<div class="doc-viewer">
<div class="doc-content">
<div class="doc-viewer-header">
<span class="doc-id">${doc.id}</span>
<span class="type-badge ${doc.type || 'general'}">${doc.type || 'general'}</span>
</div>
<div class="prose">${renderedContent}</div>
</div>
<aside class="doc-sidebar">
<div class="meta-section">
<div class="meta-header">Details</div>
<div class="meta-body">
<div class="meta-row">
<span class="meta-label">Status</span>
<span class="status-badge ${doc.status || 'draft'}">${doc.status || 'draft'}</span>
</div>
<div class="meta-row">
<span class="meta-label">Priority</span>
<span class="meta-value">${priorityEmoji[priority]} ${priority}</span>
</div>
<div class="meta-row">
<span class="meta-label">Author</span>
<span class="meta-value">${escapeHtml(doc.author || 'unknown')}</span>
</div>
<div class="meta-row">
<span class="meta-label">Created</span>
<span class="meta-value">${formatDate(doc.createdAt)}</span>
</div>
<div class="meta-row">
<span class="meta-label">Updated</span>
<span class="meta-value">${formatDate(doc.updatedAt)}</span>
</div>
</div>
</div>
${doc.tags && doc.tags.length ? `
<div class="meta-section">
<div class="meta-header">Tags</div>
<div class="meta-body doc-tags">
${doc.tags.map(t => `<span class="tag-pill" onclick="filterByTag('${escapeHtml(t)}')">${escapeHtml(t)}</span>`).join('')}
</div>
</div>
` : ''}
</aside>
</div>
</div>
</main>
`;
window.filterByTag = (tag) => {
app.navigate('dashboard');
};
}
render();
async function exportDoc() {
try {
const markdown = await api.exportDocument(id);
const blob = new Blob([markdown], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${doc.id}-${doc.title}.md`;
a.click();
URL.revokeObjectURL(url);
app.showToast('Document exported', 'success');
} catch (e) {
app.showToast('Failed to export', 'error');
}
}
async function deleteDoc() {
const confirmed = await app.confirmDelete(`Delete "${doc.title}"? This cannot be undone.`);
if (confirmed) {
try {
await api.deleteDocument(id);
app.showToast('Document deleted', 'success');
app.navigate('dashboard');
} catch (e) {
app.showToast('Failed to delete', 'error');
}
}
}
}
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
function renderMarkdown(content) {
// Simple markdown rendering using marked library if available
if (typeof marked !== 'undefined') {
return marked.parse(content);
}
// Fallback simple rendering
return content
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
.replace(/\n\n/g, '</p><p>')
.replace(/^(.+)$/gm, (match) => {
if (match.startsWith('<')) return match;
return `<p>${match}</p>`;
});
}

259
public/js/views/editor.js Normal file
View File

@@ -0,0 +1,259 @@
// Editor View
import { api } from '../api.js';
export async function renderEditor(app) {
const { id, libraryId } = app.state.params;
let doc = null;
let libraries = [];
if (id) {
try {
doc = await api.getDocument(id);
} catch (e) {
app.showToast('Failed to load document', 'error');
app.navigate('dashboard');
return;
}
}
try {
libraries = await api.getLibraries();
} catch (e) {}
const isNew = !id;
const appEl = document.getElementById('app');
let formData = {
title: doc?.title || '',
content: doc?.content || '',
tags: doc?.tags?.join(', ') || '',
type: doc?.type || 'general',
priority: doc?.priority || 'medium',
status: doc?.status || 'draft',
libraryId: doc?.libraryId || libraryId || ''
};
let showPreview = false;
let hasChanges = false;
function render() {
appEl.innerHTML = `
<header class="app-header">
<button class="btn btn-ghost" onclick="handleCancel()">Cancel</button>
<span style="flex:1;margin-left:16px">${isNew ? 'New Document' : 'Editing: ' + escapeHtml(formData.title)}</span>
<button class="btn btn-primary" onclick="handleSave()">Save</button>
</header>
<main class="main-content">
<div class="editor-container">
<form class="editor-form" id="editor-form">
<div class="form-row">
<div class="form-group" style="flex:2">
<label for="title">Title</label>
<input type="text" id="title" value="${escapeHtml(formData.title)}" required>
</div>
<div class="form-group">
<label for="libraryId">Library</label>
<select id="libraryId">
<option value="">None</option>
${libraries.map(l => `<option value="${l.id}" ${formData.libraryId === l.id ? 'selected' : ''}>${escapeHtml(l.name)}</option>`).join('')}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="type">Type</label>
<select id="type">
<option value="requirement" ${formData.type === 'requirement' ? 'selected' : ''}>Requirement</option>
<option value="note" ${formData.type === 'note' ? 'selected' : ''}>Note</option>
<option value="spec" ${formData.type === 'spec' ? 'selected' : ''}>Specification</option>
<option value="general" ${formData.type === 'general' ? 'selected' : ''}>General</option>
</select>
</div>
<div class="form-group">
<label for="status">Status</label>
<select id="status">
<option value="draft" ${formData.status === 'draft' ? 'selected' : ''}>Draft</option>
<option value="approved" ${formData.status === 'approved' ? 'selected' : ''}>Approved</option>
<option value="implemented" ${formData.status === 'implemented' ? 'selected' : ''}>Implemented</option>
</select>
</div>
<div class="form-group">
<label for="priority">Priority</label>
<select id="priority">
<option value="high" ${formData.priority === 'high' ? 'selected' : ''}>🔴 High</option>
<option value="medium" ${formData.priority === 'medium' ? 'selected' : ''}>🟡 Medium</option>
<option value="low" ${formData.priority === 'low' ? 'selected' : ''}>🟢 Low</option>
</select>
</div>
</div>
<div class="form-group">
<label for="tags">Tags (comma-separated)</label>
<input type="text" id="tags" value="${escapeHtml(formData.tags)}" placeholder="backend, api, auth">
</div>
<div class="form-group full-width">
<div class="editor-toolbar">
<button type="button" class="toolbar-btn" onclick="insertFormat('**', '**')" title="Bold">B</button>
<button type="button" class="toolbar-btn" onclick="insertFormat('*', '*')" title="Italic"><em>I</em></button>
<span class="toolbar-separator"></span>
<button type="button" class="toolbar-btn" onclick="insertLine('# ')" title="Heading 1">H1</button>
<button type="button" class="toolbar-btn" onclick="insertLine('## ')" title="Heading 2">H2</button>
<button type="button" class="toolbar-btn" onclick="insertLine('### ')" title="Heading 3">H3</button>
<span class="toolbar-separator"></span>
<button type="button" class="toolbar-btn" onclick="insertLine('- ')" title="List">•</button>
<button type="button" class="toolbar-btn" onclick="insertLine('1. ')" title="Numbered List">1.</button>
<button type="button" class="toolbar-btn" onclick="insertLine('- [ ] ')" title="Task">☐</button>
<span class="toolbar-separator"></span>
<button type="button" class="toolbar-btn" onclick="insertFormat('\`', '\`')" title="Code">&lt;/&gt;</button>
<div class="toolbar-tabs">
<button type="button" class="tab-btn ${!showPreview ? 'active' : ''}" onclick="togglePreview(false)">Write</button>
<button type="button" class="tab-btn ${showPreview ? 'active' : ''}" onclick="togglePreview(true)">Preview</button>
</div>
</div>
<div class="editor-content ${showPreview ? 'show-preview' : ''}" id="editor-content">
<div class="editor-pane">
<textarea id="content" placeholder="Write your content in Markdown...">${escapeHtml(formData.content)}</textarea>
</div>
<div class="preview-pane prose">${renderMarkdown(formData.content)}</div>
</div>
</div>
</form>
</div>
</main>
`;
// Event listeners
const titleInput = document.getElementById('title');
const contentInput = document.getElementById('content');
const tagsInput = document.getElementById('tags');
const typeInput = document.getElementById('type');
const statusInput = document.getElementById('status');
const priorityInput = document.getElementById('priority');
const libraryInput = document.getElementById('libraryId');
const inputs = [titleInput, contentInput, tagsInput, typeInput, statusInput, priorityInput, libraryInput];
inputs.forEach(input => {
if (input) {
input.addEventListener('input', () => {
hasChanges = true;
updateFormData();
if (showPreview) {
document.querySelector('.preview-pane').innerHTML = renderMarkdown(formData.content);
}
});
}
});
window.insertFormat = (before, after) => {
const textarea = document.getElementById('content');
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const selected = text.substring(start, end);
textarea.value = text.substring(0, start) + before + selected + after + text.substring(end);
textarea.focus();
textarea.selectionStart = start + before.length;
textarea.selectionEnd = end + before.length;
hasChanges = true;
};
window.insertLine = (prefix) => {
const textarea = document.getElementById('content');
const start = textarea.selectionStart;
const text = textarea.value;
// Find start of current line
let lineStart = start;
while (lineStart > 0 && text[lineStart - 1] !== '\n') lineStart--;
textarea.value = text.substring(0, lineStart) + prefix + text.substring(lineStart);
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = lineStart + prefix.length;
hasChanges = true;
};
window.togglePreview = (show) => {
showPreview = show;
const content = document.getElementById('editor-content');
if (show) {
content.classList.add('show-preview');
} else {
content.classList.remove('show-preview');
}
};
window.handleCancel = async () => {
if (hasChanges) {
const confirmed = await app.confirmDelete('You have unsaved changes. Discard?');
if (!confirmed) return;
}
app.navigate('dashboard');
};
window.handleSave = async () => {
updateFormData();
const data = {
title: formData.title,
content: formData.content,
tags: formData.tags.split(',').map(t => t.trim()).filter(t => t),
type: formData.type,
priority: formData.priority,
status: formData.status,
libraryId: formData.libraryId || null
};
try {
if (isNew) {
await api.createDocument(data);
} else {
await api.updateDocument(id, data);
}
app.showToast('Document saved', 'success');
app.navigate('dashboard');
} catch (e) {
app.showToast('Failed to save: ' + e.message, 'error');
}
};
function updateFormData() {
formData = {
title: titleInput.value,
content: contentInput.value,
tags: tagsInput.value,
type: typeInput.value,
priority: priorityInput.value,
status: statusInput.value,
libraryId: libraryInput.value
};
}
}
render();
}
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function renderMarkdown(content) {
if (!content) return '<p style="color:var(--color-text-muted)">Nothing to preview</p>';
if (typeof marked !== 'undefined') {
return marked.parse(content);
}
// Fallback
return content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/\n\n/g, '</p><p>');
}

29
public/js/views/login.js Normal file
View File

@@ -0,0 +1,29 @@
// Login View
export function renderLogin({ onLogin }) {
return `
<div class="login-screen">
<div class="login-card">
<h1>📝 SimpleNote</h1>
<p>Enter your API token to continue</p>
<form class="login-form" id="login-form">
<div class="form-group">
<input type="password" id="token-input" placeholder="API Token" autocomplete="off" required>
</div>
<p class="login-error" id="login-error">Invalid token. Please try again.</p>
<button type="submit" class="btn btn-primary" style="width:100%">Login</button>
</form>
</div>
</div>
<script>
document.getElementById('login-form').onsubmit = async (e) => {
e.preventDefault();
const token = document.getElementById('token-input').value;
const error = await onLogin(token);
if (error) {
document.getElementById('login-error').classList.add('visible');
}
};
</script>
`;
}