diff --git a/public/css/style.css b/public/css/style.css
index a9b539f..a8c118f 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -1293,3 +1293,311 @@ ul, ol {
padding: var(--space-6);
}
}
+
+/* === Projects Page === */
+.projects-page {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: var(--space-6);
+}
+
+.projects-header {
+ margin-bottom: var(--space-8);
+}
+
+.projects-header h1 {
+ font-size: 2rem;
+ font-weight: 700;
+ margin-bottom: var(--space-2);
+}
+
+.projects-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
+ gap: var(--space-4);
+}
+
+.project-card {
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ padding: var(--space-5);
+ cursor: pointer;
+ transition: var(--transition-fast);
+}
+
+.project-card:hover {
+ border-color: var(--color-accent);
+ box-shadow: var(--shadow-md);
+ transform: translateY(-2px);
+}
+
+.project-card-header {
+ display: flex;
+ gap: var(--space-4);
+ margin-bottom: var(--space-4);
+}
+
+.project-icon {
+ font-size: 2rem;
+ flex-shrink: 0;
+}
+
+.project-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.project-name {
+ font-size: 1.125rem;
+ font-weight: 600;
+ margin-bottom: var(--space-2);
+ color: var(--color-text);
+}
+
+.project-description {
+ font-size: 0.875rem;
+ color: var(--color-text-secondary);
+ margin: 0;
+ line-height: 1.5;
+}
+
+.project-card-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-4);
+ font-size: 0.8125rem;
+ color: var(--color-text-muted);
+}
+
+.project-card-meta .meta-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-1);
+}
+
+/* === Breadcrumb Nav === */
+.breadcrumb-nav {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-size: 0.875rem;
+ margin-left: var(--space-4);
+}
+
+.breadcrumb-link {
+ color: var(--color-text-muted);
+ cursor: pointer;
+ transition: var(--transition-fast);
+}
+
+.breadcrumb-link:hover {
+ color: var(--color-accent);
+}
+
+.breadcrumb-sep {
+ color: var(--color-text-muted);
+}
+
+.breadcrumb-current {
+ color: var(--color-text);
+ font-weight: 500;
+}
+
+/* === Project Sidebar === */
+.project-sidebar .sidebar-scroll {
+ padding: var(--space-3);
+}
+
+.project-sidebar .sidebar-section {
+ padding: var(--space-3);
+ border-bottom: none;
+}
+
+.project-sidebar .section-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: var(--space-3);
+}
+
+.project-sidebar .section-header h3 {
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--color-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin: 0;
+}
+
+.project-sidebar .btn-icon-only {
+ width: 24px;
+ height: 24px;
+ font-size: 1rem;
+ color: var(--color-text-muted);
+}
+
+.project-sidebar .btn-icon-only:hover {
+ color: var(--color-accent);
+}
+
+/* === Folder Tree === */
+.folder-tree {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.tree-node {
+ display: flex;
+ flex-direction: column;
+}
+
+.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);
+ position: relative;
+}
+
+.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;
+ font-size: 0.875rem;
+}
+
+.tree-item .label {
+ flex: 1;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-size: 0.875rem;
+}
+
+.tree-item .count {
+ font-size: 0.75rem;
+ color: var(--color-text-muted);
+ background: var(--color-bg);
+ padding: 1px 6px;
+ border-radius: var(--radius-full);
+}
+
+.tree-item .tree-action {
+ opacity: 0;
+ width: 20px;
+ height: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ font-size: 0.875rem;
+ color: var(--color-text-muted);
+ cursor: pointer;
+ transition: var(--transition-fast);
+}
+
+.tree-item:hover .tree-action {
+ opacity: 1;
+}
+
+.tree-item .tree-action:hover {
+ background: var(--color-accent);
+ border-color: var(--color-accent);
+ color: #000;
+}
+
+.tree-children {
+ padding-left: var(--space-4);
+ margin-left: var(--space-3);
+ border-left: 1px solid var(--color-border);
+}
+
+/* === Content Header Extensions === */
+.content-header-left {
+ display: flex;
+ align-items: baseline;
+ gap: var(--space-3);
+}
+
+.content-header-left h1 {
+ font-size: 1.25rem;
+ font-weight: 600;
+}
+
+.doc-count {
+ font-size: 0.875rem;
+ color: var(--color-text-muted);
+}
+
+.content-header-right {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+}
+
+.search-box-inline {
+ position: relative;
+ display: flex;
+ align-items: center;
+}
+
+.search-box-inline input {
+ padding: var(--space-2) var(--space-3);
+ padding-left: 32px;
+ background: var(--color-bg);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ color: var(--color-text);
+ font-size: 0.875rem;
+ width: 200px;
+}
+
+.search-box-inline input:focus {
+ outline: none;
+ border-color: var(--color-accent);
+}
+
+.search-box-inline .icon {
+ position: absolute;
+ left: 10px;
+ color: var(--color-text-muted);
+}
+
+/* === Doc Card Actions === */
+.doc-card {
+ position: relative;
+}
+
+.doc-card-actions {
+ position: absolute;
+ top: var(--space-3);
+ right: var(--space-3);
+ opacity: 0;
+ transition: var(--transition-fast);
+}
+
+.doc-card:hover .doc-card-actions {
+ opacity: 1;
+}
+
+.doc-card-actions .btn-icon-only {
+ width: 28px;
+ height: 28px;
+ font-size: 0.875rem;
+}
diff --git a/public/js/api.js b/public/js/api.js
index e7148d8..e1f1190 100644
--- a/public/js/api.js
+++ b/public/js/api.js
@@ -115,6 +115,54 @@ class ApiClient {
getTags() {
return this.get('/tags');
}
+
+ // ===== PROJECTS =====
+ getProjects() {
+ return this.get('/projects');
+ }
+
+ getProject(id) {
+ return this.get(`/projects/${id}`);
+ }
+
+ createProject(data) {
+ return this.post('/projects', data);
+ }
+
+ updateProject(id, data) {
+ return this.put(`/projects/${id}`, data);
+ }
+
+ deleteProject(id) {
+ return this.delete(`/projects/${id}`);
+ }
+
+ getProjectTree(id) {
+ return this.get(`/projects/${id}/tree`);
+ }
+
+ // ===== FOLDERS =====
+ getFolders(projectId, parentId = null) {
+ const query = parentId ? `?parentId=${parentId}` : '';
+ return this.get(`/projects/${projectId}/folders${query}`);
+ }
+
+ createFolder(data) {
+ return this.post('/folders', data);
+ }
+
+ updateFolder(id, data) {
+ return this.put(`/folders/${id}`, data);
+ }
+
+ deleteFolder(id) {
+ return this.delete(`/folders/${id}`);
+ }
+
+ // ===== Move document to folder =====
+ moveDocumentToFolder(documentId, folderId) {
+ return this.put(`/documents/${documentId}`, { folderId });
+ }
}
export const api = new ApiClient();
diff --git a/public/js/app.js b/public/js/app.js
index b54a801..d4d3361 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -2,6 +2,8 @@
import { api } from './api.js';
import { renderLogin, initLoginHandlers } from './views/login.js';
+import { renderProjects } from './views/projects.js';
+import { renderProjectView } from './views/projectView.js';
import { renderDashboard } from './views/dashboard.js';
import { renderDocument } from './views/document.js';
import { renderEditor } from './views/editor.js';
@@ -11,7 +13,7 @@ class App {
this.currentView = null;
this.state = {
token: localStorage.getItem('sn_token'),
- view: 'dashboard',
+ view: 'projects', // Default to projects view
params: {}
};
}
@@ -41,7 +43,7 @@ class App {
try {
await api.login(token);
this.state.token = token;
- this.state.view = 'dashboard';
+ this.state.view = 'projects';
this.render();
} catch (e) {
return 'Invalid token';
@@ -53,6 +55,12 @@ class App {
const app = document.getElementById('app');
switch (this.state.view) {
+ case 'projects':
+ await renderProjects(this);
+ break;
+ case 'project':
+ await renderProjectView(this);
+ break;
case 'dashboard':
await renderDashboard(this);
break;
@@ -63,7 +71,7 @@ class App {
renderEditor(this);
break;
default:
- await renderDashboard(this);
+ await renderProjects(this);
}
}
@@ -139,10 +147,14 @@ app.init();
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && window.app.state.view === 'editor') {
- window.app.navigate('dashboard');
+ window.app.navigate('project', { id: window.app.state.params.projectId });
}
- if ((e.ctrlKey || e.metaKey) && e.key === 'n' && window.app.state.view === 'dashboard') {
+ if ((e.ctrlKey || e.metaKey) && e.key === 'n' && (window.app.state.view === 'projects' || window.app.state.view === 'project')) {
e.preventDefault();
- window.showNewDocModal();
+ if (window.app.state.view === 'project') {
+ window.showNewDocModal(window.app.state.params.id, '');
+ } else {
+ window.showNewProjectModal();
+ }
}
});
diff --git a/public/js/components/sidebar.js b/public/js/components/sidebar.js
index ea5cfbf..643c8f4 100644
--- a/public/js/components/sidebar.js
+++ b/public/js/components/sidebar.js
@@ -58,6 +58,7 @@ export function renderSidebar({ libraries, tags, selectedLibrary, selectedTag, o
diff --git a/public/js/views/projectView.js b/public/js/views/projectView.js
new file mode 100644
index 0000000..d7621a3
--- /dev/null
+++ b/public/js/views/projectView.js
@@ -0,0 +1,480 @@
+// Project View - Shows a single project with folder tree
+
+import { api } from '../api.js';
+import { renderSidebar } from '../components/sidebar.js';
+
+export async function renderProjectView(app) {
+ const projectId = app.state.params.id;
+ let project = null;
+ let folders = [];
+ let documents = [];
+ let tags = [];
+ let selectedFolderId = null;
+ let selectedTag = null;
+ let searchQuery = '';
+
+ try {
+ const [projResult, docsResult, tagsResult] = await Promise.all([
+ api.getProject(projectId),
+ api.getDocuments({ projectId }),
+ api.getTags()
+ ]);
+ project = projResult;
+ documents = docsResult.documents || [];
+ tags = tagsResult.tags || [];
+
+ // Get folders
+ try {
+ const foldersResult = await api.getFolders(projectId);
+ folders = foldersResult.folders || [];
+ } catch (e) {
+ folders = [];
+ }
+ } catch (e) {
+ app.showToast('Failed to load project', 'error');
+ app.navigate('projects');
+ return;
+ }
+
+ const appEl = document.getElementById('app');
+
+ function render() {
+ // Build folder tree
+ const folderTree = buildFolderTree(folders, null);
+
+ // Filter documents
+ 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 (selectedFolderId !== null) {
+ filteredDocs = filteredDocs.filter(d => d.folderId === selectedFolderId);
+ }
+
+ if (selectedTag) {
+ filteredDocs = filteredDocs.filter(d =>
+ d.tags && d.tags.includes(selectedTag)
+ );
+ }
+
+ // Determine current folder name
+ let currentFolderName = 'All Documents';
+ if (selectedFolderId !== null) {
+ const folder = folders.find(f => f.id === selectedFolderId);
+ if (folder) currentFolderName = folder.name;
+ } else if (selectedTag) {
+ currentFolderName = `#${selectedTag}`;
+ }
+
+ appEl.innerHTML = `
+
+
+
+
+
+
+ ${filteredDocs.length === 0 ? `
+
+
📄
+
No documents found
+
${searchQuery || selectedTag ? 'Try adjusting your filters' : 'Create your first document in this project'}
+ ${!searchQuery && !selectedTag ? `
` : ''}
+
+ ` : `
+
+ ${filteredDocs.map(doc => renderDocCard(doc, projectId)).join('')}
+
+ `}
+
+
+
+ `;
+
+ // Attach event listeners
+ attachEventListeners();
+ }
+
+ function attachEventListeners() {
+ // Search
+ const searchInput = document.getElementById('search-input');
+ searchInput.oninput = (e) => {
+ searchQuery = e.target.value;
+ render();
+ };
+
+ // Folder selection
+ document.querySelectorAll('[data-action="folder"]').forEach(el => {
+ el.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const folderId = el.getAttribute('data-folder-id');
+ selectedFolderId = folderId === '' ? null : folderId;
+ selectedTag = null;
+ render();
+ });
+ });
+
+ // Tag selection
+ document.querySelectorAll('[data-action="tag"]').forEach(el => {
+ el.addEventListener('click', (e) => {
+ e.stopPropagation();
+ selectedTag = el.getAttribute('data-tag');
+ selectedFolderId = null;
+ render();
+ });
+ });
+ }
+
+ function buildFolderTree(folders, parentId, depth = 0) {
+ return folders
+ .filter(f => f.parentId === parentId)
+ .map(folder => {
+ const children = folders.filter(f => f.parentId === folder.id);
+ const hasChildren = children.length > 0;
+ const isSelected = selectedFolderId === folder.id;
+ const docCount = documents.filter(d => d.folderId === folder.id).length;
+
+ return `
+
+
+
+ ${hasChildren ? '▶' : ''}
+
+ 📁
+ ${escapeHtml(folder.name)}
+ ${docCount}
+
+
+ ${hasChildren ? `
${buildFolderTree(folders, folder.id, depth + 1)}
` : ''}
+
+ `;
+ })
+ .join('');
+ }
+
+ render();
+}
+
+function renderDocCard(doc, projectId) {
+ 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' });
+}
+
+// Global function: Show modal to create new folder
+window.showNewFolderModal = async function(projectId, parentId) {
+ let folders = [];
+ try {
+ const result = await api.getFolders(projectId);
+ folders = result.folders || [];
+ } catch (e) {}
+
+ const backdrop = document.createElement('div');
+ backdrop.className = 'modal-backdrop';
+ backdrop.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ document.body.appendChild(backdrop);
+
+ const nameInput = document.getElementById('new-folder-name');
+ const parentSelect = document.getElementById('new-folder-parent');
+ const createBtn = document.getElementById('create-folder-btn');
+
+ // Pre-select parent if provided
+ if (parentId) {
+ parentSelect.value = parentId;
+ }
+
+ createBtn.onclick = async () => {
+ const name = nameInput.value.trim();
+ if (!name) {
+ window.app.showToast('Please enter a folder name', 'error');
+ return;
+ }
+ try {
+ await api.createFolder({
+ name,
+ projectId,
+ parentId: parentSelect.value || null
+ });
+ backdrop.remove();
+ window.app.showToast('Folder created', 'success');
+ window.app.navigate('project', { id: projectId });
+ } catch (e) {
+ window.app.showToast('Failed to create folder: ' + e.message, 'error');
+ }
+ };
+
+ backdrop.onclick = (e) => {
+ if (e.target === backdrop) backdrop.remove();
+ };
+
+ nameInput.focus();
+};
+
+// Global function: Edit project modal
+window.showEditProjectModal = async function(projectId) {
+ let project = null;
+ try {
+ project = await api.getProject(projectId);
+ } catch (e) {
+ window.app.showToast('Failed to load project', 'error');
+ return;
+ }
+
+ const backdrop = document.createElement('div');
+ backdrop.className = 'modal-backdrop';
+ backdrop.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ document.body.appendChild(backdrop);
+
+ const nameInput = document.getElementById('edit-project-name');
+ const descInput = document.getElementById('edit-project-description');
+ const saveBtn = document.getElementById('save-project-btn');
+
+ saveBtn.onclick = async () => {
+ const name = nameInput.value.trim();
+ if (!name) {
+ window.app.showToast('Please enter a project name', 'error');
+ return;
+ }
+ try {
+ await api.updateProject(projectId, {
+ name,
+ description: descInput.value.trim()
+ });
+ backdrop.remove();
+ window.app.showToast('Project updated', 'success');
+ window.app.navigate('project', { id: projectId });
+ } catch (e) {
+ window.app.showToast('Failed to update project: ' + e.message, 'error');
+ }
+ };
+
+ backdrop.onclick = (e) => {
+ if (e.target === backdrop) backdrop.remove();
+ };
+
+ nameInput.focus();
+ nameInput.select();
+};
+
+// Global function: Confirm delete project
+window.confirmDeleteProject = async function(projectId) {
+ const confirmed = await window.app.confirmDelete('Delete this project? All documents and folders will be deleted.');
+ if (!confirmed) return;
+
+ try {
+ await api.deleteProject(projectId);
+ window.app.showToast('Project deleted', 'success');
+ window.app.navigate('projects');
+ } catch (e) {
+ window.app.showToast('Failed to delete project: ' + e.message, 'error');
+ }
+};
+
+// Global function: Move document to folder modal
+window.showMoveToFolderModal = async function(documentId) {
+ const currentProjectId = window.app.state.params.id;
+
+ let folders = [];
+ try {
+ const result = await api.getFolders(currentProjectId);
+ folders = result.folders || [];
+ } catch (e) {}
+
+ // Also get current document to show its current folder
+ let currentDoc = null;
+ try {
+ currentDoc = await api.getDocument(documentId);
+ } catch (e) {}
+
+ const backdrop = document.createElement('div');
+ backdrop.className = 'modal-backdrop';
+ backdrop.innerHTML = `
+
+
+
+
Select a folder for this document:
+
+
+
+
+
+
+ `;
+ document.body.appendChild(backdrop);
+
+ const select = document.getElementById('move-folder-select');
+ const moveBtn = document.getElementById('move-folder-btn');
+
+ // Pre-select current folder if any
+ if (currentDoc && currentDoc.folderId) {
+ select.value = currentDoc.folderId;
+ }
+
+ moveBtn.onclick = async () => {
+ try {
+ await api.moveDocumentToFolder(documentId, select.value || null);
+ backdrop.remove();
+ window.app.showToast('Document moved', 'success');
+ window.app.navigate('project', { id: currentProjectId });
+ } catch (e) {
+ window.app.showToast('Failed to move document: ' + e.message, 'error');
+ }
+ };
+
+ backdrop.onclick = (e) => {
+ if (e.target === backdrop) backdrop.remove();
+ };
+};
diff --git a/public/js/views/projects.js b/public/js/views/projects.js
new file mode 100644
index 0000000..4cdcffb
--- /dev/null
+++ b/public/js/views/projects.js
@@ -0,0 +1,140 @@
+// Projects List View - Shows all projects
+
+import { api } from '../api.js';
+
+export async function renderProjects(app) {
+ let projects = [];
+
+ try {
+ const result = await api.getProjects();
+ projects = result.projects || [];
+ } catch (e) {
+ app.showToast('Failed to load projects', 'error');
+ }
+
+ const appEl = document.getElementById('app');
+
+ function render() {
+ appEl.innerHTML = `
+
+
+
+
+ ${projects.length === 0 ? `
+
+
📋
+
No projects yet
+
Create your first project to get started
+
+
+ ` : projects.map(project => renderProjectCard(project)).join('')}
+
+
+ `;
+ }
+
+ render();
+}
+
+function renderProjectCard(project) {
+ const createdDate = formatDate(project.createdAt);
+ const docCount = project.documentCount || 0;
+ const folderCount = project.folderCount || 0;
+
+ return `
+
+
+
+ 📄 ${docCount} docs
+ 📁 ${folderCount} folders
+ 📅 ${createdDate}
+
+
+ `;
+}
+
+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' });
+}
+
+// Global function: Show modal to create new project
+window.showNewProjectModal = function() {
+ const backdrop = document.createElement('div');
+ backdrop.className = 'modal-backdrop';
+ backdrop.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ document.body.appendChild(backdrop);
+
+ const nameInput = document.getElementById('new-project-name');
+ const descInput = document.getElementById('new-project-description');
+ const createBtn = document.getElementById('create-project-btn');
+
+ createBtn.onclick = async () => {
+ const name = nameInput.value.trim();
+ if (!name) {
+ window.app.showToast('Please enter a project name', 'error');
+ return;
+ }
+ try {
+ await api.createProject({
+ name,
+ description: descInput.value.trim()
+ });
+ backdrop.remove();
+ window.app.showToast('Project created successfully', 'success');
+ window.app.navigate('projects');
+ } catch (e) {
+ window.app.showToast('Failed to create project: ' + e.message, 'error');
+ }
+ };
+
+ backdrop.onclick = (e) => {
+ if (e.target === backdrop) backdrop.remove();
+ };
+
+ nameInput.focus();
+};