feat(ui-phase-15): project switcher — localStorage CRUD, first-run modal

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 <noreply@anthropic.com>
This commit is contained in:
Paul Huliganga 2026-04-21 11:24:40 -04:00
parent 516af313a1
commit 85f82eaabf
1 changed files with 197 additions and 0 deletions

197
web/static/js/project.js Normal file
View File

@ -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 = `
<div class="modal-backdrop"></div>
<div class="modal-box modal-sm">
<div class="modal-header">
<span class="modal-title">Switch Project</span>
<button class="modal-close" id="pm-close"></button>
</div>
<div class="modal-body" style="padding-bottom:0">
<div class="project-list" id="pm-project-list"></div>
<div class="new-project-form">
<h4>New Project</h4>
<div class="form-group">
<input type="text" class="form-input" id="pm-new-name"
placeholder="Customer name (e.g. Acme Corp)" maxlength="60" />
<div class="form-error" id="pm-error"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="pm-cancel">Cancel</button>
<button class="btn btn-primary" id="pm-create">Create Project</button>
</div>
</div>
`;
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 = `<p style="font-size:13px;color:var(--text-muted);margin-bottom:8px">
No projects yet. Create one below to get started.
</p>`;
return;
}
list.innerHTML = projects.map(p => `
<div class="project-row ${p.id === active?.id ? 'active' : ''}" data-id="${escHtml(p.id)}">
<div class="project-row-icon">${escHtml(p.name.slice(0, 2).toUpperCase())}</div>
<div style="flex:1">
<div class="project-row-name">${escHtml(p.name)}</div>
<div class="project-row-sub">Created ${formatDate(p.createdAt)}</div>
</div>
${p.id === active?.id
? '<span class="project-row-active-badge">● Active</span>'
: `<button class="btn btn-secondary btn-xs pm-delete-btn" data-id="${escHtml(p.id)}" title="Delete project">✕</button>`
}
</div>
`).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();
}