File size: 3,393 Bytes
30a3fac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
// 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) => ({
      '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
    })[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(`<div class="lightbox"><img src="${UI.esc(url)}" alt="screenshot" /></div>`);
    lb.addEventListener('click', () => lb.remove());
    document.body.appendChild(lb);
  }
};

// Close modal on overlay click
document.addEventListener('click', (e) => {
  if (e.target.id === 'modal-overlay') UI.closeModal();
});