// Thin fetch wrapper around the backend API + shared UI helpers. const API = { base: '/api', async request(method, path, body) { const opts = { method, headers: {} }; if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } const res = await fetch(this.base + path, opts); if (res.status === 204) return null; const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`); return data; }, // Projects listProjects() { return this.request('GET', '/projects'); }, getProject(id) { return this.request('GET', `/projects/${id}`); }, createProject(p) { return this.request('POST', '/projects', p); }, updateProject(id, p) { return this.request('PUT', `/projects/${id}`, p); }, deleteProject(id) { return this.request('DELETE', `/projects/${id}`); }, // Tickets listTickets(params = {}) { const qs = new URLSearchParams(params).toString(); return this.request('GET', `/tickets${qs ? '?' + qs : ''}`); }, getTicket(id) { return this.request('GET', `/tickets/${id}`); }, createTicket(t) { return this.request('POST', '/tickets', t); }, updateTicket(id, t) { return this.request('PUT', `/tickets/${id}`, t); }, deleteTicket(id) { return this.request('DELETE', `/tickets/${id}`); }, deleteTicketImage(id, url) { return this.request('DELETE', `/tickets/${id}/images`, { url }); }, async uploadImages(id, files) { const fd = new FormData(); for (const f of files) fd.append('images', f); const res = await fetch(`${this.base}/tickets/${id}/images`, { method: 'POST', body: fd }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || 'Upload failed'); return data; } }; // ---------- UI helpers ---------- const UI = { el(html) { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; }, esc(s) { return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); }, fmtDate(v) { if (!v) return '—'; const d = new Date(v); if (isNaN(d)) return UI.esc(v); return d.toLocaleString(); }, toast(msg, isError = false) { const t = document.getElementById('toast'); t.textContent = msg; t.className = 'toast' + (isError ? ' error' : ''); t.hidden = false; clearTimeout(this._toastTimer); this._toastTimer = setTimeout(() => (t.hidden = true), 2600); }, openModal(html, wide = false) { const overlay = document.getElementById('modal-overlay'); const modal = document.getElementById('modal'); modal.className = 'modal' + (wide ? ' wide' : ''); modal.innerHTML = ''; modal.appendChild(typeof html === 'string' ? UI.el(html) : html); overlay.hidden = false; return modal; }, closeModal() { document.getElementById('modal-overlay').hidden = true; }, lightbox(url) { const lb = UI.el(`