New task
diff --git a/js/app.js b/js/app.js
index 9bfd993..72cd751 100644
--- a/js/app.js
+++ b/js/app.js
@@ -77,6 +77,7 @@ const SUBJECT_COLORS = [
];
let selectedNewSubjectColor = SUBJECT_COLORS[0];
+let editingSubjectId = null;
function escapeHtml(s) {
return String(s)
@@ -87,6 +88,7 @@ function escapeHtml(s) {
}
const newSubjectModal = document.getElementById('new-subject-modal');
+const subjectModalTitle = document.getElementById('subject-modal-title');
const newSubjectName = document.getElementById('new-subject-name');
const newSubjectColorsEl = document.getElementById('new-subject-colors');
const newSubjectCancel = document.getElementById('new-subject-cancel');
@@ -102,15 +104,88 @@ function syncNewSubjectColorSwatches() {
});
}
-function openNewSubjectModal() {
+function openNewSubjectModal(subject = null) {
if (!newSubjectModal || !newSubjectName) return;
- newSubjectName.value = '';
- selectedNewSubjectColor = SUBJECT_COLORS[0];
+
+ if (subject) {
+ editingSubjectId = subject.id;
+ subjectModalTitle.textContent = 'Edit Subject';
+ newSubjectName.value = subject.name;
+ selectedNewSubjectColor = subject.color || SUBJECT_COLORS[0];
+ } else {
+ editingSubjectId = null;
+ subjectModalTitle.textContent = 'New Subject';
+ newSubjectName.value = '';
+ selectedNewSubjectColor = SUBJECT_COLORS[0];
+ }
+
syncNewSubjectColorSwatches();
newSubjectModal.style.display = 'flex';
newSubjectName.focus();
}
+const subjectDetailsModal = document.getElementById('subject-details-modal');
+const detailModalSubjectName = document.getElementById('detail-modal-subject-name');
+const subjectDetailsList = document.getElementById('subject-details-list');
+const detailTypeSelect = document.getElementById('detail-type-select');
+const detailContentInput = document.getElementById('detail-content-input');
+const addDetailBtn = document.getElementById('add-detail-btn');
+const subjectDetailsClose = document.getElementById('subject-details-close');
+let activeDetailSubjectId = null;
+
+async function openSubjectDetails(subjectId) {
+ const subject = store.subjects.find(s => s.id === subjectId);
+ if (!subject) return;
+
+ activeDetailSubjectId = subjectId;
+ detailModalSubjectName.textContent = subject.name;
+ subjectDetailsModal.style.display = 'flex';
+ renderSubjectDetails();
+}
+
+async function renderSubjectDetails() {
+ if (!activeDetailSubjectId) return;
+ const details = await store.fetchSubjectDetails(activeDetailSubjectId);
+
+ if (details.length === 0) {
+ subjectDetailsList.innerHTML = '
No topics or resources yet.
';
+ } else {
+ subjectDetailsList.innerHTML = details.map(d => {
+ const icon = d.type === 'topic' ? '📚' : (d.type === 'link' ? '🔗' : '📝');
+ const isCompleted = d.is_completed === 1;
+ return `
+
+
${icon}
+
+
+ ${d.type === 'topic' ? `` : ''}
+
+
+
+ `;
+ }).join('');
+
+ // Attach listeners
+ subjectDetailsList.querySelectorAll('.toggle-detail-completion').forEach(cb => {
+ cb.addEventListener('change', async (e) => {
+ await store.updateSubjectDetail(e.target.dataset.id, { is_completed: e.target.checked ? 1 : 0 });
+ renderSubjectDetails();
+ });
+ });
+
+ subjectDetailsList.querySelectorAll('.delete-detail-btn').forEach(btn => {
+ btn.addEventListener('click', async (e) => {
+ if (confirm('Delete this item?')) {
+ await store.deleteSubjectDetail(e.target.dataset.id);
+ renderSubjectDetails();
+ }
+ });
+ });
+ }
+}
+
function renderSidebarSubjects() {
const listEl = document.getElementById('subjects-sidebar-list');
if (!listEl) return;
@@ -131,9 +206,43 @@ function renderSidebarSubjects() {
const n = countBySubject[s.id] ?? 0;
const safeColor = s.color ? escapeHtml(s.color) : 'var(--color-text-info)';
return ``;
}).join('');
+
+ // Attach sidebar listeners
+ listEl.querySelectorAll('.subject-sidebar-item').forEach(el => {
+ const subjectId = el.dataset.subjectId;
+
+ el.addEventListener('mouseenter', () => {
+ el.querySelector('.subject-actions').style.display = 'flex';
+ });
+ el.addEventListener('mouseleave', () => {
+ el.querySelector('.subject-actions').style.display = 'none';
+ });
+
+ el.addEventListener('click', (e) => {
+ if (e.target.closest('.subject-actions')) return;
+ openSubjectDetails(subjectId);
+ });
+
+ el.querySelector('.edit-subject-btn').addEventListener('click', (e) => {
+ e.stopPropagation();
+ const subject = store.subjects.find(s => s.id === subjectId);
+ openNewSubjectModal(subject);
+ });
+
+ el.querySelector('.delete-subject-btn').addEventListener('click', (e) => {
+ e.stopPropagation();
+ store.deleteSubject(subjectId);
+ });
+ });
}
const newTaskModal = document.getElementById('new-task-modal');
@@ -897,11 +1006,38 @@ document.addEventListener('DOMContentLoaded', () => {
if (newSubjectSave) {
newSubjectSave.addEventListener('click', async () => {
- const ok = await store.addSubject({ name: newSubjectName.value, color: selectedNewSubjectColor });
+ const payload = { name: newSubjectName.value, color: selectedNewSubjectColor };
+ let ok = false;
+ if (editingSubjectId) {
+ ok = await store.updateSubject(editingSubjectId, payload);
+ } else {
+ ok = await store.addSubject(payload);
+ }
if (ok && newSubjectModal) newSubjectModal.style.display = 'none';
});
}
+ if (subjectDetailsClose) {
+ subjectDetailsClose.addEventListener('click', () => {
+ subjectDetailsModal.style.display = 'none';
+ activeDetailSubjectId = null;
+ });
+ }
+
+ if (addDetailBtn) {
+ addDetailBtn.addEventListener('click', async () => {
+ const type = detailTypeSelect.value;
+ const content = detailContentInput.value.trim();
+ if (!content) return;
+
+ const ok = await store.addSubjectDetail(activeDetailSubjectId, { type, content });
+ if (ok) {
+ detailContentInput.value = '';
+ renderSubjectDetails();
+ }
+ });
+ }
+
if (newSubjectName) {
newSubjectName.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
diff --git a/js/store.js b/js/store.js
index 5d83807..b4b601c 100644
--- a/js/store.js
+++ b/js/store.js
@@ -36,10 +36,18 @@ export const store = {
async addSubject({ name, color }) {
const trimmed = String(name || '').trim();
- if (!trimmed) {
- alert('Please enter a subject name');
+ if (!trimmed || trimmed.length < 2) {
+ alert('Subject name must be at least 2 characters');
return false;
}
+
+ // Check for local duplicates before network request
+ const exists = this.subjects.some(s => s.name.toLowerCase() === trimmed.toLowerCase());
+ if (exists) {
+ alert('Subject already exists');
+ return false;
+ }
+
try {
const res = await fetch('/api/subjects', {
method: 'POST',
@@ -62,6 +70,91 @@ export const store = {
}
},
+ async updateSubject(id, { name, color }) {
+ try {
+ const res = await fetch(`/api/subjects/${id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name, color })
+ });
+ if (!res.ok) throw new Error('Update failed');
+ const subsRes = await fetch('/api/subjects');
+ this.subjects = await subsRes.json();
+ this.notify();
+ return true;
+ } catch (e) {
+ console.error('Failed to update subject', e);
+ return false;
+ }
+ },
+
+ async deleteSubject(id) {
+ if (!confirm('Are you sure? This will delete all tasks and resources for this subject.')) return;
+ try {
+ const res = await fetch(`/api/subjects/${id}`, { method: 'DELETE' });
+ if (!res.ok) throw new Error('Delete failed');
+
+ const [subsRes, tasksRes] = await Promise.all([
+ fetch('/api/subjects'),
+ fetch('/api/tasks')
+ ]);
+ this.subjects = await subsRes.json();
+ this.tasks = await tasksRes.json();
+ this.notify();
+ } catch (e) {
+ console.error('Failed to delete subject', e);
+ }
+ },
+
+ async fetchSubjectDetails(id) {
+ try {
+ const res = await fetch(`/api/subjects/${id}/details`);
+ return await res.json();
+ } catch (e) {
+ console.error('Failed to fetch subject details', e);
+ return [];
+ }
+ },
+
+ async addSubjectDetail(subjectId, { type, content }) {
+ try {
+ const res = await fetch(`/api/subjects/${subjectId}/details`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ type, content })
+ });
+ if (!res.ok) throw new Error('Add detail failed');
+ return await res.json();
+ } catch (e) {
+ console.error('Failed to add subject detail', e);
+ return null;
+ }
+ },
+
+ async updateSubjectDetail(detailId, fields) {
+ try {
+ const res = await fetch(`/api/subjects/details/${detailId}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(fields)
+ });
+ return res.ok;
+ } catch (e) {
+ console.error('Failed to update detail', e);
+ return false;
+ }
+ },
+
+ async deleteSubjectDetail(detailId) {
+ try {
+ const res = await fetch(`/api/subjects/details/${detailId}`, { method: 'DELETE' });
+ return res.ok;
+ } catch (e) {
+ console.error('Failed to delete detail', e);
+ return false;
+ }
+ },
+
// ================= UPDATED FUNCTION =================
async addTasks(newTasks) {
try {
diff --git a/server.js b/server.js
index 877f766..0c3e758 100644
--- a/server.js
+++ b/server.js
@@ -240,6 +240,13 @@ app.get('/api/subjects', (req, res) => {
});
});
+app.get('/api/subjects/:id/details', (req, res) => {
+ db.all('SELECT * FROM subject_details WHERE subject_id = ?', [req.params.id], (err, rows) => {
+ if (err) return res.status(500).json({ error: err.message });
+ res.json(rows);
+ });
+});
+
const ALLOWED_SUBJECT_COLORS = new Set([
'var(--color-text-info)',
'var(--color-text-success)',
@@ -252,24 +259,102 @@ const ALLOWED_SUBJECT_COLORS = new Set([
app.post('/api/subjects', (req, res) => {
const name = String(req.body?.name || '').trim();
let color = String(req.body?.color || '').trim() || 'var(--color-text-info)';
- if (!name) {
- return res.status(400).json({ error: 'Subject name is required' });
+
+ if (!name || name.length < 2) {
+ return res.status(400).json({ error: 'Subject name must be at least 2 characters' });
}
- if (!ALLOWED_SUBJECT_COLORS.has(color)) {
- color = 'var(--color-text-info)';
+
+ // Check for duplicates
+ db.get('SELECT id FROM subjects WHERE LOWER(name) = LOWER(?)', [name], (err, row) => {
+ if (err) return res.status(500).json({ error: err.message });
+ if (row) return res.status(400).json({ error: 'Subject already exists' });
+
+ if (!ALLOWED_SUBJECT_COLORS.has(color)) {
+ color = 'var(--color-text-info)';
+ }
+ const shortCode = name.replace(/[^a-zA-Z0-9]/g, '').toUpperCase().slice(0, 4) || 'SUB';
+ const id = `sub_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
+ db.run(
+ 'INSERT INTO subjects (id, name, short_code, color) VALUES (?, ?, ?, ?)',
+ [id, name, shortCode, color],
+ function (err) {
+ if (err) return res.status(500).json({ error: err.message });
+ res.status(201).json({ id, name, short_code: shortCode, color });
+ }
+ );
+ });
+});
+
+app.put('/api/subjects/:id', (req, res) => {
+ const { name, color } = req.body;
+ if (!name || name.length < 2) {
+ return res.status(400).json({ error: 'Subject name must be at least 2 characters' });
}
+
const shortCode = name.replace(/[^a-zA-Z0-9]/g, '').toUpperCase().slice(0, 4) || 'SUB';
- const id = `sub_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
+
+ db.run(
+ 'UPDATE subjects SET name = ?, color = ?, short_code = ? WHERE id = ?',
+ [name, color, shortCode, req.params.id],
+ function (err) {
+ if (err) return res.status(500).json({ error: err.message });
+ res.json({ success: true, changes: this.changes });
+ }
+ );
+});
+
+app.delete('/api/subjects/:id', (req, res) => {
+ db.run('DELETE FROM subjects WHERE id = ?', [req.params.id], function (err) {
+ if (err) return res.status(500).json({ error: err.message });
+ // Also delete tasks associated with this subject
+ db.run('DELETE FROM tasks WHERE subject_id = ?', [req.params.id]);
+ // Also delete details
+ db.run('DELETE FROM subject_details WHERE subject_id = ?', [req.params.id]);
+ res.json({ success: true, changes: this.changes });
+ });
+});
+
+app.post('/api/subjects/:id/details', (req, res) => {
+ const { type, content } = req.body;
+ if (!type || !content) return res.status(400).json({ error: 'Type and content are required' });
+
+ const id = `det_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
db.run(
- 'INSERT INTO subjects (id, name, short_code, color) VALUES (?, ?, ?, ?)',
- [id, name, shortCode, color],
+ 'INSERT INTO subject_details (id, subject_id, type, content) VALUES (?, ?, ?, ?)',
+ [id, req.params.id, type, content],
function (err) {
if (err) return res.status(500).json({ error: err.message });
- res.status(201).json({ id, name, short_code: shortCode, color });
+ res.status(201).json({ id, type, content });
}
);
});
+app.delete('/api/subjects/details/:detailId', (req, res) => {
+ db.run('DELETE FROM subject_details WHERE id = ?', [req.params.detailId], function (err) {
+ if (err) return res.status(500).json({ error: err.message });
+ res.json({ success: true });
+ });
+});
+
+app.put('/api/subjects/details/:detailId', (req, res) => {
+ const { is_completed, content } = req.body;
+ let query = 'UPDATE subject_details SET ';
+ const params = [];
+ const updates = [];
+ if (is_completed !== undefined) { updates.push('is_completed = ?'); params.push(is_completed); }
+ if (content !== undefined) { updates.push('content = ?'); params.push(content); }
+
+ if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });
+
+ query += updates.join(', ') + ' WHERE id = ?';
+ params.push(req.params.detailId);
+
+ db.run(query, params, function (err) {
+ if (err) return res.status(500).json({ error: err.message });
+ res.json({ success: true });
+ });
+});
+
// ================= TASKS =================
app.get('/api/tasks', (req, res) => {
db.all('SELECT * FROM tasks ORDER BY due_at ASC', (err, rows) => {