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:
1260
public/css/style.css
Normal file
1260
public/css/style.css
Normal file
File diff suppressed because it is too large
Load Diff
18
public/index.html
Normal file
18
public/index.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SimpleNote</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="toast-container"></div>
|
||||
<script src="/js/app.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
120
public/js/api.js
Normal file
120
public/js/api.js
Normal file
@@ -0,0 +1,120 @@
|
||||
// API Client for SimpleNote Web
|
||||
|
||||
const API_BASE = '/api/v1';
|
||||
|
||||
class ApiClient {
|
||||
constructor() {
|
||||
this.token = localStorage.getItem('sn_token');
|
||||
}
|
||||
|
||||
setToken(token) {
|
||||
this.token = token;
|
||||
if (token) {
|
||||
localStorage.setItem('sn_token', token);
|
||||
} else {
|
||||
localStorage.removeItem('sn_token');
|
||||
}
|
||||
}
|
||||
|
||||
getHeaders() {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async request(method, path, body = null) {
|
||||
const options = {
|
||||
method,
|
||||
headers: this.getHeaders()
|
||||
};
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, options);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Request failed' }));
|
||||
throw new Error(error.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
get(path) { return this.request('GET', path); }
|
||||
post(path, body) { return this.request('POST', path, body); }
|
||||
put(path, body) { return this.request('PUT', path, body); }
|
||||
delete(path) { return this.request('DELETE', path); }
|
||||
|
||||
// Auth
|
||||
async login(token) {
|
||||
try {
|
||||
const data = await this.get('/auth/verify');
|
||||
this.setToken(token);
|
||||
return data;
|
||||
} catch (e) {
|
||||
this.setToken(null);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Documents
|
||||
getDocuments(params = {}) {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
return this.get(`/documents${query ? '?' + query : ''}`);
|
||||
}
|
||||
|
||||
getDocument(id) {
|
||||
return this.get(`/documents/${id}`);
|
||||
}
|
||||
|
||||
createDocument(data) {
|
||||
return this.post('/documents', data);
|
||||
}
|
||||
|
||||
updateDocument(id, data) {
|
||||
return this.put(`/documents/${id}`, data);
|
||||
}
|
||||
|
||||
deleteDocument(id) {
|
||||
return this.delete(`/documents/${id}`);
|
||||
}
|
||||
|
||||
exportDocument(id) {
|
||||
return fetch(`${API_BASE}/documents/${id}/export`, {
|
||||
headers: this.getHeaders()
|
||||
}).then(r => r.text());
|
||||
}
|
||||
|
||||
// Libraries
|
||||
getLibraries() {
|
||||
return this.get('/libraries');
|
||||
}
|
||||
|
||||
getLibrary(id) {
|
||||
return this.get(`/libraries/${id}`);
|
||||
}
|
||||
|
||||
createLibrary(data) {
|
||||
return this.post('/libraries', data);
|
||||
}
|
||||
|
||||
updateLibrary(id, data) {
|
||||
return this.put(`/libraries/${id}`, data);
|
||||
}
|
||||
|
||||
deleteLibrary(id) {
|
||||
return this.delete(`/libraries/${id}`);
|
||||
}
|
||||
|
||||
// Tags
|
||||
getTags() {
|
||||
return this.get('/tags');
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient();
|
||||
140
public/js/app.js
Normal file
140
public/js/app.js
Normal file
@@ -0,0 +1,140 @@
|
||||
// SimpleNote Web - Main Application
|
||||
|
||||
import { api } from './api.js';
|
||||
import { renderLogin } from './views/login.js';
|
||||
import { renderDashboard } from './views/dashboard.js';
|
||||
import { renderDocument } from './views/document.js';
|
||||
import { renderEditor } from './views/editor.js';
|
||||
|
||||
class App {
|
||||
constructor() {
|
||||
this.currentView = null;
|
||||
this.state = {
|
||||
token: localStorage.getItem('sn_token'),
|
||||
view: 'dashboard',
|
||||
params: {}
|
||||
};
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.state.token) {
|
||||
this.renderLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
api.setToken(this.state.token);
|
||||
|
||||
try {
|
||||
await api.login(this.state.token);
|
||||
this.render();
|
||||
} catch (e) {
|
||||
this.state.token = null;
|
||||
localStorage.removeItem('sn_token');
|
||||
this.renderLogin();
|
||||
}
|
||||
}
|
||||
|
||||
renderLogin() {
|
||||
const app = document.getElementById('app');
|
||||
app.innerHTML = renderLogin({
|
||||
onLogin: async (token) => {
|
||||
try {
|
||||
await api.login(token);
|
||||
this.state.token = token;
|
||||
this.state.view = 'dashboard';
|
||||
this.render();
|
||||
} catch (e) {
|
||||
return 'Invalid token';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async render() {
|
||||
const app = document.getElementById('app');
|
||||
|
||||
switch (this.state.view) {
|
||||
case 'dashboard':
|
||||
await renderDashboard(this);
|
||||
break;
|
||||
case 'document':
|
||||
await renderDocument(this);
|
||||
break;
|
||||
case 'editor':
|
||||
renderEditor(this);
|
||||
break;
|
||||
default:
|
||||
await renderDashboard(this);
|
||||
}
|
||||
}
|
||||
|
||||
navigate(view, params = {}) {
|
||||
this.state.view = view;
|
||||
this.state.params = params;
|
||||
this.render();
|
||||
}
|
||||
|
||||
showToast(message, type = 'info') {
|
||||
const container = document.getElementById('toast-container');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.innerHTML = `
|
||||
<span class="toast-message">${message}</span>
|
||||
<button class="toast-close" onclick="this.parentElement.remove()">✕</button>
|
||||
`;
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 4000);
|
||||
}
|
||||
|
||||
async confirmDelete(message) {
|
||||
return new Promise((resolve) => {
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'modal-backdrop';
|
||||
backdrop.innerHTML = `
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span>⚠️</span>
|
||||
<h3>Confirm Delete</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>${message}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" id="cancel-btn">Cancel</button>
|
||||
<button class="btn btn-danger" id="confirm-btn">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(backdrop);
|
||||
|
||||
backdrop.querySelector('#cancel-btn').onclick = () => {
|
||||
backdrop.remove();
|
||||
resolve(false);
|
||||
};
|
||||
backdrop.querySelector('#confirm-btn').onclick = () => {
|
||||
backdrop.remove();
|
||||
resolve(true);
|
||||
};
|
||||
backdrop.onclick = (e) => {
|
||||
if (e.target === backdrop) {
|
||||
backdrop.remove();
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.app = new App();
|
||||
app.init();
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && window.app.state.view === 'editor') {
|
||||
window.app.navigate('dashboard');
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'n' && window.app.state.view === 'dashboard') {
|
||||
e.preventDefault();
|
||||
window.app.navigate('editor');
|
||||
}
|
||||
});
|
||||
46
public/js/components/modal.js
Normal file
46
public/js/components/modal.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// Modal Component
|
||||
|
||||
export function showModal({ title, content, onConfirm, onCancel, confirmText = 'Confirm', cancelText = 'Cancel', danger = false }) {
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'modal-backdrop';
|
||||
backdrop.innerHTML = `
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>${title}</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
${content}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" id="modal-cancel">${cancelText}</button>
|
||||
<button class="btn ${danger ? 'btn-danger' : 'btn-primary'}" id="modal-confirm">${confirmText}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(backdrop);
|
||||
|
||||
backdrop.querySelector('#modal-cancel').onclick = () => {
|
||||
backdrop.remove();
|
||||
if (onCancel) onCancel();
|
||||
};
|
||||
|
||||
backdrop.querySelector('#modal-confirm').onclick = () => {
|
||||
backdrop.remove();
|
||||
if (onConfirm) onConfirm();
|
||||
};
|
||||
|
||||
backdrop.onclick = (e) => {
|
||||
if (e.target === backdrop) {
|
||||
backdrop.remove();
|
||||
if (onCancel) onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return backdrop;
|
||||
}
|
||||
|
||||
export function hideModal(backdrop) {
|
||||
if (backdrop && backdrop.parentElement) {
|
||||
backdrop.remove();
|
||||
}
|
||||
}
|
||||
87
public/js/components/sidebar.js
Normal file
87
public/js/components/sidebar.js
Normal file
@@ -0,0 +1,87 @@
|
||||
// Sidebar Component
|
||||
|
||||
export function renderSidebar({ libraries, tags, selectedLibrary, selectedTag, onSelectLibrary, onSelectTag, onHome }) {
|
||||
const buildLibraryTree = (libs, parentId = null, depth = 0) => {
|
||||
return libs
|
||||
.filter(l => l.parentId === parentId)
|
||||
.map(lib => {
|
||||
const children = libs.filter(l => l.parentId === lib.id);
|
||||
const hasChildren = children.length > 0;
|
||||
const isSelected = selectedLibrary === lib.id;
|
||||
|
||||
return `
|
||||
<div class="tree-node">
|
||||
<div class="tree-item ${isSelected ? 'active' : ''}" onclick="handleSelectLibrary('${lib.id}')">
|
||||
<span class="tree-toggle ${hasChildren ? 'expanded' : ''}" style="padding-left:${depth * 12}px">
|
||||
${hasChildren ? '▶' : ''}
|
||||
</span>
|
||||
<span class="icon">📁</span>
|
||||
<span class="label">${escapeHtml(lib.name)}</span>
|
||||
</div>
|
||||
${hasChildren ? `<div class="tree-children">${buildLibraryTree(libraries, lib.id, depth + 1)}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
};
|
||||
|
||||
return `
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-scroll">
|
||||
<div class="sidebar-section">
|
||||
<h3>📚 Libraries</h3>
|
||||
<div class="library-tree">
|
||||
<div class="tree-item ${!selectedLibrary ? 'active' : ''}" onclick="handleHome()">
|
||||
<span class="icon">🏠</span>
|
||||
<span class="label">All Documents</span>
|
||||
</div>
|
||||
${buildLibraryTree(libraries)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<h3>🏷️ Tags</h3>
|
||||
<div class="tag-list">
|
||||
${tags.map(tag => `
|
||||
<div class="tag-item ${selectedTag === tag.name ? 'active' : ''}" onclick="handleSelectTag('${escapeHtml(tag.name)}')">
|
||||
<span>#${escapeHtml(tag.name)}</span>
|
||||
<span class="tag-count">${tag.count}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="quick-links">
|
||||
<a class="quick-link" onclick="handleHome()">📋 All Documents</a>
|
||||
<a class="quick-link" onclick="window.app.navigate('editor')">+ New Document</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<script>
|
||||
window.handleSelectLibrary = (id) => {
|
||||
${onSelectLibrary ? `window.document.dispatchEvent(new CustomEvent('select-library', {detail: '${''}'}))` : ''}
|
||||
};
|
||||
window.handleSelectTag = (tag) => {
|
||||
${onSelectTag ? `window.document.dispatchEvent(new CustomEvent('select-tag', {detail: tag}))` : ''}
|
||||
};
|
||||
window.handleHome = () => {
|
||||
${onHome ? `window.document.dispatchEvent(new CustomEvent('go-home'))` : ''}
|
||||
};
|
||||
|
||||
window.document.addEventListener('select-library', (e) => {
|
||||
${onSelectLibrary ? onSelectLibrary.toString().replace(/\s+/g, ' ') : ''}
|
||||
});
|
||||
window.document.addEventListener('select-tag', (e) => {
|
||||
${onSelectTag ? onSelectTag.toString().replace(/\s+/g, ' ') : ''}
|
||||
});
|
||||
window.document.addEventListener('go-home', (e) => {
|
||||
${onHome ? onHome.toString().replace(/\s+/g, ' ') : ''}
|
||||
});
|
||||
</script>
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
161
public/js/views/dashboard.js
Normal file
161
public/js/views/dashboard.js
Normal 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
154
public/js/views/document.js
Normal 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
259
public/js/views/editor.js
Normal 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"></></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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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
29
public/js/views/login.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user