async function loadProjectTree() { const tree = document.getElementById('projectTree'); if (!tree) return; tree.innerHTML = '
Loading projects...
'; const result = await apiGet('/api/project.php', { api: 'ListProjects' }); if (!result.success) { tree.innerHTML = '
Could not load projects.
'; return; } if (!result.projects.length) { tree.innerHTML = '
No projects yet.
'; return; } tree.innerHTML = ''; taskLookupCache.projectBlocksById.clear(); for (const projectId of result.projects) { const project = await getProjectInfo(projectId); const projectBlock = document.createElement('div'); projectBlock.dataset.projectId = projectId; const projectButton = document.createElement('button'); projectButton.className = 'kiln-tree-project'; projectButton.innerHTML = ` ${escapeHtml(project?.name ?? projectId)} `; projectButton.addEventListener('click', (event) => { if (event.target.closest('[data-project-caret]')) { toggleProject(projectId, projectBlock); return; } loadProject(projectId); if (!projectBlock.querySelector('.kiln-tree-group')) { toggleProject(projectId, projectBlock, true); } }); projectBlock.appendChild(projectButton); tree.appendChild(projectBlock); taskLookupCache.projectBlocksById.set(projectId, projectBlock); } if (currentTask?.project) { const projectBlock = taskLookupCache.projectBlocksById.get(currentTask.project); if (projectBlock && !projectBlock.querySelector('.kiln-tree-group')) { await toggleProject(currentTask.project, projectBlock, true); } } } async function getProjectInfo(projectId) { if (taskLookupCache.projectsById.has(projectId)) { return taskLookupCache.projectsById.get(projectId); } const result = await apiGet('/api/project.php', { api: 'ProjectInfo', project_id: projectId }); const project = result.success ? result.project : null; taskLookupCache.projectsById.set(projectId, project); return project; } async function ensureProjectOpen(projectId) { await projectTreePromise; const projectBlock = taskLookupCache.projectBlocksById.get(projectId); if (!projectBlock || projectBlock.querySelector('.kiln-tree-group')) { return; } await toggleProject(projectId, projectBlock, true); } async function toggleProject(projectId, projectBlock, forceOpen = false) { let group = projectBlock.querySelector('.kiln-tree-group'); if (group) { if (forceOpen) return; group.remove(); setProjectCaret(projectBlock, false); return; } setProjectCaret(projectBlock, true); group = document.createElement('div'); group.className = 'kiln-tree-group'; group.innerHTML = '
Loading...
'; projectBlock.appendChild(group); const versions = await apiGet('/api/version.php', { api: 'ListVersions', project_id: projectId }); group.innerHTML = ''; const versionGroup = document.createElement('div'); versionGroup.className = 'kiln-tree-version-group'; group.appendChild(versionGroup); const versionLabel = document.createElement('div'); versionLabel.className = 'kiln-tree-label'; versionLabel.textContent = 'Versions'; versionGroup.appendChild(versionLabel); if (document.querySelector('.kiln-app')?.dataset.canCreateVersions === '1') { const createVersion = document.createElement('button'); createVersion.className = 'kiln-tree-version'; createVersion.innerHTML = 'Create new version'; createVersion.addEventListener('click', () => openVersionCreatePopup(projectId)); versionGroup.appendChild(createVersion); } if (versions.success && versions.versions.length) { for (const versionId of versions.versions) { const versionInfo = await apiGet('/api/version.php', { api: 'VersionInfo', version_id: versionId }); const versionButton = document.createElement('button'); versionButton.className = 'kiln-tree-version'; if (versionInfo.success && versionInfo.version) { versionButton.textContent = versionInfo.version.name; } else { versionButton.textContent = `Version ${versionId}`; } versionButton.addEventListener('click', () => loadVersion(versionId)); versionGroup.appendChild(versionButton); } } const showTasksButton = document.createElement('button'); showTasksButton.className = 'kiln-tree-version'; showTasksButton.innerHTML = 'Show all tasks'; showTasksButton.addEventListener('click', (event) => { event.stopPropagation(); loadProjectTasks(projectId); }); group.appendChild(showTasksButton); } function setProjectCaret(projectBlock, isOpen) { const caret = projectBlock.querySelector('[data-project-caret]'); if (!caret) return; caret.classList.toggle('fa-caret-right', !isOpen); caret.classList.toggle('fa-caret-down', isOpen); } async function loadProject(projectId, pushHistory = true) { const view = showView('projectView'); if (!view) return; if (pushHistory) { const url = new URL(window.location.href); url.searchParams.set('page', 'home'); url.searchParams.set('project', projectId); url.searchParams.delete('tasks'); url.searchParams.delete('task'); url.searchParams.delete('version'); url.searchParams.delete('profile'); url.searchParams.delete('admin'); window.history.pushState({}, '', url); } const result = await apiGet('/api/project.php', { api: 'ProjectInfo', project_id: projectId, _: Date.now() }); if (!result.success) { view.innerHTML = '
Project not found.
'; return; } await renderProject(result.project); await ensureProjectOpen(result.project.id); } async function renderProject(project) { currentProject = project; taskLookupCache.projectsById.set(project.id, project); const owner = project.owner ? await getUserInfo(project.owner) : null; setVersionEditableText('projectKey', project.id); setVersionEditableText('projectName', project.name, project.name); setVersionEditableText('projectInlineName', project.name, project.name); setText('projectId', project.id); setEditableHtml('projectOwner', renderUser(owner), project.owner ?? '', 'No owner'); setText('projectCreated', project.created_date || '-'); } async function populateProjectOwnerSelect(selectedOwner = '') { populateSelect(document.getElementById('projectFormOwner'), await getUsers(), { includeEmpty: false, selectedValue: selectedOwner }); } function resetProjectPopup() { const form = document.getElementById('createProjectForm'); if (!form) return; form.reset(); form.dataset.mode = 'create'; document.getElementById('projectPopupTitle').textContent = 'Create Project'; document.getElementById('projectPopupSubmit').textContent = 'Create Project'; document.getElementById('projectFormId').disabled = false; document.getElementById('projectFormId').value = ''; document.getElementById('projectFormName').value = ''; } async function openProjectCreatePopup() { resetProjectPopup(); await populateProjectOwnerSelect(getCurrentUserId()); openPopup('createProject'); } async function openProjectEditPopup() { if (!currentProject) return; resetProjectPopup(); const form = document.getElementById('createProjectForm'); if (!form) return; form.dataset.mode = 'edit'; document.getElementById('projectPopupTitle').textContent = `Edit ${currentProject.id}`; document.getElementById('projectPopupSubmit').textContent = 'Update Project'; document.getElementById('projectFormId').value = currentProject.id; document.getElementById('projectFormId').disabled = true; document.getElementById('projectFormName').value = currentProject.name ?? ''; await populateProjectOwnerSelect(currentProject.owner ?? ''); openPopup('createProject'); } function initProjectInlineEditing() { if (!canEditProjects()) return; document.querySelectorAll('.project-editable').forEach((element) => { if (element.dataset.inlineReady === 'true') return; element.dataset.inlineReady = 'true'; element.title = 'Click to edit'; element.addEventListener('click', () => openProjectInlineEditor(element)); element.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openProjectInlineEditor(element); } }); }); } async function openProjectInlineEditor(element) { if (!canEditProjects() || !currentProject || element.querySelector('.version-inline-form')) return; const field = element.dataset.projectField; const currentValue = element.dataset.currentValue ?? ''; const originalHtml = element.innerHTML; const originalClasses = Array.from(element.classList); if (!['name', 'owner'].includes(field)) return; element.classList.remove('is-empty'); element.innerHTML = ''; const form = document.createElement('form'); form.className = 'version-inline-form'; let input; if (field === 'owner') { input = document.createElement('select'); input.className = 'form-select version-inline-input'; populateSelect(input, await getUsers(), { includeEmpty: false, selectedValue: currentValue }); } else { input = document.createElement('input'); input.className = 'form-control version-inline-input'; input.type = 'text'; input.required = true; input.maxLength = 128; input.value = currentValue; } form.appendChild(input); const actions = document.createElement('div'); actions.className = 'version-inline-actions'; actions.innerHTML = ` `; form.appendChild(actions); element.appendChild(form); input.focus(); if (input.select && input.tagName !== 'SELECT') { input.select(); } let closed = false; const restore = () => { if (closed) return; closed = true; element.innerHTML = originalHtml; element.className = originalClasses.join(' '); }; const save = async () => { const nextValue = field === 'name' ? input.value.trim() : input.value; if (nextValue === currentValue) { restore(); return; } const result = await apiPost('/api/project.php', { api: 'Edit', project_id: currentProject.id }, { [field]: nextValue }); if (!result.success) { alert(result.error || 'Could not update project.'); restore(); return; } taskLookupCache.projectsById.delete(currentProject.id); closed = true; await loadProject(currentProject.id, false); projectTreePromise = loadProjectTree(); }; form.addEventListener('submit', async (event) => { event.preventDefault(); await save(); }); if (input.tagName === 'SELECT') { input.addEventListener('change', save); } form.querySelector('[data-project-inline-cancel]')?.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); restore(); }); input.addEventListener('keydown', async (event) => { if (event.key === 'Escape') { event.preventDefault(); restore(); } }); }