1
0
mirror of git://f0xx.org/ac/ac-platform-web synced 2026-07-29 03:38:33 +03:00
Files
ac-platform-web/assets/js/app.js
Anton Afanasyeu aef9e33b73 initial
2026-06-23 12:29:29 +02:00

2068 lines
66 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
function t(key, params) {
return window.CrashI18n ? window.CrashI18n.t(key, params) : key;
}
const STORAGE = {
theme: 'crash_console_theme',
perPage: 'crash_console_per_page',
lastVisit: 'crash_console_last_visit_ms',
viewed: 'crash_console_viewed_ids',
sort: 'crash_console_sort',
skipStamp: 'crash_skip_visit_stamp',
colWidths: 'crash_console_col_widths',
colOrder: 'crash_console_col_order',
};
const DEFAULT_COL_WIDTHS = {
expand: 40,
device_model: 128,
app_version: 80,
os_name: 88,
os_version: 120,
received_at_ms: 152,
rating: 92,
tags: 128,
};
function tagNew() {
return { label: t('tag.new'), bg: '#3d8bfd' };
}
const LIST_COLUMNS = [
{ key: 'device_model', labelKey: 'col.device' },
{ key: 'app_version', labelKey: 'col.app' },
{ key: 'os_name', labelKey: 'col.os' },
{ key: 'os_version', labelKey: 'col.os_ver' },
{ key: 'received_at_ms', labelKey: 'col.received', fmt: 'time' },
{ key: 'rating', labelKey: 'col.rating', fmt: 'stars' },
];
const SORT_API_MAP = {
priority: 'priority',
rating: 'fingerprint_cnt',
os_name: 'device_model',
os_version: 'app_version',
};
const GROUP_COLUMNS = [
{ key: 'cnt', labelKey: 'col.count' },
{ key: 'rating', labelKey: 'col.rating', fmt: 'stars' },
{ key: 'fingerprint', labelKey: 'col.fingerprint', fmt: 'fp' },
{ key: 'crash_type', labelKey: 'col.type' },
{ key: 'last_generated_ms', labelKey: 'col.last_generated', fmt: 'time' },
{ key: 'last_received_ms', labelKey: 'col.last_received', fmt: 'time' },
];
function colLabel(col) {
if (!col) return '';
return t(col.labelKey || 'col.' + col.key);
}
function basePath() {
return document.body.getAttribute('data-base-path') || '';
}
function lsGet(key, fallback) {
try {
const v = localStorage.getItem(key);
return v === null ? fallback : v;
} catch {
return fallback;
}
}
function lsSet(key, value) {
try {
localStorage.setItem(key, value);
} catch { /* ignore */ }
}
function getViewedIds() {
try {
const raw = lsGet(STORAGE.viewed, '[]');
const arr = JSON.parse(raw);
return new Set(Array.isArray(arr) ? arr.map(Number).filter(Boolean) : []);
} catch {
return new Set();
}
}
function addViewedId(id) {
const set = getViewedIds();
set.add(Number(id));
lsSet(STORAGE.viewed, JSON.stringify([...set]));
}
function lastVisitMs() {
return Number(lsGet(STORAGE.lastVisit, '0')) || 0;
}
function stampLastVisit() {
lsSet(STORAGE.lastVisit, String(Date.now()));
}
function applyTheme(theme) {
const t = theme === 'light' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', t);
lsSet(STORAGE.theme, t);
const sel = document.getElementById('theme-select');
if (sel) sel.value = t;
}
function initTheme() {
applyTheme(lsGet(STORAGE.theme, 'dark'));
const sel = document.getElementById('theme-select');
if (sel) {
sel.addEventListener('change', () => applyTheme(sel.value));
}
}
function initNav() {
/* nav_shell.js handles toggle + drag on #nav-pane / #nav-handle */
}
function markReportViewed(id) {
if (!id) return;
addViewedId(id);
const xhr = new XMLHttpRequest();
xhr.open('POST', basePath() + '/api/report_viewed.php', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.send(JSON.stringify({ id: Number(id) }));
}
function bindDetailTree(root) {
root.querySelectorAll('[data-tree-toggle]').forEach((row) => {
const panelId = row.getAttribute('data-controls');
const panel = panelId ? document.getElementById(panelId) : null;
const btn = row.querySelector('.report-tree-toggle');
if (!panel) return;
const toggle = (e) => {
if (e && e.target.closest('.report-tree-toggle') && e.type === 'click') {
e.stopPropagation();
}
const open = panel.hidden;
panel.hidden = !open;
if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false');
row.classList.toggle('report-row--open', open);
};
if (btn) {
btn.addEventListener('click', (e) => {
e.stopPropagation();
toggle(e);
});
}
row.addEventListener('click', () => toggle());
row.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle();
}
});
});
}
function initReportDetail() {
const id = document.body.getAttribute('data-report-id');
if (!id) return;
markReportViewed(id);
const tree = document.getElementById('detail-tree');
if (tree) bindDetailTree(tree);
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function formatTime(ms) {
if (!ms) return '—';
const d = new Date(Number(ms));
return d.toISOString().replace('T', ' ').slice(0, 19);
}
function formatCell(col, row) {
const v = row[col.key];
if (col.fmt === 'time') return formatTime(v);
if (col.fmt === 'stars') return formatStars(Number(row.rating ?? 0));
if (col.fmt === 'fp') {
const s = String(v || '');
return '<code class="fp">' + escapeHtml(s.slice(0, 12)) + (s.length > 12 ? '…' : '') + '</code>';
}
return escapeHtml(v ?? '') || '<span class="muted">—</span>';
}
function formatStars(n) {
const score = Math.max(0, Math.min(5, Number(n) || 0));
let html =
'<span class="star-rating" role="img" aria-label="' + score + ' out of 5 priority">';
for (let i = 1; i <= 5; i++) {
html += '<span class="star' + (i <= score ? ' star--on' : '') + '" aria-hidden="true"></span>';
}
return html + '</span>';
}
function crashKindTag(crashType) {
const kind = String(crashType || '').toLowerCase();
const map = {
native: { labelKey: 'tag.ndk', bg: '#b45309' },
java: { labelKey: 'tag.java', bg: '#6d28d9' },
android: { labelKey: 'tag.android', bg: '#047857' },
};
const hit = map[kind];
if (hit) return { label: t(hit.labelKey), bg: hit.bg };
return { label: kind || '?', bg: '#5c6b82' };
}
function renderTagPills(tags, filterable) {
if (!tags.length) return '<span class="muted">—</span>';
return tags
.map((t) => {
const label = escapeHtml(t.label || t.id || '');
const bg = escapeHtml(t.bg || '#5c6b82');
const id = t.id ? String(t.id).trim() : '';
if (filterable && id) {
return (
'<button type="button" class="report-tag report-tag--filter" data-tag-id="' +
escapeHtml(id) +
'" style="--tag-bg:' +
bg +
'" title="Filter by tag">' +
label +
'</button>'
);
}
return '<span class="report-tag" style="--tag-bg:' + bg + '">' + label + '</span>';
})
.join('');
}
function buildTags(row, grouped) {
const tags = [];
const kind = crashKindTag(row.crash_type);
tags.push(kind);
if (!grouped && row.id) {
const viewed = row.viewed || getViewedIds().has(Number(row.id));
const sinceVisit = Number(row.received_at_ms) > lastVisitMs();
if (!viewed && (lastVisitMs() === 0 || sinceVisit)) {
tags.push(tagNew());
}
}
if (Array.isArray(row.tags)) {
row.tags.forEach((t) => {
if (!t || !t.id) return;
const id = String(t.id).toLowerCase();
if (id === 'new' || id === 'java' || id === 'ndk' || id === 'android') return;
tags.push({
id,
label: t.label || t.id,
bg: t.bg || '#5c6b82',
});
});
}
return renderTagPills(tags, !grouped);
}
function apiSortKey(key) {
return SORT_API_MAP[key] || key;
}
function loadSavedSort(isGrouped) {
try {
const s = JSON.parse(lsGet(STORAGE.sort, ''));
if (!s || !s.sort) {
return { sort: isGrouped ? 'cnt' : 'priority', dir: 'desc' };
}
if (!!s.grouped !== !!isGrouped) {
return { sort: isGrouped ? 'cnt' : 'priority', dir: 'desc' };
}
return { sort: s.sort, dir: s.dir || 'desc' };
} catch {
return { sort: isGrouped ? 'cnt' : 'priority', dir: 'desc' };
}
}
function getColWidths() {
try {
const w = JSON.parse(lsGet(STORAGE.colWidths, '{}'));
return { ...DEFAULT_COL_WIDTHS, ...(w && typeof w === 'object' ? w : {}) };
} catch {
return { ...DEFAULT_COL_WIDTHS };
}
}
function saveColWidths(widths) {
lsSet(STORAGE.colWidths, JSON.stringify(widths));
}
function getOrderedColumnKeys() {
const defaults = LIST_COLUMNS.map((c) => c.key);
try {
const saved = JSON.parse(lsGet(STORAGE.colOrder, '[]'));
if (Array.isArray(saved) && saved.length) {
const out = saved.filter((k) => defaults.includes(k));
defaults.forEach((k) => {
if (!out.includes(k)) out.push(k);
});
return out;
}
} catch { /* ignore */ }
return defaults;
}
function saveColumnOrder(keys) {
lsSet(STORAGE.colOrder, JSON.stringify(keys));
}
function getDisplayColumns(isGrouped) {
if (isGrouped) return GROUP_COLUMNS;
return getOrderedColumnKeys()
.map((k) => LIST_COLUMNS.find((c) => c.key === k))
.filter(Boolean);
}
function applyColgroup(isGrouped) {
const cg = document.getElementById('reports-colgroup');
if (!cg || isGrouped) {
if (cg) cg.innerHTML = '';
return;
}
const widths = getColWidths();
const order = getOrderedColumnKeys();
let html =
'<col class="col-expand" style="width:' + (widths.expand || 40) + 'px" />';
order.forEach((k) => {
html +=
'<col data-col="' +
escapeHtml(k) +
'" style="width:' +
(widths[k] || 100) +
'px" />';
});
html +=
'<col class="col-tags" style="width:' + (widths.tags || 128) + 'px" />';
cg.innerHTML = html;
}
function columnLabelByKey(key) {
const c = LIST_COLUMNS.find((col) => col.key === key);
if (c) return colLabel(c);
const g = GROUP_COLUMNS.find((col) => col.key === key);
if (g) return colLabel(g);
if (key === 'tags') return t('col.tags');
return key;
}
function clearColumnDropMarkers(thead) {
thead.querySelectorAll('.th-drop-target').forEach((el) => {
el.classList.remove('th-drop-target');
});
thead.querySelectorAll('.th-dragging').forEach((el) => {
el.classList.remove('th-dragging');
});
}
function bindReportsTableLayout(table, thead, ctx) {
if (!table || !thead || table.dataset.layoutBound === '1') return;
table.dataset.layoutBound = '1';
thead.addEventListener('dragstart', (e) => {
if (ctx.getGrouped()) return;
if (e.target.closest('.col-resizer')) {
e.preventDefault();
return;
}
const th = e.target.closest('th.th-draggable[data-col-key]');
if (!th) return;
const sourceKey = th.getAttribute('data-col-key');
if (!sourceKey) return;
table.dataset.dragColKey = sourceKey;
th.classList.add('th-dragging');
const label = columnLabelByKey(sourceKey);
const ghost = document.createElement('div');
ghost.className = 'col-drag-ghost';
ghost.textContent = label;
ghost.setAttribute('aria-hidden', 'true');
document.body.appendChild(ghost);
table._dragGhost = ghost;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', sourceKey);
try {
e.dataTransfer.setDragImage(ghost, 16, 14);
} catch { /* ignore */ }
}
});
thead.addEventListener('dragend', () => {
clearColumnDropMarkers(thead);
delete table.dataset.dragColKey;
if (table._dragGhost) {
table._dragGhost.remove();
table._dragGhost = null;
}
});
thead.addEventListener('dragenter', (e) => {
if (ctx.getGrouped() || !table.dataset.dragColKey) return;
e.preventDefault();
});
thead.addEventListener('dragover', (e) => {
if (ctx.getGrouped() || !table.dataset.dragColKey) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
const th = e.target.closest('th[data-col-key]');
if (!th) return;
const active = thead.querySelector('.th-drop-target');
if (active && active !== th) active.classList.remove('th-drop-target');
th.classList.add('th-drop-target');
});
thead.addEventListener('dragleave', (e) => {
const th = e.target.closest('th[data-col-key]');
if (!th) return;
const rel = e.relatedTarget;
if (rel && th.contains(rel)) return;
th.classList.remove('th-drop-target');
});
thead.addEventListener('drop', (e) => {
if (ctx.getGrouped()) return;
const th = e.target.closest('th[data-col-key]');
if (!th) return;
e.preventDefault();
e.stopPropagation();
const sourceKey =
table.dataset.dragColKey ||
(e.dataTransfer && e.dataTransfer.getData('text/plain')) ||
'';
const targetKey = th.getAttribute('data-col-key');
clearColumnDropMarkers(thead);
delete table.dataset.dragColKey;
if (table._dragGhost) {
table._dragGhost.remove();
table._dragGhost = null;
}
if (!sourceKey || !targetKey || sourceKey === targetKey) return;
const order = getOrderedColumnKeys();
const from = order.indexOf(sourceKey);
const to = order.indexOf(targetKey);
if (from < 0 || to < 0) return;
order.splice(from, 1);
order.splice(to, 0, sourceKey);
saveColumnOrder(order);
ctx.refreshLayout();
});
table.addEventListener('mousedown', (e) => {
if (ctx.getGrouped()) return;
const handle = e.target.closest('.col-resizer');
if (!handle) return;
e.preventDefault();
e.stopPropagation();
const colKey = handle.getAttribute('data-resize-col');
const th = handle.closest('th');
if (!colKey || !th) return;
const widths = getColWidths();
const startX = e.clientX;
const startW = th.getBoundingClientRect().width;
const onMove = (ev) => {
const w = Math.max(48, Math.round(startW + (ev.clientX - startX)));
widths[colKey] = w;
th.style.width = w + 'px';
th.style.minWidth = w + 'px';
th.style.maxWidth = w + 'px';
const cg = document.getElementById('reports-colgroup');
const col = cg && cg.querySelector('col[data-col="' + colKey + '"]');
if (col) col.style.width = w + 'px';
else if (colKey === 'tags') {
const tagCol = cg && cg.querySelector('col.col-tags');
if (tagCol) tagCol.style.width = w + 'px';
}
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
saveColWidths(widths);
applyColgroup(false);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
function parseListContext() {
const p = new URLSearchParams(window.location.search);
const filterTags = [];
p.getAll('tag').forEach((t) => {
const id = String(t).trim().toLowerCase();
if (id && !filterTags.includes(id)) filterTags.push(id);
});
return {
similarTo: Number(p.get('similar_to')) || 0,
filterDevice: p.get('filter_device') || '',
filterAbi: p.get('filter_abi') || '',
filterOsSdk: Number(p.get('filter_os_sdk')) || 0,
filterOsRelease: p.get('filter_os_release') || '',
filterAppVersion: p.get('filter_app_version') || '',
filterBuild: p.get('filter_build') || '',
filterTags,
tagMode: p.get('tag_mode') === 'or' ? 'or' : 'and',
searchQuery: p.get('q') || '',
};
}
function listContextActive(ctx) {
return (
ctx.similarTo > 0 ||
!!ctx.filterDevice ||
!!ctx.filterAbi ||
ctx.filterOsSdk > 0 ||
!!ctx.filterOsRelease ||
!!ctx.filterAppVersion ||
!!ctx.filterBuild ||
(ctx.filterTags && ctx.filterTags.length > 0) ||
!!ctx.searchQuery
);
}
function reportsUrlWithTagToggle(tagId, tagMode) {
const p = new URLSearchParams(window.location.search);
p.set('view', 'reports');
const tags = p.getAll('tag');
const idx = tags.indexOf(tagId);
if (idx >= 0) {
tags.splice(idx, 1);
} else {
tags.push(tagId);
}
p.delete('tag');
tags.forEach((t) => p.append('tag', t));
if (tags.length) {
p.set('tag_mode', tagMode === 'or' ? 'or' : 'and');
} else {
p.delete('tag_mode');
}
p.delete('group');
p.delete('page');
return basePath() + '/?' + p.toString();
}
function updateFilterBanner(ctx, bannerEl) {
if (!bannerEl) return;
const parts = [];
if (ctx.similarTo > 0) {
parts.push('Similar reports for #' + ctx.similarTo);
}
if (ctx.filterDevice) {
parts.push('Device: “' + ctx.filterDevice + '”');
}
if (ctx.filterAbi) {
parts.push('ABI: ' + ctx.filterAbi);
}
if (ctx.filterOsSdk > 0) {
parts.push('SDK ' + ctx.filterOsSdk);
}
if (ctx.filterOsRelease) {
parts.push('Android ' + ctx.filterOsRelease);
}
if (ctx.filterAppVersion) {
parts.push('App ' + ctx.filterAppVersion);
}
if (ctx.filterBuild) {
parts.push('Build ' + ctx.filterBuild);
}
if (ctx.filterTags && ctx.filterTags.length) {
const modeLabel = ctx.tagMode === 'or' ? 'OR' : 'AND';
parts.push('Tags (' + modeLabel + '): ' + ctx.filterTags.join(', '));
}
if (ctx.searchQuery) {
parts.push('Search: “' + ctx.searchQuery + '”');
}
if (!parts.length) {
bannerEl.hidden = true;
bannerEl.textContent = '';
return;
}
bannerEl.hidden = false;
bannerEl.innerHTML =
parts.join(' · ') +
' — <a href="' +
escapeHtml(basePath() + '/?view=reports') +
'">Clear filters</a>';
}
function navigateTo(href) {
try {
sessionStorage.setItem(STORAGE.skipStamp, '1');
} catch { /* ignore */ }
window.location.href = href;
}
function bindTreeInteractions(root) {
root.querySelectorAll('.report-tree-toggle').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const id = btn.getAttribute('aria-controls');
const briefRow = id ? document.getElementById(id) : null;
const row = btn.closest('.report-row');
if (!briefRow || !row) return;
const open = briefRow.hidden;
briefRow.hidden = !open;
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
row.classList.toggle('report-row--open', open);
});
});
root.querySelectorAll('[data-href]').forEach((el) => {
const href = el.getAttribute('data-href');
if (!href) return;
const go = () => navigateTo(href);
const onActivate = (e) => {
if (e.target.closest('.report-tree-toggle, .tag-edit-btn, .report-tag--filter')) return;
go();
};
el.addEventListener('click', onActivate);
el.addEventListener('keydown', (e) => {
if (e.target.closest('.report-tree-toggle, .tag-edit-btn, .report-tag--filter')) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
go();
}
});
});
}
function bindReportTagFilters(root, onTag) {
if (!root || typeof onTag !== 'function') return;
root.querySelectorAll('.report-tag--filter').forEach((btn) => {
btn.addEventListener('click', (e) => {
const tagId = btn.getAttribute('data-tag-id') || '';
if (tagId) onTag(tagId, e);
});
});
}
function initReportsApp() {
const app = document.getElementById('reports-app');
if (!app) return;
const thead = document.getElementById('reports-thead');
const tbody = document.getElementById('reports-tbody');
const pagination = document.getElementById('reports-pagination');
const statusEl = document.getElementById('reports-status');
const perPageSel = document.getElementById('per-page-select');
const table = document.getElementById('reports-tree');
const filterBanner = document.getElementById('reports-filter-banner');
const listContext = parseListContext();
let grouped =
listContext.filterTags && listContext.filterTags.length > 0
? false
: listContextActive(listContext)
? false
: app.getAttribute('data-grouped') === '1';
let page = 1;
let perPage = Number(lsGet(STORAGE.perPage, '50')) || 50;
let listTotal = 0;
const saved = loadSavedSort(grouped);
let sort = saved.sort;
let dir = saved.dir;
let loading = false;
let lastItems = [];
function saveSortState() {
lsSet(
STORAGE.sort,
JSON.stringify({ sort, dir, grouped: !!grouped })
);
}
function sortActive(colKey) {
if (sort === 'priority' && colKey === 'rating') return true;
if (sort === colKey) return true;
return apiSortKey(sort) === apiSortKey(colKey);
}
const layoutCtx = {
getGrouped: () => grouped,
refreshLayout: () => {
applyColgroup(grouped);
renderHead();
if (lastItems.length) renderRows(lastItems);
},
};
function refreshTableChrome() {
layoutCtx.refreshLayout();
}
bindReportsTableLayout(table, thead, layoutCtx);
updateFilterBanner(listContext, filterBanner);
initTagFilterBar(listContext);
if (listContextActive(listContext)) {
app.setAttribute('data-grouped', '0');
app.querySelectorAll('.reports-mode-btn').forEach((b) => {
b.classList.toggle('active', b.getAttribute('data-grouped') === '0');
b.disabled = true;
b.title = 'Disabled while a filter is active';
});
}
if (perPageSel) {
perPageSel.value = String(perPage);
perPageSel.addEventListener('change', () => {
perPage = Number(perPageSel.value) || 50;
lsSet(STORAGE.perPage, String(perPage));
page = 1;
load();
});
}
app.querySelectorAll('.reports-mode-btn').forEach((btn) => {
btn.addEventListener('click', () => {
grouped = btn.getAttribute('data-grouped') === '1';
app.setAttribute('data-grouped', grouped ? '1' : '0');
app.querySelectorAll('.reports-mode-btn').forEach((b) => {
b.classList.toggle('active', b === btn);
});
const next = loadSavedSort(grouped);
sort = next.sort;
dir = next.dir;
page = 1;
load();
});
});
const maybeStampVisit = () => {
try {
if (sessionStorage.getItem(STORAGE.skipStamp)) {
sessionStorage.removeItem(STORAGE.skipStamp);
return;
}
} catch { /* ignore */ }
stampLastVisit();
};
window.addEventListener('pagehide', maybeStampVisit);
function columns() {
return getDisplayColumns(grouped);
}
function colspan() {
return columns().length + 2;
}
function renderHead() {
const cols = columns();
const widths = getColWidths();
let html =
'<tr><th class="report-tree-head" aria-label="Expand"><span class="col-resizer" data-resize-col="expand"></span></th>';
cols.forEach((c) => {
const active = sortActive(c.key);
const arrow =
active ? (dir === 'asc' ? '▲' : '▼') : '<span class="sort-idle">↕</span>';
const w = widths[c.key] ? ' style="width:' + widths[c.key] + 'px"' : '';
let thClass = 'sortable' + (active ? ' sortable--active' : '');
let thDragAttrs = '';
if (!grouped) {
thClass += ' th-draggable';
thDragAttrs =
' draggable="true" data-col-key="' +
escapeHtml(c.key) +
'" title="' +
escapeHtml(t('col.drag_reorder')) +
'"';
}
html +=
'<th class="' +
thClass +
'"' +
thDragAttrs +
' data-sort="' +
escapeHtml(c.key) +
'"' +
w +
' scope="col"><span class="th-inner">' +
(grouped
? ''
: '<span class="th-drag" aria-hidden="true" title="' +
escapeHtml(t('col.drag')) +
'">⋮⋮</span>') +
'<span class="th-label">' +
escapeHtml(colLabel(c)) +
'</span> <span class="sort-ind" aria-hidden="true">' +
arrow +
'</span></span>' +
(grouped ? '' : '<span class="col-resizer" data-resize-col="' + escapeHtml(c.key) + '"></span>') +
'</th>';
});
html +=
'<th class="col-tags" scope="col" style="width:' +
(widths.tags || 128) +
'px">Tags<span class="col-resizer" data-resize-col="tags"></span></th></tr>';
thead.innerHTML = html;
thead.querySelectorAll('.sortable').forEach((th) => {
th.addEventListener('click', (e) => {
if (e.target.closest('.col-resizer')) return;
const key = th.getAttribute('data-sort');
if (!key) return;
if (sort === key) {
dir = dir === 'asc' ? 'desc' : 'asc';
} else {
sort = key;
dir = 'desc';
}
saveSortState();
page = 1;
load();
});
});
}
function pageRangeEnd(targetPage, totalCount) {
return Math.min(targetPage * perPage, totalCount);
}
function renderPagination(total) {
listTotal = total;
const pages = Math.max(1, Math.ceil(total / perPage));
if (page > pages) page = pages;
const prev = page > 1 ? page - 1 : null;
const next = page < pages ? page + 1 : null;
let prevLabel = '← Previous';
let nextLabel = 'Next →';
if (prev) {
const t = page - 1;
const before = (t - 1) * perPage;
const through = pageRangeEnd(t, total);
prevLabel = '← Previous (' + before + '/' + through + ')';
}
if (next) {
const start = page * perPage + 1;
const end = pageRangeEnd(page + 1, total);
nextLabel = 'Next (' + start + '/' + end + ') →';
}
let html = '<div class="pagination-inner">';
html +=
'<button type="button" class="btn page-btn" data-page="' +
(prev || '') +
'" ' +
(prev ? '' : 'disabled') +
'>' +
escapeHtml(prevLabel) +
'</button>';
html +=
'<span class="page-info">Page ' +
page +
' / ' +
pages +
' · ' +
total +
' reports</span>';
html +=
'<button type="button" class="btn page-btn" data-page="' +
(next || '') +
'" ' +
(next ? '' : 'disabled') +
'>' +
escapeHtml(nextLabel) +
'</button>';
html += '</div>';
pagination.innerHTML = html;
pagination.querySelectorAll('.page-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const p = Number(btn.getAttribute('data-page'));
if (!p) return;
page = p;
load();
});
});
}
function renderRows(items) {
const cols = columns();
if (!items.length) {
tbody.innerHTML =
'<tr><td colspan="' +
colspan() +
'" class="muted">' +
escapeHtml(t('reports.empty')) +
'</td></tr>';
return;
}
let html = '';
items.forEach((row, i) => {
const rowKey = grouped ? 'g' + i + '-' + page : 'r' + row.id;
const briefId = 'brief-' + rowKey;
const openUrl = !grouped && row.id
? basePath() + '/?view=report&id=' + row.id
: '';
const navClass = openUrl ? ' report-row--nav' : '';
const hrefAttr = openUrl ? ' data-href="' + escapeHtml(openUrl) + '"' : '';
const tabAttr = openUrl ? ' tabindex="0" role="link"' : '';
html += '<tr class="report-row' + navClass + '"' + hrefAttr + tabAttr + '>';
html +=
'<td class="report-tree-cell"><button type="button" class="report-tree-toggle" aria-expanded="false" aria-controls="' +
briefId +
'" title="' +
escapeHtml(t('row.show_summary')) +
'"><span class="report-tree-arrow" aria-hidden="true"></span></button></td>';
cols.forEach((c) => {
if (c.key === 'cnt') {
html += '<td><span class="badge">' + escapeHtml(row.cnt) + '</span></td>';
} else {
html += '<td>' + formatCell(c, row) + '</td>';
}
});
const tagEditBtn =
!grouped && row.id && canTagEdit()
? '<button type="button" class="tag-edit-btn" data-report-id="' +
row.id +
'" title="Edit tags" aria-label="Edit tags">✎</button>'
: '';
html +=
'<td class="col-tags"><div class="report-tags">' +
buildTags(row, grouped) +
tagEditBtn +
'</div></td>';
html += '</tr>';
const briefLines = row.brief || [];
let panelOpen = '<div class="report-brief-panel">';
if (openUrl) {
panelOpen =
'<div class="report-brief-panel report-brief-panel--nav report-row--nav" data-href="' +
escapeHtml(openUrl) +
'" tabindex="0" role="link">';
}
html +=
'<tr class="report-brief-row" id="' +
briefId +
'" hidden><td colspan="' +
colspan() +
'" class="report-brief-cell">' +
panelOpen +
'<div class="report-brief">';
briefLines.forEach((line) => {
html += '<p>' + escapeHtml(line) + '</p>';
});
if (openUrl) {
html += '<p class="report-brief-hint muted">Click to open full report</p>';
}
html += '</div></div></td></tr>';
});
tbody.innerHTML = html;
bindTreeInteractions(tbody);
bindReportTagFilters(tbody, handleReportTagClick);
}
function applyReportTagFilter(tagId) {
listContext.filterTags = [tagId];
listContext.tagMode = listContext.tagMode || 'and';
grouped = false;
app.setAttribute('data-grouped', '0');
page = 1;
updateFilterBanner(listContext, filterBanner);
const p = new URLSearchParams(window.location.search);
p.set('view', 'reports');
p.delete('tag');
p.append('tag', tagId);
p.set('tag_mode', listContext.tagMode);
p.delete('group');
p.delete('page');
history.replaceState(null, '', basePath() + '/?' + p.toString());
const modeWrap = document.getElementById('tag-mode-wrap');
if (modeWrap) modeWrap.hidden = false;
const modeSel = document.getElementById('tag-mode-select');
if (modeSel) modeSel.value = listContext.tagMode;
load();
}
function handleReportTagClick(tagId, ev) {
ev.stopPropagation();
ev.preventDefault();
if (grouped) {
applyReportTagFilter(tagId);
return;
}
const xhr = new XMLHttpRequest();
xhr.open(
'GET',
basePath() +
'/api/reports.php?page=1&per_page=2&tag=' +
encodeURIComponent(tagId) +
'&tag_mode=and&sort=received_at_ms&dir=desc&since_ms=0',
true
);
xhr.onload = function () {
let data;
try {
data = JSON.parse(xhr.responseText);
} catch {
return;
}
if (!data.ok) return;
const total = Number(data.total || 0);
if (total === 1 && data.items && data.items[0] && data.items[0].id) {
navigateTo(basePath() + '/?view=report&id=' + data.items[0].id);
return;
}
applyReportTagFilter(tagId);
};
xhr.send();
}
function load() {
if (loading) return;
loading = true;
statusEl.textContent = t('reports.loading');
let qs =
'page=' +
page +
'&per_page=' +
perPage +
'&sort=' +
encodeURIComponent(apiSortKey(sort)) +
'&dir=' +
encodeURIComponent(dir) +
'&since_ms=' +
encodeURIComponent(String(lastVisitMs()));
if (listContext.searchQuery) {
qs += '&q=' + encodeURIComponent(listContext.searchQuery);
} else if (listContext.similarTo > 0) {
qs += '&similar_to=' + encodeURIComponent(String(listContext.similarTo));
} else {
if (grouped) qs += '&group=1';
if (listContext.filterDevice) {
qs += '&filter_device=' + encodeURIComponent(listContext.filterDevice);
}
if (listContext.filterAbi) {
qs += '&filter_abi=' + encodeURIComponent(listContext.filterAbi);
}
if (listContext.filterOsSdk > 0) {
qs += '&filter_os_sdk=' + encodeURIComponent(String(listContext.filterOsSdk));
}
if (listContext.filterOsRelease) {
qs += '&filter_os_release=' + encodeURIComponent(listContext.filterOsRelease);
}
if (listContext.filterAppVersion) {
qs += '&filter_app_version=' + encodeURIComponent(listContext.filterAppVersion);
}
if (listContext.filterBuild) {
qs += '&filter_build=' + encodeURIComponent(listContext.filterBuild);
}
if (listContext.filterTags && listContext.filterTags.length) {
listContext.filterTags.forEach((tagId) => {
qs += '&tag=' + encodeURIComponent(tagId);
});
qs += '&tag_mode=' + encodeURIComponent(listContext.tagMode || 'and');
}
}
const xhr = new XMLHttpRequest();
xhr.open('GET', basePath() + '/api/reports.php?' + qs, true);
xhr.onload = function () {
loading = false;
let data;
try {
data = JSON.parse(xhr.responseText);
} catch {
statusEl.textContent = 'Invalid response';
return;
}
if (!data.ok) {
statusEl.textContent = data.error || 'Load failed';
return;
}
lastItems = data.items || [];
refreshTableChrome();
renderPagination(Number(data.total) || 0);
statusEl.textContent =
'Showing ' +
(data.items?.length || 0) +
' of ' +
(data.total || 0) +
(grouped ? ' (grouped)' : '');
};
xhr.onerror = function () {
loading = false;
statusEl.textContent = 'Network error';
};
xhr.send();
}
initTagEditButtons(tbody, load);
const searchInput = document.getElementById('reports-search-input');
const searchSuggest = document.getElementById('reports-search-suggest');
const searchClear = document.getElementById('reports-search-clear');
let suggestTimer = null;
function hideSuggest() {
if (searchSuggest) {
searchSuggest.hidden = true;
searchSuggest.innerHTML = '';
}
}
function applySearchQuery(q, pushUrl) {
listContext.similarTo = 0;
listContext.filterDevice = '';
listContext.filterAbi = '';
listContext.filterOsSdk = 0;
listContext.filterOsRelease = '';
listContext.filterAppVersion = '';
listContext.filterBuild = '';
listContext.searchQuery = (q || '').trim();
grouped = false;
app.setAttribute('data-grouped', '0');
page = 1;
updateFilterBanner(listContext, filterBanner);
if (searchInput) searchInput.value = listContext.searchQuery;
if (searchClear) searchClear.hidden = !listContext.searchQuery;
if (pushUrl) {
const url =
basePath() +
'/?view=reports' +
(listContext.searchQuery ? '&q=' + encodeURIComponent(listContext.searchQuery) : '');
history.replaceState(null, '', url);
}
load();
}
function fetchSuggest(q) {
if (!searchSuggest || q.length < 2) {
hideSuggest();
return;
}
const xhr = new XMLHttpRequest();
xhr.open(
'GET',
basePath() +
'/api/reports.php?suggest=1&q=' +
encodeURIComponent(q) +
'&per_page=10',
true
);
xhr.onload = function () {
let data;
try {
data = JSON.parse(xhr.responseText);
} catch {
hideSuggest();
return;
}
if (!data.ok || !data.suggestions || !data.suggestions.length) {
hideSuggest();
return;
}
searchSuggest.innerHTML = data.suggestions
.map(
(s) =>
'<li role="option"><button type="button" class="suggest-item" data-suggest="' +
escapeHtml(s) +
'">' +
escapeHtml(s) +
'</button></li>'
)
.join('');
searchSuggest.hidden = false;
};
xhr.send();
}
if (searchInput) {
if (listContext.searchQuery) {
searchInput.value = listContext.searchQuery;
if (searchClear) searchClear.hidden = false;
}
searchInput.addEventListener('input', () => {
const q = searchInput.value;
if (searchClear) searchClear.hidden = !q.trim();
hideSuggest();
clearTimeout(suggestTimer);
const trimmed = q.trim();
if (trimmed.length < 2) return;
suggestTimer = setTimeout(() => fetchSuggest(trimmed), 1000);
});
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
clearTimeout(suggestTimer);
hideSuggest();
applySearchQuery(searchInput.value.trim(), true);
}
if (e.key === 'Escape') {
hideSuggest();
}
});
}
function selectSearchSuggestion(term) {
if (!searchInput) return;
const t = (term || '').trim();
if (!t) return;
const raw = searchInput.value;
const withoutTrailing = raw.replace(/\s+$/, '');
const lastSpace = withoutTrailing.lastIndexOf(' ');
searchInput.value =
lastSpace < 0
? t
: withoutTrailing.slice(0, lastSpace + 1) + t;
if (searchClear) searchClear.hidden = !searchInput.value.trim();
searchInput.focus();
}
if (searchSuggest) {
searchSuggest.addEventListener('click', (e) => {
const btn = e.target.closest('[data-suggest]');
if (!btn) return;
const term = btn.getAttribute('data-suggest') || '';
selectSearchSuggestion(term);
hideSuggest();
});
}
if (searchClear) {
searchClear.addEventListener('click', () => {
if (searchInput) searchInput.value = '';
searchClear.hidden = true;
hideSuggest();
applySearchQuery('', true);
});
}
applyColgroup(grouped);
load();
}
const TAG_QUICK_COLORS = [
'#5c6b82', '#3d8bfd', '#6d28d9', '#b45309', '#047857',
'#dc2626', '#d97706', '#0891b2', '#be185d', '#4f46e5',
];
function canTagEdit() {
return document.body.getAttribute('data-can-tag-edit') === '1';
}
function slugTagId(label) {
return String(label)
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 32);
}
function renderTagPill(tag, removable) {
let html =
'<span class="report-tag" style="--tag-bg:' +
escapeHtml(tag.bg || '#5c6b82') +
'">' +
escapeHtml(tag.label || tag.id);
if (removable) {
html +=
'<button type="button" class="tag-remove" data-remove-id="' +
escapeHtml(tag.id) +
'" aria-label="' + escapeHtml(t('tag.remove')) + '">×</button>';
}
return html + '</span>';
}
function tagsApiUrl(entityType) {
return basePath() + (entityType === 'ticket' ? '/api/ticket_tags.php' : '/api/report_tags.php');
}
const TICKET_META_IDS = ['ticket', 'issue'];
function isTicketMetaId(id) {
return TICKET_META_IDS.indexOf(id) >= 0;
}
function stripTicketMetaTags(tags) {
return (tags || []).filter((t) => !isTicketMetaId(t.id));
}
function tagsJson(tags) {
return JSON.stringify(tags || []);
}
function createTagEditor(cfg) {
const entityType = cfg.entityType === 'ticket' ? 'ticket' : 'report';
const state = {
tags: [...(cfg.initialTags || [])],
workflow: [],
presets: [],
entityId: cfg.entityId ?? cfg.reportId ?? cfg.ticketId ?? 0,
entityType,
};
const els = cfg.elements;
function renderList() {
if (!els.list) return;
if (!state.tags.length) {
els.list.innerHTML = '<li class="muted">No custom tags yet.</li>';
return;
}
els.list.innerHTML = state.tags
.map(
(t, i) =>
'<li class="tag-editor-item">' +
renderTagPill(t, true) +
'<label class="tag-color-edit">Color <input type="color" data-color-idx="' +
i +
'" value="' +
escapeHtml(t.bg || '#5c6b82') +
'"></label></li>'
)
.join('');
els.list.querySelectorAll('.tag-remove').forEach((btn) => {
btn.addEventListener('click', () => {
const id = btn.getAttribute('data-remove-id');
state.tags = state.tags.filter((t) => t.id !== id);
renderAll();
});
});
els.list.querySelectorAll('input[data-color-idx]').forEach((inp) => {
inp.addEventListener('input', () => {
const idx = Number(inp.getAttribute('data-color-idx'));
if (state.tags[idx]) state.tags[idx].bg = inp.value;
renderPreview();
});
});
}
function renderPreview() {
if (!els.preview) return;
els.preview.innerHTML = state.tags.length
? state.tags.map((t) => renderTagPill(t, false)).join('')
: '<span class="muted">No custom tags</span>';
}
function presetChipHtml(p) {
return (
'<button type="button" class="tag-preset-chip" data-preset-id="' +
escapeHtml(p.id) +
'" style="--tag-bg:' +
escapeHtml(p.bg) +
'">' +
escapeHtml(p.label) +
'</button>'
);
}
function wirePresetChips(root) {
if (!root) return;
root.querySelectorAll('.tag-preset-chip').forEach((btn) => {
btn.addEventListener('click', () => {
const id = btn.getAttribute('data-preset-id');
const p =
state.workflow.find((x) => x.id === id) ||
state.presets.find((x) => x.id === id);
if (!p || state.tags.some((t) => t.id === p.id)) return;
if (entityType === 'ticket' && isTicketMetaId(p.id)) {
state.tags = stripTicketMetaTags(state.tags);
}
state.tags.push({ ...p });
renderAll();
});
});
}
function renderPresets() {
if (!els.presetBar || !els.presetChips) return;
const wf = state.workflow || [];
const hist = (state.presets || []).filter(
(p) => !wf.some((w) => w.id === p.id)
);
if (!wf.length && !hist.length) {
els.presetBar.hidden = true;
return;
}
els.presetBar.hidden = false;
let html = '';
if (wf.length) {
html +=
'<div class="tag-preset-group"><span class="muted tag-preset-group-label">' +
escapeHtml(t('tag.workflow')) +
'</span><div class="tag-preset-chips">' +
wf.map(presetChipHtml).join('') +
'</div></div>';
}
if (hist.length) {
html +=
'<div class="tag-preset-group"><span class="muted tag-preset-group-label">' +
escapeHtml(t('tag.suggestions')) +
'</span><div class="tag-preset-chips">' +
hist.map(presetChipHtml).join('') +
'</div></div>';
}
els.presetChips.innerHTML = html;
wirePresetChips(els.presetChips);
}
function renderAll() {
renderList();
renderPreview();
}
function setStatus(msg, ok) {
if (!els.status) return;
els.status.textContent = msg || '';
els.status.classList.toggle('tag-editor-status--err', ok === false);
}
function addFromForm(form) {
const label = (form.label?.value || '').trim();
const bg = form.bg?.value || '#5c6b82';
if (!label) return;
const id = slugTagId(label);
if (!id) return;
const reserved = ['new', 'java', 'ndk', 'android'];
if (reserved.includes(id)) {
setStatus('Reserved tag id: ' + id, false);
return;
}
const existing = state.tags.findIndex((t) => t.id === id);
const entry = { id, label, bg };
if (existing >= 0) state.tags[existing] = entry;
else state.tags.push(entry);
form.label.value = '';
renderAll();
setStatus('', true);
}
if (els.form) {
els.form.addEventListener('submit', (e) => {
e.preventDefault();
addFromForm(els.form);
});
}
if (els.saveBtn) {
els.saveBtn.addEventListener('click', () => {
setStatus(t('tag.saving'), true);
const sentTags = state.tags.map((t) => ({ ...t }));
const xhr = new XMLHttpRequest();
xhr.open('POST', tagsApiUrl(state.entityType), true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
let data;
try {
data = JSON.parse(xhr.responseText);
} catch {
setStatus('Invalid response', false);
return;
}
if (!data.ok) {
setStatus(data.hint || data.error || 'Save failed', false);
return;
}
state.tags = data.tags || [];
renderAll();
const adjusted = tagsJson(sentTags) !== tagsJson(state.tags);
setStatus(adjusted ? 'Saved (type/status tags normalized)' : 'Saved', true);
if (cfg.onSaved) cfg.onSaved(state.tags, state.entityId);
};
xhr.onerror = function () {
setStatus(t('reports.network_error'), false);
};
xhr.send(JSON.stringify({ id: state.entityId, tags: state.tags }));
});
}
function loadFromApi() {
const xhr = new XMLHttpRequest();
xhr.open('GET', tagsApiUrl(state.entityType) + '?id=' + state.entityId, true);
xhr.onload = function () {
let data;
try {
data = JSON.parse(xhr.responseText);
} catch {
return;
}
if (data.ok) {
state.tags = data.tags || [];
state.workflow = data.workflow || [];
state.presets = data.presets || [];
renderAll();
renderPresets();
}
};
xhr.send();
}
renderAll();
if (cfg.fetchPresets) loadFromApi();
return {
setTags(tags) {
state.tags = tags || [];
renderAll();
},
getTags() {
return state.tags;
},
reload() {
loadFromApi();
},
};
}
let tagModalEditor = null;
function openTagModal(entityId, entityType) {
const modal = document.getElementById('tag-modal');
const editorEl = document.getElementById('tag-editor-modal');
if (!modal || !editorEl || !canTagEdit()) return;
const et = entityType === 'ticket' ? 'ticket' : 'report';
editorEl.setAttribute('data-report-id', et === 'report' ? String(entityId) : '0');
editorEl.setAttribute('data-ticket-id', et === 'ticket' ? String(entityId) : '0');
editorEl.setAttribute('data-entity-type', et);
modal.hidden = false;
modal.setAttribute('aria-hidden', 'false');
tagModalEditor = createTagEditor({
entityId: Number(entityId),
entityType: et,
initialTags: [],
fetchPresets: true,
elements: {
list: document.getElementById('tag-modal-list'),
preview: document.getElementById('tag-modal-preview'),
form: document.getElementById('tag-modal-add'),
presetBar: document.getElementById('tag-modal-presets'),
presetChips: document.getElementById('tag-modal-preset-chips'),
saveBtn: document.getElementById('tag-modal-save'),
status: document.getElementById('tag-modal-status'),
},
onSaved() {
if (typeof reportsReloadRef === 'function') reportsReloadRef();
if (typeof window.ticketsReloadRef === 'function') window.ticketsReloadRef();
},
});
}
window.openTagModal = openTagModal;
function closeTagModal() {
const modal = document.getElementById('tag-modal');
if (!modal) return;
modal.hidden = true;
modal.setAttribute('aria-hidden', 'true');
tagModalEditor = null;
}
let reportsReloadRef = null;
function initTagModal() {
document.querySelectorAll('[data-tag-modal-close]').forEach((el) => {
el.addEventListener('click', closeTagModal);
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeTagModal();
});
}
function initTagEditorReadOnly() {
const preview = document.getElementById('tag-editor-preview');
const dataEl =
document.getElementById('ticket-custom-tags-data') ||
document.getElementById('report-custom-tags-data');
if (!preview || !dataEl || canTagEdit()) return;
try {
const tags = JSON.parse(dataEl.textContent || '[]');
preview.innerHTML = tags.length
? tags.map((t) => renderTagPill(t, false)).join('')
: '<span class="muted">No custom tags</span>';
} catch {
preview.innerHTML = '<span class="muted">—</span>';
}
}
function initTagEditorDetail() {
const root = document.getElementById('tag-editor');
if (!root) return;
const entityType = root.getAttribute('data-entity-type') === 'ticket' ? 'ticket' : 'report';
const entityId = Number(
root.getAttribute(entityType === 'ticket' ? 'data-ticket-id' : 'data-report-id')
);
const canEdit = entityType === 'ticket' ? !!root.querySelector('#tag-editor-list') : canTagEdit();
if (!canEdit) {
initTagEditorReadOnly();
return;
}
let initial = [];
const dataEl =
document.getElementById('ticket-custom-tags-data') ||
document.getElementById('report-custom-tags-data');
if (dataEl) {
try {
initial = JSON.parse(dataEl.textContent || '[]');
} catch { /* ignore */ }
}
createTagEditor({
entityId,
entityType,
initialTags: initial,
fetchPresets: true,
elements: {
list: document.getElementById('tag-editor-list'),
preview: document.getElementById('tag-editor-preview'),
form: document.getElementById('tag-editor-add'),
presetBar: document.getElementById('tag-preset-bar'),
presetChips: document.getElementById('tag-preset-chips'),
saveBtn: document.getElementById('tag-editor-save'),
status: document.getElementById('tag-editor-status'),
},
});
}
function initTicketDetail() {
const id = document.body.getAttribute('data-ticket-id');
if (!id) return;
const tree = document.getElementById('detail-tree');
if (tree) bindDetailTree(tree);
let assignees = [];
const assigneeDataEl = document.getElementById('ticket-assignees-data');
if (assigneeDataEl) {
try {
assignees = JSON.parse(assigneeDataEl.textContent || '[]');
if (!Array.isArray(assignees)) assignees = [];
} catch {
assignees = [];
}
}
const assigneeEditor = document.getElementById('assignee-editor');
const assigneePick = document.getElementById('assignee-user-pick');
const assigneeAddBtn = document.getElementById('assignee-add-btn');
function renderAssignees() {
if (!assigneeEditor) return;
if (!assignees.length) {
assigneeEditor.innerHTML = '<span class="muted">—</span>';
return;
}
assigneeEditor.innerHTML = assignees
.map(function (a, idx) {
const role = a.role ? ' <span class="muted">(' + escapeHtml(a.role) + ')</span>' : '';
return (
'<span class="assignee-pill">' +
escapeHtml(a.username) +
role +
' <button type="button" class="assignee-remove" data-idx="' +
idx +
'" aria-label="Remove">×</button></span>'
);
})
.join('');
assigneeEditor.querySelectorAll('.assignee-remove').forEach(function (btn) {
btn.addEventListener('click', function () {
const i = Number(btn.getAttribute('data-idx'));
assignees.splice(i, 1);
renderAssignees();
});
});
}
function loadUsersForAssignees() {
if (!assigneePick) return;
const xhr = new XMLHttpRequest();
xhr.open('GET', basePath() + '/api/users.php', true);
xhr.onload = function () {
let data;
try {
data = JSON.parse(xhr.responseText);
} catch {
return;
}
if (!data.ok || !Array.isArray(data.users)) return;
data.users.forEach(function (u) {
const opt = document.createElement('option');
opt.value = u.username;
opt.textContent = u.username + (u.role ? ' (' + u.role + ')' : '');
opt.dataset.role = u.role || '';
assigneePick.appendChild(opt);
});
};
xhr.send();
}
if (assigneeEditor) {
renderAssignees();
loadUsersForAssignees();
if (assigneeAddBtn && assigneePick) {
assigneeAddBtn.addEventListener('click', function () {
const username = assigneePick.value;
if (!username) return;
const role = assigneePick.selectedOptions[0]?.dataset.role || '';
if (assignees.some(function (a) { return a.username === username; })) return;
assignees.push({ username: username, role: role });
renderAssignees();
});
}
}
const attachmentList = document.getElementById('ticket-attachment-list');
const linkForm = document.getElementById('ticket-link-form');
const fileForm = document.getElementById('ticket-file-form');
const linkStatus = document.getElementById('ticket-link-status');
const fileStatus = document.getElementById('ticket-file-status');
function renderAttachmentItem(att) {
const li = document.createElement('li');
li.className = 'ticket-attachment ticket-attachment--' + (att.kind || 'file');
li.dataset.attachmentId = String(att.id);
let main = '';
const label = escapeHtml(att.provider_label || 'File');
if (att.kind === 'link' && att.external_url) {
main =
'<a href="' +
escapeHtml(att.external_url) +
'" target="_blank" rel="noopener noreferrer">' +
escapeHtml(att.title || att.external_url) +
'</a>';
} else if (att.download_url) {
const size =
att.size_bytes > 0
? ' <span class="muted">(' + (att.size_bytes / 1024).toFixed(1) + ' KB)</span>'
: '';
main =
'<a href="' +
escapeHtml(att.download_url) +
'">' +
escapeHtml(att.title || att.original_name || 'file') +
'</a>' +
size;
} else {
main = '<span>' + escapeHtml(att.title || '') + '</span>';
}
li.innerHTML =
'<span class="attachment-provider">' +
label +
'</span> ' +
main +
' <span class="muted attachment-meta">' +
escapeHtml(att.created_by || '') +
'</span>' +
' <button type="button" class="btn btn-small attachment-remove" data-id="' +
att.id +
'">×</button>';
li.querySelector('.attachment-remove').addEventListener('click', function () {
if (!confirm('Remove attachment?')) return;
const xhr = new XMLHttpRequest();
xhr.open('DELETE', basePath() + '/api/ticket_attachments.php?id=' + att.id, true);
xhr.onload = function () {
let data;
try {
data = JSON.parse(xhr.responseText);
} catch {
return;
}
if (data.ok) li.remove();
};
xhr.send();
});
return li;
}
if (linkForm) {
linkForm.addEventListener('submit', function (e) {
e.preventDefault();
const fd = new FormData(linkForm);
const url = String(fd.get('url') || '').trim();
if (!url) return;
if (linkStatus) linkStatus.textContent = 'Saving…';
const xhr = new XMLHttpRequest();
xhr.open('POST', basePath() + '/api/ticket_attachments.php', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
let data;
try {
data = JSON.parse(xhr.responseText);
} catch {
if (linkStatus) linkStatus.textContent = 'Invalid response';
return;
}
if (!data.ok) {
if (linkStatus) linkStatus.textContent = data.error || 'Failed';
return;
}
if (attachmentList && data.attachment) {
attachmentList.appendChild(renderAttachmentItem(data.attachment));
}
linkForm.reset();
if (linkStatus) linkStatus.textContent = 'Attached';
};
xhr.onerror = function () {
if (linkStatus) linkStatus.textContent = t('reports.network_error');
};
xhr.send(
JSON.stringify({
ticket_id: Number(id),
title: fd.get('title'),
url: url,
})
);
});
}
if (fileForm) {
fileForm.addEventListener('submit', function (e) {
e.preventDefault();
const fd = new FormData(fileForm);
fd.append('ticket_id', id);
if (fileStatus) fileStatus.textContent = 'Uploading…';
const xhr = new XMLHttpRequest();
xhr.open('POST', basePath() + '/api/ticket_attachments.php', true);
xhr.onload = function () {
let data;
try {
data = JSON.parse(xhr.responseText);
} catch {
if (fileStatus) fileStatus.textContent = 'Invalid response';
return;
}
if (!data.ok) {
if (fileStatus) fileStatus.textContent = data.error || 'Failed';
return;
}
if (attachmentList && data.attachment) {
attachmentList.appendChild(renderAttachmentItem(data.attachment));
}
fileForm.reset();
if (fileStatus) fileStatus.textContent = 'Uploaded';
};
xhr.onerror = function () {
if (fileStatus) fileStatus.textContent = t('reports.network_error');
};
xhr.send(fd);
});
}
if (attachmentList) {
attachmentList.querySelectorAll('.attachment-remove').forEach(function (btn) {
btn.addEventListener('click', function () {
const attId = btn.getAttribute('data-id');
if (!confirm('Remove attachment?')) return;
const xhr = new XMLHttpRequest();
xhr.open('DELETE', basePath() + '/api/ticket_attachments.php?id=' + attId, true);
xhr.onload = function () {
let data;
try {
data = JSON.parse(xhr.responseText);
} catch {
return;
}
if (data.ok) btn.closest('.ticket-attachment')?.remove();
};
xhr.send();
});
});
}
const commentForm = document.getElementById('ticket-comment-form');
const commentStatus = document.getElementById('ticket-comment-status');
const commentList = document.getElementById('ticket-comment-list');
if (commentForm) {
commentForm.addEventListener('submit', function (e) {
e.preventDefault();
const fd = new FormData(commentForm);
const bodyText = String(fd.get('body') || '').trim();
if (!bodyText) return;
if (commentStatus) commentStatus.textContent = 'Posting…';
const xhr = new XMLHttpRequest();
xhr.open('POST', basePath() + '/api/ticket_comments.php', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
let data;
try {
data = JSON.parse(xhr.responseText);
} catch {
if (commentStatus) commentStatus.textContent = 'Invalid response';
return;
}
if (!data.ok) {
if (commentStatus) commentStatus.textContent = data.error || 'Failed';
return;
}
const c = data.comment;
if (commentList && c) {
const li = document.createElement('li');
li.className = 'ticket-comment';
const when = c.created_at_ms
? new Date(Number(c.created_at_ms)).toISOString().replace('T', ' ').slice(0, 19)
: '';
li.innerHTML =
'<div class="ticket-comment-meta"><strong>' +
escapeHtml(c.author_username) +
'</strong> <span class="muted">' +
escapeHtml(when) +
'</span></div><pre class="ticket-comment-body"></pre>';
li.querySelector('.ticket-comment-body').textContent = c.body;
commentList.appendChild(li);
}
commentForm.reset();
if (commentStatus) commentStatus.textContent = 'Posted';
};
xhr.onerror = function () {
if (commentStatus) commentStatus.textContent = t('reports.network_error');
};
xhr.send(JSON.stringify({ ticket_id: Number(id), body: bodyText }));
});
}
const form = document.getElementById('ticket-edit-form');
if (!form) return;
const statusEl = document.getElementById('ticket-save-status');
const ratingSel = form.querySelector('select[name="rating"]');
const starsPreview = form.querySelector('.ticket-stars-preview .star-rating');
if (ratingSel && starsPreview) {
ratingSel.addEventListener('change', () => {
const n = Math.max(0, Math.min(5, Number(ratingSel.value) || 0));
let html = '';
for (let i = 1; i <= 5; i++) {
html += '<span class="star' + (i <= n ? ' star--on' : '') + '"></span>';
}
starsPreview.innerHTML = html;
});
}
form.addEventListener('submit', (e) => {
e.preventDefault();
if (statusEl) statusEl.textContent = 'Saving…';
const fd = new FormData(form);
const body = {
id: Number(form.getAttribute('data-ticket-id')),
title: fd.get('title'),
brief: fd.get('brief'),
body: fd.get('body'),
owner_username: fd.get('owner_username'),
verified_by_username: fd.get('verified_by_username'),
rating: Number(fd.get('rating')),
workflow_state: fd.get('workflow_state'),
assignees: assignees,
};
const xhr = new XMLHttpRequest();
xhr.open('PUT', basePath() + '/api/ticket_update.php', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
let data;
try {
data = JSON.parse(xhr.responseText);
} catch {
if (statusEl) statusEl.textContent = 'Invalid response';
return;
}
if (!data.ok) {
if (statusEl) statusEl.textContent = data.error || 'Save failed';
return;
}
if (statusEl) statusEl.textContent = 'Saved';
};
xhr.onerror = function () {
if (statusEl) statusEl.textContent = t('reports.network_error');
};
xhr.send(JSON.stringify(body));
});
}
function initTagFilterBar(listContext) {
const chipsEl = document.getElementById('tag-filter-chips');
const modeWrap = document.getElementById('tag-mode-wrap');
const modeSel = document.getElementById('tag-mode-select');
if (!chipsEl) return;
function renderFilterChips(workflow) {
const active = listContext.filterTags || [];
chipsEl.innerHTML = (workflow || [])
.map((tag) => {
const on = active.includes(tag.id);
return (
'<a class="tag-filter-chip' +
(on ? ' tag-filter-chip--active' : '') +
'" href="' +
escapeHtml(reportsUrlWithTagToggle(tag.id, listContext.tagMode)) +
'" style="--tag-bg:' +
escapeHtml(tag.bg) +
'">' +
escapeHtml(tag.label) +
'</a>'
);
})
.join('');
}
if (modeSel) {
modeSel.value = listContext.tagMode === 'or' ? 'or' : 'and';
modeSel.addEventListener('change', () => {
if (!(listContext.filterTags && listContext.filterTags.length)) return;
const p = new URLSearchParams(window.location.search);
p.set('view', 'reports');
p.set('tag_mode', modeSel.value === 'or' ? 'or' : 'and');
p.delete('page');
window.location.href = basePath() + '/?' + p.toString();
});
}
const xhr = new XMLHttpRequest();
xhr.open('GET', basePath() + '/api/tag_catalog.php', true);
xhr.onload = function () {
let data;
try {
data = JSON.parse(xhr.responseText);
} catch {
return;
}
if (!data.ok) return;
renderFilterChips(data.workflow || []);
if (modeWrap) {
modeWrap.hidden = !(listContext.filterTags && listContext.filterTags.length);
}
};
xhr.send();
}
function initTagEditButtons(tbody, reloadFn) {
reportsReloadRef = reloadFn;
if (!tbody) return;
tbody.addEventListener('click', (e) => {
const btn = e.target.closest('.tag-edit-btn');
if (!btn) return;
e.stopPropagation();
e.preventDefault();
openTagModal(Number(btn.getAttribute('data-report-id')), 'report');
});
}
function boot() {
initTheme();
initNav();
initTagModal();
initReportDetail();
initTicketDetail();
initTagEditorDetail();
initReportsApp();
}
function onReady(fn) {
const run = () => {
if (window.CrashI18n && window.CrashI18n.ready) {
window.CrashI18n.ready.then(fn);
} else {
fn();
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', run);
} else {
run();
}
}
onReady(boot);
})();