From c4921c8e73ea110f22deea51ee850c12dc378d22 Mon Sep 17 00:00:00 2001
From: Hiro
Date: Sat, 28 Mar 2026 11:44:42 +0000
Subject: [PATCH] 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
---
package.json | 1 +
public/css/style.css | 1260 +++++++++++++++++++++++++++++++
public/index.html | 18 +
public/js/api.js | 120 +++
public/js/app.js | 140 ++++
public/js/components/modal.js | 46 ++
public/js/components/sidebar.js | 87 +++
public/js/views/dashboard.js | 161 ++++
public/js/views/document.js | 154 ++++
public/js/views/editor.js | 259 +++++++
public/js/views/login.js | 29 +
src/index.js | 5 +
12 files changed, 2280 insertions(+)
create mode 100644 public/css/style.css
create mode 100644 public/index.html
create mode 100644 public/js/api.js
create mode 100644 public/js/app.js
create mode 100644 public/js/components/modal.js
create mode 100644 public/js/components/sidebar.js
create mode 100644 public/js/views/dashboard.js
create mode 100644 public/js/views/document.js
create mode 100644 public/js/views/editor.js
create mode 100644 public/js/views/login.js
diff --git a/package.json b/package.json
index a951523..dd5382e 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
"express": "^4.21.0",
"gray-matter": "^4.0.3",
"js-yaml": "^4.1.0",
+ "marked": "^11.1.0",
"uuid": "^9.0.1"
},
"devDependencies": {
diff --git a/public/css/style.css b/public/css/style.css
new file mode 100644
index 0000000..c5ffc92
--- /dev/null
+++ b/public/css/style.css
@@ -0,0 +1,1260 @@
+/* === Design Tokens === */
+:root {
+ /* Backgrounds */
+ --color-bg: #0f1117;
+ --color-surface: #1a1d26;
+ --color-surface-raised: #22262f;
+ --color-hover: rgba(255, 255, 255, 0.05);
+ --color-active: rgba(255, 255, 255, 0.08);
+
+ /* Text */
+ --color-text: #e4e6eb;
+ --color-text-secondary: #b0b3b8;
+ --color-text-muted: #65676b;
+
+ /* Borders */
+ --color-border: #303338;
+ --color-border-light: #404249;
+
+ /* Accent */
+ --color-accent: #00d4aa;
+ --color-accent-hover: #00e8bb;
+ --color-accent-alpha: rgba(0, 212, 170, 0.15);
+
+ /* Semantic */
+ --color-success: #31a065;
+ --color-success-bg: rgba(49, 160, 101, 0.15);
+ --color-warning: #cf9d2c;
+ --color-warning-bg: rgba(207, 157, 44, 0.15);
+ --color-danger: #cf4a4a;
+ --color-danger-hover: #e05555;
+ --color-info: #4a90cf;
+ --color-info-bg: rgba(74, 144, 207, 0.15);
+
+ /* Type colors */
+ --color-requirement: #00d4aa;
+ --color-note: #4a90cf;
+ --color-spec: #9b59b6;
+ --color-general: #65676b;
+
+ /* Priority */
+ --color-priority-high: #cf4a4a;
+ --color-priority-medium: #cf9d2c;
+ --color-priority-low: #31a065;
+
+ /* Shadows */
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25);
+ --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.3);
+
+ /* Typography */
+ --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ --font-mono: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Consolas', monospace;
+
+ /* Spacing */
+ --space-1: 0.25rem;
+ --space-2: 0.5rem;
+ --space-3: 0.75rem;
+ --space-4: 1rem;
+ --space-5: 1.25rem;
+ --space-6: 1.5rem;
+ --space-8: 2rem;
+ --space-10: 2.5rem;
+
+ /* Border Radius */
+ --radius-sm: 4px;
+ --radius-md: 6px;
+ --radius-lg: 8px;
+ --radius-xl: 12px;
+ --radius-full: 9999px;
+
+ /* Transitions */
+ --transition-fast: 0.1s ease;
+ --transition-normal: 0.15s ease;
+
+ /* Z-index */
+ --z-sidebar: 100;
+ --z-modal: 500;
+ --z-toast: 600;
+}
+
+/* === Light Theme === */
+[data-theme="light"] {
+ --color-bg: #f5f6f8;
+ --color-surface: #ffffff;
+ --color-surface-raised: #f0f1f3;
+ --color-hover: rgba(0, 0, 0, 0.04);
+ --color-active: rgba(0, 0, 0, 0.06);
+ --color-text: #1a1d26;
+ --color-text-secondary: #4a4f5c;
+ --color-text-muted: #8b919d;
+ --color-border: #e1e3e8;
+ --color-border-light: #ebedf2;
+ --color-accent: #00a884;
+ --color-accent-hover: #00c49a;
+ --color-accent-alpha: rgba(0, 168, 132, 0.12);
+ --color-success: #22863a;
+ --color-warning: #b08800;
+ --color-danger: #cb2431;
+ --color-info: #0366d6;
+}
+
+/* === Reset === */
+*, *::before, *::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+html {
+ font-size: 16px;
+ -webkit-font-smoothing: antialiased;
+}
+
+body {
+ font-family: var(--font-sans);
+ font-size: 1rem;
+ line-height: 1.5;
+ color: var(--color-text);
+ background: var(--color-bg);
+ min-height: 100vh;
+}
+
+a {
+ color: var(--color-accent);
+ text-decoration: none;
+}
+
+a:hover {
+ text-decoration: underline;
+}
+
+button {
+ font: inherit;
+ cursor: pointer;
+ border: none;
+ background: none;
+}
+
+input, textarea, select {
+ font: inherit;
+}
+
+ul, ol {
+ list-style: none;
+}
+
+/* === Scrollbar === */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--color-surface);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--color-border);
+ border-radius: var(--radius-full);
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--color-text-muted);
+}
+
+/* === Layout === */
+#app {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+}
+
+.app-layout {
+ display: flex;
+ flex: 1;
+ overflow: hidden;
+}
+
+/* === Header === */
+.app-header {
+ display: flex;
+ align-items: center;
+ gap: var(--space-4);
+ padding: var(--space-3) var(--space-4);
+ background: var(--color-surface);
+ border-bottom: 1px solid var(--color-border);
+ height: 56px;
+}
+
+.app-header .logo {
+ font-weight: 700;
+ font-size: 1.125rem;
+ color: var(--color-accent);
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.app-header .search-box {
+ flex: 1;
+ max-width: 400px;
+ position: relative;
+}
+
+.app-header .search-box input {
+ width: 100%;
+ padding: var(--space-2) var(--space-4);
+ padding-left: 36px;
+ background: var(--color-bg);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ color: var(--color-text);
+}
+
+.app-header .search-box input:focus {
+ outline: none;
+ border-color: var(--color-accent);
+}
+
+.app-header .search-box .icon {
+ position: absolute;
+ left: 10px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--color-text-muted);
+}
+
+.header-actions {
+ display: flex;
+ gap: var(--space-2);
+ margin-left: auto;
+}
+
+/* === Sidebar === */
+.sidebar {
+ width: 260px;
+ background: var(--color-surface);
+ border-right: 1px solid var(--color-border);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ flex-shrink: 0;
+}
+
+.sidebar-section {
+ padding: var(--space-4);
+ border-bottom: 1px solid var(--color-border);
+}
+
+.sidebar-section h3 {
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--color-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-bottom: var(--space-3);
+}
+
+.sidebar-scroll {
+ flex: 1;
+ overflow-y: auto;
+ padding: var(--space-4);
+}
+
+.library-tree {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+}
+
+.tree-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ color: var(--color-text-secondary);
+ transition: var(--transition-fast);
+}
+
+.tree-item:hover {
+ background: var(--color-hover);
+ color: var(--color-text);
+}
+
+.tree-item.active {
+ background: var(--color-accent-alpha);
+ color: var(--color-accent);
+}
+
+.tree-item .icon {
+ flex-shrink: 0;
+ width: 16px;
+ height: 16px;
+}
+
+.tree-item .label {
+ flex: 1;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.tree-children {
+ padding-left: var(--space-4);
+}
+
+.tree-toggle {
+ width: 16px;
+ height: 16px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ transition: transform var(--transition-fast);
+}
+
+.tree-toggle.expanded {
+ transform: rotate(90deg);
+}
+
+.tag-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+}
+
+.tag-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ color: var(--color-text-secondary);
+ transition: var(--transition-fast);
+}
+
+.tag-item:hover {
+ background: var(--color-hover);
+ color: var(--color-text);
+}
+
+.tag-item.active {
+ background: var(--color-accent-alpha);
+ color: var(--color-accent);
+}
+
+.tag-count {
+ font-size: 0.75rem;
+ color: var(--color-text-muted);
+}
+
+/* === Main Content === */
+.main-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.content-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--space-4) var(--space-6);
+ border-bottom: 1px solid var(--color-border);
+ background: var(--color-surface);
+}
+
+.content-header h1 {
+ font-size: 1.25rem;
+ font-weight: 600;
+}
+
+.content-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: var(--space-6);
+}
+
+/* === Document Cards === */
+.doc-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+ gap: var(--space-4);
+}
+
+.doc-card {
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ padding: var(--space-4);
+ cursor: pointer;
+ transition: var(--transition-fast);
+}
+
+.doc-card:hover {
+ border-color: var(--color-accent);
+ box-shadow: var(--shadow-md);
+}
+
+.doc-card-header {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-3);
+ margin-bottom: var(--space-3);
+}
+
+.doc-id {
+ font-family: var(--font-mono);
+ font-size: 0.75rem;
+ color: var(--color-text-muted);
+ background: var(--color-bg);
+ padding: 2px 6px;
+ border-radius: var(--radius-sm);
+}
+
+.doc-title {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--color-text);
+ flex: 1;
+ line-height: 1.4;
+}
+
+.doc-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+ margin-bottom: var(--space-3);
+}
+
+.tag-pill {
+ font-size: 0.75rem;
+ padding: 2px 8px;
+ background: var(--color-accent-alpha);
+ color: var(--color-accent);
+ border-radius: var(--radius-full);
+}
+
+.doc-meta {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ font-size: 0.75rem;
+ color: var(--color-text-muted);
+}
+
+.doc-meta-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-1);
+}
+
+.status-badge {
+ padding: 2px 8px;
+ border-radius: var(--radius-full);
+ font-size: 0.75rem;
+ font-weight: 500;
+}
+
+.status-badge.draft {
+ background: var(--color-warning-bg);
+ color: var(--color-warning);
+}
+
+.status-badge.approved {
+ background: var(--color-info-bg);
+ color: var(--color-info);
+}
+
+.status-badge.implemented {
+ background: var(--color-success-bg);
+ color: var(--color-success);
+}
+
+.priority-indicator {
+ font-size: 0.875rem;
+}
+
+/* === Document Viewer === */
+.doc-viewer {
+ display: flex;
+ gap: var(--space-6);
+ max-width: 1200px;
+}
+
+.doc-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.doc-sidebar {
+ width: 280px;
+ flex-shrink: 0;
+}
+
+.doc-viewer-header {
+ display: flex;
+ align-items: center;
+ gap: var(--space-4);
+ margin-bottom: var(--space-6);
+}
+
+.back-btn {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-md);
+ color: var(--color-text-secondary);
+ transition: var(--transition-fast);
+}
+
+.back-btn:hover {
+ background: var(--color-hover);
+ color: var(--color-text);
+}
+
+.doc-actions {
+ display: flex;
+ gap: var(--space-2);
+ margin-left: auto;
+}
+
+.doc-actions button {
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-md);
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ color: var(--color-text-secondary);
+ transition: var(--transition-fast);
+}
+
+.doc-actions button:hover {
+ background: var(--color-hover);
+ color: var(--color-text);
+}
+
+.doc-actions button.danger:hover {
+ background: var(--color-danger-hover);
+ color: white;
+}
+
+.prose {
+ color: var(--color-text);
+ line-height: 1.7;
+}
+
+.prose h1 { font-size: 2rem; font-weight: 700; margin: 1.5em 0 0.5em; }
+.prose h2 { font-size: 1.5rem; font-weight: 600; margin: 1.5em 0 0.5em; }
+.prose h3 { font-size: 1.25rem; font-weight: 600; margin: 1.25em 0 0.5em; }
+.prose h4 { font-size: 1rem; font-weight: 600; margin: 1em 0 0.5em; }
+
+.prose p { margin-bottom: 1em; }
+
+.prose ul, .prose ol {
+ margin-bottom: 1em;
+ padding-left: 1.5em;
+}
+
+.prose ul { list-style-type: disc; }
+.prose ol { list-style-type: decimal; }
+
+.prose li { margin-bottom: 0.25em; }
+
+.prose code {
+ font-family: var(--font-mono);
+ font-size: 0.875em;
+ background: var(--color-surface);
+ padding: 0.125em 0.375em;
+ border-radius: var(--radius-sm);
+ color: var(--color-accent);
+}
+
+.prose pre {
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ padding: 1rem;
+ overflow-x: auto;
+ margin-bottom: 1em;
+}
+
+.prose pre code {
+ background: transparent;
+ padding: 0;
+ color: var(--color-text);
+}
+
+.prose blockquote {
+ border-left: 4px solid var(--color-accent);
+ padding-left: 1rem;
+ margin: 1em 0;
+ color: var(--color-text-secondary);
+}
+
+.prose table {
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 1em;
+}
+
+.prose th, .prose td {
+ padding: 0.5rem 0.75rem;
+ border: 1px solid var(--color-border);
+ text-align: left;
+}
+
+.prose th {
+ background: var(--color-surface);
+ font-weight: 600;
+}
+
+.prose input[type="checkbox"] {
+ margin-right: 0.5em;
+ accent-color: var(--color-accent);
+}
+
+.prose hr {
+ border: none;
+ border-top: 1px solid var(--color-border);
+ margin: 2em 0;
+}
+
+/* === Metadata Panel === */
+.meta-section {
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ margin-bottom: var(--space-4);
+}
+
+.meta-header {
+ padding: var(--space-3) var(--space-4);
+ border-bottom: 1px solid var(--color-border);
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--color-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.meta-body {
+ padding: var(--space-4);
+}
+
+.meta-row {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: var(--space-3);
+}
+
+.meta-row:last-child {
+ margin-bottom: 0;
+}
+
+.meta-label {
+ font-size: 0.875rem;
+ color: var(--color-text-muted);
+}
+
+.meta-value {
+ font-size: 0.875rem;
+ color: var(--color-text);
+}
+
+.doc-tags .tag-pill {
+ cursor: pointer;
+}
+
+.doc-tags .tag-pill:hover {
+ opacity: 0.8;
+}
+
+/* === Document Editor === */
+.editor-container {
+ max-width: 900px;
+ margin: 0 auto;
+}
+
+.editor-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--space-4) var(--space-6);
+ border-bottom: 1px solid var(--color-border);
+ background: var(--color-surface);
+}
+
+.editor-header h2 {
+ font-size: 1rem;
+ font-weight: 600;
+}
+
+.editor-form {
+ padding: var(--space-6);
+}
+
+.form-row {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: var(--space-4);
+ margin-bottom: var(--space-4);
+}
+
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.form-group label {
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: var(--color-text-secondary);
+}
+
+.form-group input,
+.form-group select,
+.form-group textarea {
+ padding: var(--space-3);
+ background: var(--color-bg);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ color: var(--color-text);
+}
+
+.form-group input:focus,
+.form-group select:focus,
+.form-group textarea:focus {
+ outline: none;
+ border-color: var(--color-accent);
+}
+
+.form-group textarea {
+ min-height: 300px;
+ resize: vertical;
+ font-family: var(--font-mono);
+ line-height: 1.6;
+}
+
+.form-group.full-width {
+ grid-column: 1 / -1;
+}
+
+.editor-toolbar {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-3);
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-bottom: none;
+ border-radius: var(--radius-lg) var(--radius-lg) 0 0;
+}
+
+.toolbar-btn {
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--radius-sm);
+ color: var(--color-text-secondary);
+ transition: var(--transition-fast);
+}
+
+.toolbar-btn:hover {
+ background: var(--color-hover);
+ color: var(--color-text);
+}
+
+.toolbar-separator {
+ width: 1px;
+ height: 20px;
+ background: var(--color-border);
+ margin: 0 var(--space-2);
+}
+
+.toolbar-tabs {
+ margin-left: auto;
+ display: flex;
+ gap: var(--space-1);
+}
+
+.tab-btn {
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-sm);
+ font-size: 0.875rem;
+ color: var(--color-text-secondary);
+ transition: var(--transition-fast);
+}
+
+.tab-btn:hover {
+ background: var(--color-hover);
+}
+
+.tab-btn.active {
+ background: var(--color-accent-alpha);
+ color: var(--color-accent);
+}
+
+.editor-content {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0;
+ border: 1px solid var(--color-border);
+ border-radius: 0 0 var(--radius-lg) var(--radius-lg);
+ overflow: hidden;
+}
+
+.editor-content.full {
+ grid-template-columns: 1fr;
+}
+
+.editor-pane {
+ padding: var(--space-4);
+ background: var(--color-bg);
+}
+
+.editor-pane:first-child {
+ border-right: 1px solid var(--color-border);
+}
+
+.preview-pane {
+ background: var(--color-surface);
+ overflow-y: auto;
+}
+
+.preview-toggle {
+ margin-left: auto;
+}
+
+/* === Buttons === */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-4);
+ border-radius: var(--radius-md);
+ font-weight: 500;
+ font-size: 0.875rem;
+ transition: var(--transition-fast);
+}
+
+.btn-primary {
+ background: var(--color-accent);
+ color: #000;
+}
+
+.btn-primary:hover {
+ background: var(--color-accent-hover);
+}
+
+.btn-ghost {
+ background: transparent;
+ color: var(--color-text-secondary);
+}
+
+.btn-ghost:hover {
+ background: var(--color-hover);
+ color: var(--color-text);
+}
+
+.btn-danger {
+ background: var(--color-danger);
+ color: white;
+}
+
+.btn-danger:hover {
+ background: var(--color-danger-hover);
+}
+
+.btn-icon-only {
+ width: 36px;
+ height: 36px;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* === Modal === */
+.modal-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.6);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: var(--z-modal);
+ animation: fadeIn 0.15s ease;
+}
+
+.modal {
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow-lg);
+ min-width: 400px;
+ max-width: 90vw;
+ animation: scaleIn 0.15s ease;
+}
+
+.modal-header {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-4);
+ border-bottom: 1px solid var(--color-border);
+}
+
+.modal-header h3 {
+ font-size: 1rem;
+ font-weight: 600;
+ flex: 1;
+}
+
+.modal-body {
+ padding: var(--space-4);
+}
+
+.modal-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: var(--space-3);
+ padding: var(--space-4);
+ border-top: 1px solid var(--color-border);
+}
+
+/* === Login Screen === */
+.login-screen {
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--color-bg);
+}
+
+.login-card {
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-xl);
+ padding: var(--space-8);
+ width: 100%;
+ max-width: 400px;
+ box-shadow: var(--shadow-lg);
+}
+
+.login-card h1 {
+ font-size: 1.5rem;
+ font-weight: 700;
+ margin-bottom: var(--space-2);
+ color: var(--color-accent);
+ text-align: center;
+}
+
+.login-card p {
+ color: var(--color-text-secondary);
+ text-align: center;
+ margin-bottom: var(--space-6);
+ font-size: 0.875rem;
+}
+
+.login-form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+}
+
+.login-form .form-group {
+ gap: var(--space-2);
+}
+
+.login-form input {
+ padding: var(--space-3);
+ background: var(--color-bg);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ color: var(--color-text);
+ font-family: var(--font-mono);
+ font-size: 0.875rem;
+}
+
+.login-form input:focus {
+ outline: none;
+ border-color: var(--color-accent);
+}
+
+.login-error {
+ color: var(--color-danger);
+ font-size: 0.875rem;
+ text-align: center;
+ display: none;
+}
+
+.login-error.visible {
+ display: block;
+}
+
+/* === Toast === */
+#toast-container {
+ position: fixed;
+ bottom: var(--space-6);
+ right: var(--space-6);
+ z-index: var(--z-toast);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+
+.toast {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-3) var(--space-4);
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-lg);
+ animation: slideInRight 0.2s ease;
+ min-width: 250px;
+}
+
+.toast.success {
+ border-left: 4px solid var(--color-success);
+}
+
+.toast.error {
+ border-left: 4px solid var(--color-danger);
+}
+
+.toast.info {
+ border-left: 4px solid var(--color-info);
+}
+
+.toast-message {
+ flex: 1;
+ font-size: 0.875rem;
+}
+
+.toast-close {
+ color: var(--color-text-muted);
+ padding: var(--space-1);
+ border-radius: var(--radius-sm);
+}
+
+.toast-close:hover {
+ background: var(--color-hover);
+ color: var(--color-text);
+}
+
+/* === Empty State === */
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: var(--space-16);
+ text-align: center;
+ color: var(--color-text-muted);
+}
+
+.empty-state .icon {
+ font-size: 3rem;
+ margin-bottom: var(--space-4);
+ opacity: 0.5;
+}
+
+.empty-state h3 {
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: var(--color-text-secondary);
+ margin-bottom: var(--space-2);
+}
+
+.empty-state p {
+ margin-bottom: var(--space-6);
+}
+
+/* === Loading === */
+.loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--space-16);
+ color: var(--color-text-muted);
+}
+
+.loading::before {
+ content: '';
+ width: 24px;
+ height: 24px;
+ border: 2px solid var(--color-border);
+ border-top-color: var(--color-accent);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+ margin-right: var(--space-3);
+}
+
+/* === Breadcrumbs === */
+.breadcrumbs {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-size: 0.875rem;
+ color: var(--color-text-secondary);
+ margin-bottom: var(--space-4);
+}
+
+.breadcrumb-item {
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ transition: var(--transition-fast);
+}
+
+.breadcrumb-item:hover {
+ color: var(--color-accent);
+}
+
+.breadcrumb-separator {
+ color: var(--color-text-muted);
+}
+
+/* === Quick Links === */
+.quick-links {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ margin-top: var(--space-4);
+ padding-top: var(--space-4);
+ border-top: 1px solid var(--color-border);
+}
+
+.quick-link {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-md);
+ color: var(--color-text-secondary);
+ font-size: 0.875rem;
+ cursor: pointer;
+ transition: var(--transition-fast);
+}
+
+.quick-link:hover {
+ background: var(--color-hover);
+ color: var(--color-text);
+}
+
+/* === Type Badges === */
+.type-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 8px;
+ border-radius: var(--radius-sm);
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.type-badge.requirement {
+ background: var(--color-accent-alpha);
+ color: var(--color-requirement);
+}
+
+.type-badge.note {
+ background: var(--color-info-bg);
+ color: var(--color-note);
+}
+
+.type-badge.spec {
+ background: rgba(155, 89, 182, 0.15);
+ color: var(--color-spec);
+}
+
+.type-badge.general {
+ background: var(--color-hover);
+ color: var(--color-text-muted);
+}
+
+/* === Animations === */
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes scaleIn {
+ from { opacity: 0; transform: scale(0.95); }
+ to { opacity: 1; transform: scale(1); }
+}
+
+@keyframes slideInRight {
+ from { opacity: 0; transform: translateX(100%); }
+ to { opacity: 1; transform: translateX(0); }
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* === Responsive === */
+@media (max-width: 1024px) {
+ .sidebar {
+ display: none;
+ }
+
+ .doc-sidebar {
+ display: none;
+ }
+
+ .editor-content {
+ grid-template-columns: 1fr;
+ }
+
+ .editor-pane:first-child {
+ border-right: none;
+ }
+
+ .preview-pane {
+ display: none;
+ }
+
+ .editor-content.show-preview .editor-pane:first-child {
+ display: none;
+ }
+
+ .editor-content.show-preview .preview-pane {
+ display: block;
+ }
+}
+
+@media (max-width: 768px) {
+ .app-header .search-box {
+ display: none;
+ }
+
+ .content-body {
+ padding: var(--space-4);
+ }
+
+ .doc-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .modal {
+ min-width: auto;
+ margin: var(--space-4);
+ }
+
+ .login-card {
+ margin: var(--space-4);
+ padding: var(--space-6);
+ }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..01feca5
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+ SimpleNote
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/js/api.js b/public/js/api.js
new file mode 100644
index 0000000..43c6fb6
--- /dev/null
+++ b/public/js/api.js
@@ -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();
diff --git a/public/js/app.js b/public/js/app.js
new file mode 100644
index 0000000..c7c8833
--- /dev/null
+++ b/public/js/app.js
@@ -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 = `
+ ${message}
+
+ `;
+ 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 = `
+
+ `;
+ 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');
+ }
+});
diff --git a/public/js/components/modal.js b/public/js/components/modal.js
new file mode 100644
index 0000000..01ad11a
--- /dev/null
+++ b/public/js/components/modal.js
@@ -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 = `
+
+ `;
+ 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();
+ }
+}
diff --git a/public/js/components/sidebar.js b/public/js/components/sidebar.js
new file mode 100644
index 0000000..87389da
--- /dev/null
+++ b/public/js/components/sidebar.js
@@ -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 `
+
+
+
+ ${hasChildren ? '▶' : ''}
+
+ 📁
+ ${escapeHtml(lib.name)}
+
+ ${hasChildren ? `
${buildLibraryTree(libraries, lib.id, depth + 1)}
` : ''}
+
+ `;
+ })
+ .join('');
+ };
+
+ return `
+
+
+ `;
+}
+
+function escapeHtml(str) {
+ if (!str) return '';
+ const div = document.createElement('div');
+ div.textContent = str;
+ return div.innerHTML;
+}
diff --git a/public/js/views/dashboard.js b/public/js/views/dashboard.js
new file mode 100644
index 0000000..cd99acd
--- /dev/null
+++ b/public/js/views/dashboard.js
@@ -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 = `
+
+
+ ${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();
+ }
+ })}
+
+
+
+ ${filteredDocs.length === 0 ? `
+
+
📄
+
No documents found
+
${searchQuery || selectedTag ? 'Try adjusting your filters' : 'Create your first document'}
+
+
+ ` : `
+
+ ${filteredDocs.map(doc => renderDocCard(doc)).join('')}
+
+ `}
+
+
+
+ `;
+
+ // 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 `
+
+
+
${escapeHtml(doc.title)}
+ ${doc.tags && doc.tags.length ? `
+
+ ${doc.tags.map(t => `${escapeHtml(t)}`).join('')}
+
+ ` : ''}
+
+ 📅 ${formatDate(doc.createdAt)}
+ 👤 ${escapeHtml(doc.author || 'unknown')}
+ ${doc.status || 'draft'}
+ ${priorityEmoji[priority]}
+
+
+ `;
+}
+
+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' });
+}
diff --git a/public/js/views/document.js b/public/js/views/document.js
new file mode 100644
index 0000000..4106cb5
--- /dev/null
+++ b/public/js/views/document.js
@@ -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 = `
+
+
+
+
+ `;
+
+ 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, '$1
')
+ .replace(/^## (.+)$/gm, '$1
')
+ .replace(/^# (.+)$/gm, '$1
')
+ .replace(/\*\*(.+?)\*\*/g, '$1')
+ .replace(/\*(.+?)\*/g, '$1')
+ .replace(/`(.+?)`/g, '$1')
+ .replace(/^- (.+)$/gm, '$1')
+ .replace(/(.*<\/li>)/s, '')
+ .replace(/\n\n/g, '
')
+ .replace(/^(.+)$/gm, (match) => {
+ if (match.startsWith('<')) return match;
+ return `
${match}
`;
+ });
+}
diff --git a/public/js/views/editor.js b/public/js/views/editor.js
new file mode 100644
index 0000000..d01d6db
--- /dev/null
+++ b/public/js/views/editor.js
@@ -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 = `
+
+
+
+
+ `;
+
+ // 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 'Nothing to preview
';
+
+ if (typeof marked !== 'undefined') {
+ return marked.parse(content);
+ }
+
+ // Fallback
+ return content
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/^### (.+)$/gm, '$1
')
+ .replace(/^## (.+)$/gm, '$1
')
+ .replace(/^# (.+)$/gm, '$1
')
+ .replace(/\*\*(.+?)\*\*/g, '$1')
+ .replace(/\*(.+?)\*/g, '$1')
+ .replace(/`(.+?)`/g, '$1')
+ .replace(/\n\n/g, '');
+}
diff --git a/public/js/views/login.js b/public/js/views/login.js
new file mode 100644
index 0000000..274283e
--- /dev/null
+++ b/public/js/views/login.js
@@ -0,0 +1,29 @@
+// Login View
+
+export function renderLogin({ onLogin }) {
+ return `
+
+
+
📝 SimpleNote
+
Enter your API token to continue
+
+
+
+
+ `;
+}
diff --git a/src/index.js b/src/index.js
index 0791c73..9aec29c 100644
--- a/src/index.js
+++ b/src/index.js
@@ -5,6 +5,8 @@
import express from 'express';
import cors from 'cors';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
import config from './config/index.js';
import { createApiRouter } from './routes/index.js';
import { errorHandler } from './middleware/errorHandler.js';
@@ -13,6 +15,9 @@ import { ensureDir } from './utils/fsHelper.js';
const app = express();
+// Serve static files from public/
+app.use(express.static(join(dirname(fileURLToPath(import.meta.url)), '..', 'public')));
+
// Middleware
app.use(cors({ origin: config.corsOrigin }));
app.use(express.json({ limit: '10mb' }));