mirror of
git://f0xx.org/ac/ac-platform-web
synced 2026-07-29 05:00:00 +03:00
2068 lines
66 KiB
JavaScript
2068 lines
66 KiB
JavaScript
(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, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
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);
|
||
})();
|