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(); + } + })} +
+
+

${selectedLibrary ? getLibraryName(libraries, selectedLibrary) : selectedTag ? `#${selectedTag}` : 'All Documents'}

+
+ +
+
+
+ ${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 ` +
+
+ ${doc.id} + ${doc.type || 'general'} +
+

${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 = ` +
+ +
+ + + +
+
+
+
+
+
+
+ ${doc.id} + ${doc.type || 'general'} +
+
${renderedContent}
+
+ +
+
+
+ `; + + 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 = ` +
    + + ${isNew ? 'New Document' : 'Editing: ' + escapeHtml(formData.title)} + +
    +
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    +
    + + + + + + + + + + + + +
    + + +
    +
    +
    +
    + +
    +
    ${renderMarkdown(formData.content)}
    +
    +
    +
    +
    +
    + `; + + // 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 ` +

    + +
    + + `; +} 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' }));