From 85f82eaabf46492cdfc6d3149d7ae23d9f625a86 Mon Sep 17 00:00:00 2001 From: Paul Huliganga Date: Tue, 21 Apr 2026 11:24:40 -0400 Subject: [PATCH] =?UTF-8?q?feat(ui-phase-15):=20project=20switcher=20?= =?UTF-8?q?=E2=80=94=20localStorage=20CRUD,=20first-run=20modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Projects stored in localStorage (key: migrator_projects). CRUD: create, list, setActive, delete. Switcher modal opens automatically on first run when no projects exist. Active project name displayed in nav footer and project button. Deleting a project requires confirmation. Co-Authored-By: Claude Sonnet 4.6 --- web/static/js/project.js | 197 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 web/static/js/project.js diff --git a/web/static/js/project.js b/web/static/js/project.js new file mode 100644 index 0000000..4b18901 --- /dev/null +++ b/web/static/js/project.js @@ -0,0 +1,197 @@ +// Project / customer context — localStorage CRUD + switcher modal + +import { escHtml, uuid, formatDate } from './utils.js'; + +const STORAGE_KEY = 'migrator_projects'; + +// ── Data model ───────────────────────────────────────────────────────────── +// localStorage schema: +// { active: string|null, projects: Array<{ id, name, createdAt }> } + +function load() { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY)) || { active: null, projects: [] }; + } catch { + return { active: null, projects: [] }; + } +} + +function save(data) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); +} + +export function listProjects() { + return load().projects; +} + +export function getActive() { + const data = load(); + return data.projects.find(p => p.id === data.active) || null; +} + +export function createProject(name) { + const data = load(); + const project = { id: uuid(), name: name.trim(), createdAt: new Date().toISOString() }; + data.projects.push(project); + if (!data.active) data.active = project.id; + save(data); + return project; +} + +export function deleteProject(id) { + const data = load(); + data.projects = data.projects.filter(p => p.id !== id); + if (data.active === id) { + data.active = data.projects[0]?.id || null; + } + save(data); +} + +export function setActive(id) { + const data = load(); + if (data.projects.find(p => p.id === id)) { + data.active = id; + save(data); + } +} + +// ── Init: called on app startup ───────────────────────────────────────────── +// onUpdate callback is called whenever the active project changes + +let _onUpdate = null; + +export function initProject(onUpdate) { + _onUpdate = onUpdate; + onUpdate(); + // Show project modal on first run if no projects exist + if (listProjects().length === 0) { + showProjectModal(onUpdate); + } +} + +// ── Project switcher modal ───────────────────────────────────────────────── + +export function showProjectModal(onUpdate) { + if (onUpdate) _onUpdate = onUpdate; + + const existing = document.getElementById('project-modal'); + if (existing) existing.remove(); + + const wrapper = document.createElement('div'); + wrapper.id = 'project-modal'; + wrapper.innerHTML = ` + + + `; + document.body.appendChild(wrapper); + + renderProjectList(); + + document.getElementById('pm-close').onclick = () => wrapper.remove(); + document.getElementById('pm-cancel').onclick = () => wrapper.remove(); + document.getElementById('pm-create').onclick = handleCreate; + document.getElementById('pm-new-name').addEventListener('keydown', e => { + if (e.key === 'Enter') handleCreate(); + }); + + // Focus name input + setTimeout(() => document.getElementById('pm-new-name')?.focus(), 50); +} + +function renderProjectList() { + const list = document.getElementById('pm-project-list'); + const active = getActive(); + const projects = listProjects(); + + if (!projects.length) { + list.innerHTML = `

+ No projects yet. Create one below to get started. +

`; + return; + } + + list.innerHTML = projects.map(p => ` +
+
${escHtml(p.name.slice(0, 2).toUpperCase())}
+
+
${escHtml(p.name)}
+
Created ${formatDate(p.createdAt)}
+
+ ${p.id === active?.id + ? '● Active' + : `` + } +
+ `).join(''); + + // Activate on row click + list.querySelectorAll('.project-row').forEach(row => { + row.addEventListener('click', e => { + if (e.target.classList.contains('pm-delete-btn')) return; + activateProject(row.dataset.id); + }); + }); + + // Delete buttons + list.querySelectorAll('.pm-delete-btn').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + handleDelete(btn.dataset.id); + }); + }); +} + +function activateProject(id) { + setActive(id); + if (_onUpdate) _onUpdate(); + const modal = document.getElementById('project-modal'); + if (modal) modal.remove(); +} + +function handleDelete(id) { + const project = listProjects().find(p => p.id === id); + if (!project) return; + if (!confirm(`Delete project "${project.name}"? This cannot be undone.`)) return; + deleteProject(id); + if (_onUpdate) _onUpdate(); + renderProjectList(); +} + +function handleCreate() { + const input = document.getElementById('pm-new-name'); + const errorEl = document.getElementById('pm-error'); + const name = input?.value.trim(); + + if (!name) { + if (errorEl) errorEl.textContent = 'Project name is required.'; + input?.focus(); + return; + } + if (errorEl) errorEl.textContent = ''; + + const project = createProject(name); + setActive(project.id); + if (_onUpdate) _onUpdate(); + const modal = document.getElementById('project-modal'); + if (modal) modal.remove(); +}