feat: Add Projects and Folders UI (SimpleNote v2)

- New Projects view (projects.js): Lists all projects with cards
- New ProjectView (projectView.js): Project dashboard with folder tree
- Updated API client: Projects and Folders CRUD methods
- New modals: NewProjectModal, NewFolderModal, MoveToFolderModal
- Edit/Delete project functionality
- Updated navigation: ProjectList -> ProjectView -> FolderView
- Consistent dark theme styling

Changes:
- public/js/views/projects.js (NEW)
- public/js/views/projectView.js (NEW)
- public/js/api.js (added Projects/Folders API methods)
- public/js/app.js (added navigation routes)
- public/js/components/sidebar.js (added Projects link)
- public/css/style.css (added project/folder styles)
This commit is contained in:
Hiro
2026-03-28 13:03:23 +00:00
parent d7bb018c83
commit 9496fc8e36
6 changed files with 995 additions and 6 deletions

View File

@@ -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 = `
<header class="app-header">
<div class="logo">📝 SimpleNote</div>
<div class="breadcrumb-nav">
<span class="breadcrumb-link" onclick="window.app.navigate('projects')">Projects</span>
<span class="breadcrumb-sep">/</span>
<span class="breadcrumb-current">${escapeHtml(project.name)}</span>
</div>
<div class="header-actions">
<button class="btn btn-ghost" onclick="window.showEditProjectModal('${project.id}')" title="Edit Project">✏️</button>
<button class="btn btn-ghost" onclick="window.confirmDeleteProject('${project.id}')" title="Delete Project">🗑️</button>
</div>
</header>
<div class="app-layout">
<aside class="sidebar project-sidebar">
<div class="sidebar-scroll">
<div class="sidebar-section">
<div class="section-header">
<h3>📁 Folders</h3>
<button class="btn btn-ghost btn-icon-only" onclick="window.showNewFolderModal('${projectId}', null)" title="New Folder">+</button>
</div>
<div class="folder-tree">
<div class="tree-item ${selectedFolderId === null && !selectedTag ? 'active' : ''}" data-action="folder" data-folder-id="">
<span class="icon">📋</span>
<span class="label">All Documents</span>
<span class="count">${documents.length}</span>
</div>
${folderTree}
</div>
</div>
<div class="sidebar-section">
<h3>🏷️ Tags</h3>
<div class="tag-list">
${tags.map(tag => `
<div class="tag-item ${selectedTag === tag.name ? 'active' : ''}" data-action="tag" data-tag="${escapeHtml(tag.name)}">
<span>#${escapeHtml(tag.name)}</span>
<span class="tag-count">${tag.count}</span>
</div>
`).join('')}
</div>
</div>
<div class="quick-links">
<a class="quick-link" href="#" onclick="window.app.navigate('projects'); return false;">📋 All Projects</a>
<a class="quick-link" href="#" onclick="window.showNewDocModal('${projectId}', '${selectedFolderId || ''}'); return false;">📄 New Document</a>
</div>
</div>
</aside>
<main class="main-content">
<div class="content-header">
<div class="content-header-left">
<h1>${escapeHtml(currentFolderName)}</h1>
<span class="doc-count">${filteredDocs.length} document${filteredDocs.length !== 1 ? 's' : ''}</span>
</div>
<div class="content-header-right">
<div class="search-box-inline">
<span class="icon">🔍</span>
<input type="text" id="search-input" placeholder="Search documents..." value="${escapeHtml(searchQuery)}">
</div>
<button class="btn btn-primary" onclick="window.showNewDocModal('${projectId}', '${selectedFolderId || ''}')">+ New Document</button>
</div>
</div>
<div class="content-body">
${filteredDocs.length === 0 ? `
<div class="empty-state">
<div class="icon">📄</div>
<h3>No documents found</h3>
<p>${searchQuery || selectedTag ? 'Try adjusting your filters' : 'Create your first document in this project'}</p>
${!searchQuery && !selectedTag ? `<button class="btn btn-primary" onclick="window.showNewDocModal('${projectId}', '${selectedFolderId || ''}')">+ Create Document</button>` : ''}
</div>
` : `
<div class="doc-grid">
${filteredDocs.map(doc => renderDocCard(doc, projectId)).join('')}
</div>
`}
</div>
</main>
</div>
`;
// 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 `
<div class="tree-node">
<div class="tree-item ${isSelected ? 'active' : ''}" data-action="folder" data-folder-id="${folder.id}">
<span class="tree-toggle ${hasChildren ? 'expanded' : ''}" style="padding-left: ${depth * 12}px">
${hasChildren ? '▶' : ''}
</span>
<span class="icon">📁</span>
<span class="label">${escapeHtml(folder.name)}</span>
<span class="count">${docCount}</span>
<button class="tree-action" onclick="event.stopPropagation(); window.showNewFolderModal('${projectId}', '${folder.id}')" title="New subfolder">+</button>
</div>
${hasChildren ? `<div class="tree-children">${buildFolderTree(folders, folder.id, depth + 1)}</div>` : ''}
</div>
`;
})
.join('');
}
render();
}
function renderDocCard(doc, projectId) {
const priorityEmoji = { high: '🔴', medium: '🟡', low: '🟢' };
const priority = doc.priority || 'medium';
return `
<div class="doc-card" onclick="window.app.navigate('document', {id: '${doc.id}', projectId: '${projectId}'})">
<div class="doc-card-header">
<span class="doc-id">${doc.id}</span>
<span class="type-badge ${doc.type || 'general'}">${doc.type || 'general'}</span>
</div>
<h3 class="doc-title">${escapeHtml(doc.title)}</h3>
${doc.tags && doc.tags.length ? `
<div class="doc-tags">
${doc.tags.map(t => `<span class="tag-pill">${escapeHtml(t)}</span>`).join('')}
</div>
` : ''}
<div class="doc-meta">
<span class="doc-meta-item">📅 ${formatDate(doc.createdAt)}</span>
<span class="doc-meta-item">👤 ${escapeHtml(doc.author || 'unknown')}</span>
<span class="status-badge ${doc.status || 'draft'}">${doc.status || 'draft'}</span>
<span class="priority-indicator">${priorityEmoji[priority]}</span>
</div>
<div class="doc-card-actions">
<button class="btn btn-ghost btn-icon-only" onclick="event.stopPropagation(); window.showMoveToFolderModal('${doc.id}')" title="Move to folder">📁</button>
</div>
</div>
`;
}
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
// 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 = `
<div class="modal" style="min-width: 450px;">
<div class="modal-header">
<span>📁</span>
<h3>Create New Folder</h3>
<button class="modal-close" onclick="this.closest('.modal-backdrop').remove()">✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="new-folder-name">Folder Name</label>
<input type="text" id="new-folder-name" class="form-control" placeholder="e.g., API Documentation">
</div>
<div class="form-group" style="margin-top: 16px;">
<label for="new-folder-parent">Parent Folder (optional)</label>
<select id="new-folder-parent" class="form-control">
<option value="">— Root (no parent) —</option>
${folders.map(f => `<option value="${f.id}">📁 ${escapeHtml(f.name)}</option>`).join('')}
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="this.closest('.modal-backdrop').remove()">Cancel</button>
<button class="btn btn-primary" id="create-folder-btn">Create Folder</button>
</div>
</div>
`;
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 = `
<div class="modal" style="min-width: 450px;">
<div class="modal-header">
<span>✏️</span>
<h3>Edit Project</h3>
<button class="modal-close" onclick="this.closest('.modal-backdrop').remove()">✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="edit-project-name">Project Name</label>
<input type="text" id="edit-project-name" class="form-control" value="${escapeHtml(project.name)}">
</div>
<div class="form-group" style="margin-top: 16px;">
<label for="edit-project-description">Description</label>
<textarea id="edit-project-description" class="form-control" rows="3" style="resize: vertical;">${escapeHtml(project.description || '')}</textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="this.closest('.modal-backdrop').remove()">Cancel</button>
<button class="btn btn-primary" id="save-project-btn">Save Changes</button>
</div>
</div>
`;
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 = `
<div class="modal" style="min-width: 450px;">
<div class="modal-header">
<span>📁</span>
<h3>Move to Folder</h3>
<button class="modal-close" onclick="this.closest('.modal-backdrop').remove()">✕</button>
</div>
<div class="modal-body">
<p style="color: var(--color-text-secondary); margin-bottom: 16px;">Select a folder for this document:</p>
<div class="form-group">
<select id="move-folder-select" class="form-control">
<option value="">— No Folder (Root) —</option>
${folders.map(f => `<option value="${f.id}">📁 ${escapeHtml(f.name)}</option>`).join('')}
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="this.closest('.modal-backdrop').remove()">Cancel</button>
<button class="btn btn-primary" id="move-folder-btn">Move</button>
</div>
</div>
`;
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();
};
};

140
public/js/views/projects.js Normal file
View File

@@ -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 = `
<header class="app-header">
<div class="logo">📝 SimpleNote</div>
<div class="header-actions">
<button class="btn btn-primary" onclick="window.showNewProjectModal()">+ New Project</button>
</div>
</header>
<div class="projects-page">
<div class="projects-header">
<h1>Projects</h1>
<p class="text-muted">Organize your documents into projects and folders</p>
</div>
<div class="projects-grid">
${projects.length === 0 ? `
<div class="empty-state">
<div class="icon">📋</div>
<h3>No projects yet</h3>
<p>Create your first project to get started</p>
<button class="btn btn-primary" onclick="window.showNewProjectModal()">+ Create Project</button>
</div>
` : projects.map(project => renderProjectCard(project)).join('')}
</div>
</div>
`;
}
render();
}
function renderProjectCard(project) {
const createdDate = formatDate(project.createdAt);
const docCount = project.documentCount || 0;
const folderCount = project.folderCount || 0;
return `
<div class="project-card" onclick="window.app.navigate('project', {id: '${project.id}'})">
<div class="project-card-header">
<div class="project-icon">📋</div>
<div class="project-info">
<h3 class="project-name">${escapeHtml(project.name)}</h3>
${project.description ? `<p class="project-description">${escapeHtml(project.description)}</p>` : ''}
</div>
</div>
<div class="project-card-meta">
<span class="meta-item">📄 ${docCount} docs</span>
<span class="meta-item">📁 ${folderCount} folders</span>
<span class="meta-item">📅 ${createdDate}</span>
</div>
</div>
`;
}
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
// Global function: Show modal to create new project
window.showNewProjectModal = function() {
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop';
backdrop.innerHTML = `
<div class="modal" style="min-width: 450px;">
<div class="modal-header">
<span>📋</span>
<h3>Create New Project</h3>
<button class="modal-close" onclick="this.closest('.modal-backdrop').remove()">✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="new-project-name">Project Name</label>
<input type="text" id="new-project-name" class="form-control" placeholder="e.g., Backend Requirements">
</div>
<div class="form-group" style="margin-top: 16px;">
<label for="new-project-description">Description (optional)</label>
<textarea id="new-project-description" class="form-control" placeholder="Brief description of the project..." rows="3" style="resize: vertical;"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="this.closest('.modal-backdrop').remove()">Cancel</button>
<button class="btn btn-primary" id="create-project-btn">Create Project</button>
</div>
</div>
`;
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();
};