commit aef9e33b734ffdd4fb047cae3e930ef6205c509c Author: Anton Afanasyeu Date: Tue Jun 23 12:29:29 2026 +0200 initial diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b0d352 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# ac-platform-web + +Shared CSS/JS/i18n for BE consoles. + +Remote: `git://f0xx.org/ac/ac-platform-web` diff --git a/assets/css/app.css b/assets/css/app.css new file mode 100644 index 0000000..e8cda5e --- /dev/null +++ b/assets/css/app.css @@ -0,0 +1,2423 @@ +:root, +[data-theme="dark"] { + --bg: #0f1419; + --surface: #1a2332; + --surface2: #0a0e14; + --border: #2d3a4f; + --text: #e7ecf3; + --muted: #8b9cb3; + --accent: #3d8bfd; + --accent2: #5eead4; + --danger: #f87171; + --row-hover: rgba(61, 139, 253, .12); + --row-open: rgba(61, 139, 253, .08); +} + +[data-theme="light"] { + --bg: #f0f2f6; + --surface: #ffffff; + --surface2: #e8ecf2; + --border: #c5d0e0; + --text: #1a2332; + --muted: #5a6b82; + --accent: #2563eb; + --accent2: #0d9488; + --danger: #dc2626; + --row-hover: rgba(37, 99, 235, .1); + --row-open: rgba(37, 99, 235, .06); +} +* { box-sizing: border-box; } +html { + width: 100%; + max-width: 100%; + overflow-x: clip; +} +[hidden] { + display: none !important; + pointer-events: none !important; +} +dialog:not([open]) { + display: none !important; +} +dialog:not([open])::backdrop { + display: none !important; + pointer-events: none !important; +} +dialog::backdrop { + pointer-events: auto; +} +body { + margin: 0; + font-family: "Segoe UI", system-ui, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + min-height: 100dvh; + width: 100%; + max-width: 100%; + overflow-x: clip; +} +body:has(.shell) { + display: block; + overflow: hidden; + height: 100vh; + height: 100dvh; +} +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +.shell { + display: flex; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: var(--footer-h, 40px); + width: 100%; + max-width: 100%; + min-height: 0; + overflow: hidden; + z-index: 1; +} +.shell.single .main-pane { max-width: 1100px; margin: 0 auto; padding: 24px; } +.shell.shell--graphs-full .main-pane { + max-width: none; + width: 100%; + min-width: 0; + padding: 20px 24px 32px; + box-sizing: border-box; +} +.shell.shell--graphs-full .graphs-app, +.shell.shell--graphs-full .graphs-grid { + max-width: 100%; + min-width: 0; +} +.shell.shell--graphs-full .graph-detail-body { + max-width: none; +} +.shell.shell--graphs-full .graph-detail-table { + max-width: none; +} +.nav-pane { + width: 56px; + height: 100%; + max-height: 100%; + align-self: stretch; + background: var(--surface); + border-right: 1px solid var(--border); + transition: width .22s ease; + overflow: hidden; + position: relative; + flex-shrink: 0; + display: flex; + flex-direction: column; +} +.nav-pane.open { width: 240px; } +.nav-handle { + position: absolute; + top: 8px; + left: 50%; + transform: translateX(-50%); + width: 40px; + height: 40px; + transition: left .22s ease, transform .22s ease; + padding: 0; + border: none; + border-radius: 10px; + cursor: pointer; + background: transparent; + color: var(--muted); + display: flex; + align-items: center; + justify-content: center; + transition: background .15s ease, color .15s ease; +} +.nav-handle:hover, +.nav-handle:focus-visible { + background: rgba(61, 139, 253, .15); + color: var(--text); + outline: none; +} +.nav-pane.open .nav-handle { + left: 8px; + transform: none; +} +.nav-list { + list-style: none; + margin: 56px 0 0; + padding: 0 8px; + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; +} +.nav-list li { margin: 6px 0; } +.nav-link { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + margin: 0 auto; + padding: 0; + border-radius: 10px; + color: var(--muted); + text-decoration: none; + transition: background .15s ease, color .15s ease, width .22s ease, padding .22s ease; +} +.nav-pane.open .nav-link { + width: 100%; + height: auto; + min-height: 40px; + margin: 0; + padding: 8px 12px; + justify-content: flex-start; + align-items: flex-start; + gap: 10px; +} +.nav-pane.open .nav-link > .nav-icon { + flex-shrink: 0; + margin-top: 2px; +} +.nav-link.active, +.nav-link:hover { + background: rgba(61, 139, 253, .22); + color: var(--accent); + text-decoration: none; +} +.nav-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1 1 auto; + opacity: 0; + max-width: 0; + overflow: hidden; + transition: opacity .18s ease, max-width .22s ease; +} +.nav-pane.open .nav-text { + opacity: 1; + max-width: 11rem; + transition-delay: .06s; +} +.nav-label { + flex: 0 1 auto; + min-width: 0; + font-size: 14px; + font-weight: 500; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.nav-desc { + display: none; + font-size: 11px; + font-weight: 400; + line-height: 1.25; + color: var(--muted); + white-space: normal; + overflow: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.nav-pane.open .nav-desc { + display: -webkit-box; +} +.nav-link > .nav-label { + opacity: 0; + max-width: 0; + transition: opacity .18s ease, max-width .22s ease; +} +.nav-pane.open .nav-link > .nav-label { + opacity: 1; + max-width: 9rem; + transition-delay: .06s; +} +.nav-text .nav-label { + opacity: 1; + max-width: none; + transition: none; +} +.nav-handle.is-dragging { + cursor: grabbing; +} +@media (prefers-reduced-motion: reduce) { + .nav-pane, + .nav-link, + .nav-label, + .nav-text, + .locale-code { + transition: none; + } + .nav-pane.open .nav-link > .nav-label, + .nav-pane.open .nav-text, + .nav-pane.open .locale-code { + transition-delay: 0s; + } +} +.nav-icon { + display: block; + flex-shrink: 0; + position: relative; + box-sizing: border-box; +} +/* Hamburger (nav handle) */ +.nav-icon--menu { + width: 18px; + height: 2px; + background: currentColor; + border-radius: 1px; + box-shadow: 0 -6px 0 currentColor, 0 6px 0 currentColor; +} +/* House */ +.nav-icon--home { + width: 18px; + height: 16px; +} +.nav-icon--home::before { + content: ""; + position: absolute; + left: 50%; + top: 0; + transform: translateX(-50%); + border-left: 9px solid transparent; + border-right: 9px solid transparent; + border-bottom: 7px solid currentColor; +} +.nav-icon--home::after { + content: ""; + position: absolute; + left: 3px; + bottom: 0; + width: 12px; + height: 9px; + border: 2px solid currentColor; + border-top: none; + border-radius: 0 0 2px 2px; + box-sizing: border-box; +} +/* Stacked reports */ +.nav-icon--reports { + width: 16px; + height: 18px; + border: 2px solid currentColor; + border-radius: 3px; +} +.nav-icon--reports::before, +.nav-icon--reports::after { + content: ""; + position: absolute; + left: 3px; + right: 3px; + height: 2px; + background: currentColor; + border-radius: 1px; +} +.nav-icon--reports::before { top: 5px; } +.nav-icon--reports::after { top: 10px; width: 70%; } +/* Task ticket — clipboard + check (distinct from stacked “reports” document) */ +.nav-icon--tickets { + width: 14px; + height: 16px; + margin-top: 2px; + border: 2px solid currentColor; + border-top: none; + border-radius: 0 0 4px 4px; + box-sizing: border-box; +} +.nav-icon--tickets::before { + content: ""; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 9px; + height: 5px; + border: 2px solid currentColor; + border-bottom: none; + border-radius: 3px 3px 0 0; + box-sizing: border-box; + background: var(--surface); +} +.nav-icon--tickets::after { + content: ""; + position: absolute; + left: 3px; + bottom: 3px; + width: 6px; + height: 4px; + border-left: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: rotate(-45deg); + transform-origin: left bottom; + box-sizing: border-box; +} +.nav-icon--graphs { + width: 16px; + height: 16px; + border-left: 2px solid currentColor; + border-bottom: 2px solid currentColor; +} +.nav-icon--graphs::before, +.nav-icon--graphs::after { + content: ""; + position: absolute; + bottom: 0; + width: 3px; + background: currentColor; + border-radius: 1px 1px 0 0; +} +.nav-icon--graphs::before { + left: 4px; + height: 8px; +} +.nav-icon--graphs::after { + left: 10px; + height: 12px; +} +.ticket-col-issue { min-width: 200px; max-width: 420px; } +.ticket-issue-title { font-weight: 600; } +.ticket-issue-brief { font-size: 0.88em; margin-top: 4px; } +.ticket-col-env { font-size: 0.9em; max-width: 220px; } +.assignee-editor { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; } +.assignee-pill { + display: inline-flex; align-items: center; gap: 6px; + padding: 4px 10px; border-radius: 999px; background: var(--surface-2, #2a3344); + font-size: 0.9rem; +} +.assignee-remove { + border: none; background: transparent; color: var(--muted, #94a3b8); + cursor: pointer; font-size: 1.1rem; line-height: 1; padding: 0 2px; +} +.assignee-add-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } +.ticket-attachments { margin-top: 24px; } +.ticket-attachment-list { list-style: none; padding: 0; margin: 0 0 16px; } +.ticket-attachment { + display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; + padding: 10px 12px; margin-bottom: 8px; border-radius: 8px; + background: var(--surface-2, #1e293b); +} +.attachment-provider { + font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; + color: var(--muted, #94a3b8); +} +.ticket-attachment-add { + display: grid; grid-template-columns: 1fr 1fr; gap: 16px; +} +@media (max-width: 720px) { + .ticket-attachment-add { grid-template-columns: 1fr; } +} +.btn-small { padding: 2px 8px; font-size: 0.85rem; min-height: auto; } +.ticket-comments { margin-top: 24px; } +.ticket-comment-list { list-style: none; padding: 0; margin: 0 0 16px; } +.ticket-comment { + padding: 12px 14px; margin-bottom: 10px; border-radius: 8px; + background: var(--surface-2, #1e293b); +} +.ticket-comment-meta { margin-bottom: 8px; display: flex; gap: 10px; flex-wrap: wrap; } +.ticket-comment-body { + margin: 0; white-space: pre-wrap; font-family: inherit; font-size: 0.95rem; + background: transparent; border: none; padding: 0; +} +.ticket-edit-form { display: grid; grid-template-columns: 1fr min(320px, 100%); gap: 16px; align-items: start; } +.ticket-edit-form .card--wide { grid-column: 1 / -1; } +@media (min-width: 900px) { + .ticket-edit-form { grid-template-columns: 1fr 280px; } + .ticket-edit-form .card--wide { grid-column: 1; grid-row: 1 / span 2; } +} +.ticket-field { display: block; margin-bottom: 12px; } +.ticket-field span { display: block; font-size: 0.85em; color: var(--muted); margin-bottom: 4px; } +.ticket-field input, +.ticket-field textarea, +.ticket-field select { + width: 100%; + box-sizing: border-box; + padding: 8px 10px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); +} +.ticket-body { white-space: pre-wrap; word-break: break-word; } +/* User */ +.nav-icon--user { + width: 18px; + height: 18px; +} +.nav-icon--user::before { + content: ""; + position: absolute; + top: 1px; + left: 50%; + transform: translateX(-50%); + width: 8px; + height: 8px; + border: 2px solid currentColor; + border-radius: 50%; + box-sizing: border-box; +} +.nav-icon--user::after { + content: ""; + position: absolute; + left: 2px; + right: 2px; + bottom: 0; + height: 7px; + border: 2px solid currentColor; + border-top: none; + border-radius: 8px 8px 3px 3px; + box-sizing: border-box; +} +/* Logout — door + arrow */ +.nav-icon--logout { + width: 18px; + height: 16px; +} +.nav-icon--logout::before { + content: ""; + position: absolute; + left: 0; + top: 1px; + width: 10px; + height: 14px; + border: 2px solid currentColor; + border-right: none; + border-radius: 2px 0 0 2px; + box-sizing: border-box; +} +.nav-icon--logout::after { + content: ""; + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + width: 0; + height: 0; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 7px solid currentColor; +} +.nav-user { + position: static; + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + color: var(--muted); + padding: 8px 8px 10px; + box-sizing: border-box; +} +.nav-pane.open .nav-user { + align-items: stretch; + padding: 0 10px; +} +.nav-user-name { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + color: var(--muted); + gap: 10px; +} +.nav-pane.open .nav-user-name { + width: 100%; + padding: 0 12px; + justify-content: flex-start; + box-sizing: border-box; +} +.nav-pane.open .nav-link--logout { + width: 100%; + margin: 0; + padding: 0 12px; + justify-content: flex-start; + gap: 10px; +} +.nav-link--logout { color: var(--muted); } +.nav-link--logout:hover { color: var(--danger); background: rgba(248, 113, 113, .12); } +.main-pane { + flex: 1 1 auto; + min-height: 0; + min-width: 0; + max-width: 100%; + height: 100%; + overflow-x: hidden; + overflow-y: auto; + padding: 24px 28px; + box-sizing: border-box; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + -ms-overflow-style: none; +} +.main-pane::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} +body { --footer-h: 40px; } +.bottom-bar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + flex-shrink: 0; + height: var(--footer-h); + border-top: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + font-size: 12px; + color: var(--muted); + background: var(--surface); + z-index: 40; + pointer-events: none; +} +.bottom-bar a, +.bottom-bar button, +.bottom-bar input, +.bottom-bar select, +.bottom-bar textarea { + pointer-events: auto; +} +.bottom-bar.platform-footer { + height: var(--footer-h); + min-height: var(--footer-h); + max-height: var(--footer-h); + overflow: hidden; +} +.platform-footer { + flex-direction: column; + align-items: stretch; + justify-content: center; + min-height: var(--footer-h); + padding: 6px 16px; + gap: 4px; +} +.platform-footer__top, +.platform-footer__bottom { + width: 100%; + text-align: center; + line-height: 1.35; +} +.platform-footer__main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + width: 100%; + min-height: 24px; +} +.platform-footer--single-row .platform-footer__main { + justify-content: flex-start; +} +.platform-footer__left { + flex: 1 1 auto; + min-width: 0; +} +.platform-footer__right { + flex: 0 0 auto; + text-align: right; + max-width: 50%; +} +.login-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + min-height: 100dvh; + padding: 3rem 16px calc(var(--footer-h, 40px) + 1.5rem); + box-sizing: border-box; + position: relative; + isolation: isolate; + z-index: 0; +} +.login-page .login-card { + position: relative; + z-index: 100; + pointer-events: auto; +} +.login-page .login-locale { + z-index: 101; + pointer-events: auto; +} +.login-page .bottom-bar { + z-index: 50; +} +.login-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 32px; + width: min(400px, 92vw); + display: flex; + flex-direction: column; + gap: 12px; +} +.login-card input { + width: 100%; + padding: 10px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); +} +.login-card button { + padding: 12px; + border: 1px solid var(--accent); + border-radius: 8px; + background: var(--accent); + color: #fff; + font-weight: 600; + cursor: pointer; + box-shadow: + 0 0 0 1px rgba(61, 139, 253, .25), + 0 4px 14px rgba(0, 0, 0, .3), + 0 0 20px rgba(61, 139, 253, .35); + transition: box-shadow .18s ease, filter .18s ease, transform .12s ease; +} +.login-card button:hover, +.login-card button:focus-visible { + filter: brightness(1.08); + box-shadow: + 0 0 0 1px rgba(61, 139, 253, .55), + 0 6px 18px rgba(0, 0, 0, .34), + 0 0 28px rgba(61, 139, 253, .48); + outline: none; +} +.toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; } +.btn { + display: inline-block; + padding: 8px 14px; + border-radius: 8px; + border: 1px solid var(--border); + margin-left: 8px; + cursor: pointer; + background: var(--surface); + color: var(--text); + text-decoration: none; + font: inherit; + line-height: 1.25; + box-shadow: + 0 0 0 1px rgba(61, 139, 253, .12), + 0 2px 10px rgba(0, 0, 0, .28), + 0 0 18px rgba(61, 139, 253, .08); + transition: + border-color .18s ease, + box-shadow .18s ease, + transform .12s ease, + filter .18s ease, + background .18s ease; +} +[data-theme="light"] .btn { + box-shadow: + 0 0 0 1px rgba(37, 99, 235, .14), + 0 2px 8px rgba(15, 23, 42, .12), + 0 0 16px rgba(37, 99, 235, .1); +} +.btn:hover, +.btn:focus-visible { + border-color: var(--accent); + text-decoration: none; + box-shadow: + 0 0 0 1px rgba(61, 139, 253, .45), + 0 4px 16px rgba(0, 0, 0, .32), + 0 0 22px rgba(61, 139, 253, .38); + outline: none; +} +[data-theme="light"] .btn:hover, +[data-theme="light"] .btn:focus-visible { + box-shadow: + 0 0 0 1px rgba(37, 99, 235, .5), + 0 4px 14px rgba(15, 23, 42, .16), + 0 0 20px rgba(37, 99, 235, .28); +} +.btn:active { + transform: translateY(1px); +} +.btn.active { background: var(--accent); border-color: var(--accent); color: #fff; } +.btn:disabled, +.btn[aria-disabled="true"] { + opacity: .55; + cursor: not-allowed; + box-shadow: 0 1px 4px rgba(0, 0, 0, .15); +} +.data-table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 14px; } +.data-table th, .data-table td { border-bottom: 1px solid var(--border); padding: 10px 8px; text-align: left; } +.reports-tree .report-tree-head { width: 2.5rem; padding: 0; } +.reports-tree .report-tree-cell { + width: 2.5rem; + padding: 6px 4px; + vertical-align: middle; +} +.report-tree-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + margin: 0; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: var(--muted); + cursor: pointer; + transition: background .12s ease, color .12s ease; +} +.report-tree-toggle:hover, +.report-tree-toggle:focus-visible { + background: rgba(61, 139, 253, .18); + color: var(--accent); + outline: none; +} +.report-tree-arrow { + display: block; + width: 0; + height: 0; + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; + border-left: 7px solid currentColor; + transition: transform .18s ease; +} +.report-tree-toggle[aria-expanded="true"] .report-tree-arrow { + transform: rotate(90deg); +} +.report-row { + transition: background .12s ease; +} +.report-row--nav { cursor: pointer; } +.report-row:hover, +.report-row:focus-within, +.report-brief-panel--nav:hover, +.report-brief-panel--nav:focus-within { + background: var(--row-hover); +} +.report-row.report-row--open { + background: var(--row-open); +} +.report-brief-panel--nav { + cursor: pointer; + border-radius: 0 8px 8px 0; + margin: 0; + padding: 0; +} +.report-brief-panel--nav:focus { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.report-brief-row[hidden] { display: none; } +.report-brief-cell { + padding: 0 12px 12px 2.75rem !important; + border-bottom: 1px solid var(--border); + background: var(--surface2); +} +.report-brief { + margin: 0; + padding: 10px 14px; + border-left: 3px solid var(--accent); + border-radius: 0 8px 8px 0; + font-size: 13px; + line-height: 1.45; +} +.report-brief p { margin: 0 0 6px; } +.report-brief p:last-child { margin-bottom: 0; } +.report-brief-hint { font-size: 12px; margin-top: 8px !important; } +.badge { background: var(--accent2); color: #042; padding: 2px 8px; border-radius: 999px; font-weight: 700; } +.stack { background: var(--surface2); padding: 16px; border-radius: 8px; overflow-x: auto; font-size: 13px; line-height: 1.5; } +.reports-toolbar .toolbar-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.toolbar-select { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--muted); } +.nav-locale { + flex-shrink: 0; + margin-top: auto; + padding: 8px 10px 6px; + border-top: 1px solid var(--border); +} +.nav-locale .locale-toolbar-select { + width: 100%; + justify-content: center; + margin: 0; +} +.nav-pane.open .nav-locale .locale-toolbar-select { + justify-content: flex-start; +} +.locale-toolbar-select { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--muted); + cursor: pointer; +} +.locale-toolbar-select .locale-flag { + flex-shrink: 0; + font-size: 1rem; + line-height: 1; + width: 1.25rem; + text-align: center; + user-select: none; + pointer-events: none; +} +.locale-toolbar-select select { + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + font-size: 13px; + font-weight: 600; + cursor: pointer; + min-width: 4.5rem; +} +.nav-locale .locale-toolbar-select select { + flex: 1 1 auto; + min-width: 0; + max-width: 100%; +} +.login-locale { + position: fixed; + top: 12px; + right: 12px; + z-index: 5; +} +.login-page .login-card { margin-top: 2.5rem; } +.login-card input, +.login-card button, +.login-card textarea, +.login-card select { + pointer-events: auto; +} +.toolbar-select select { + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); +} +.reports-mode-btn { margin-left: 0; } +.reports-mode-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; } +.reports-status { margin: 8px 0 0; min-height: 1.2em; } +.reports-table-wrap { overflow-x: auto; } +.reports-table--cols { + table-layout: fixed; + width: 100%; +} +.reports-table--cols th, +.reports-table--cols td { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + position: relative; + vertical-align: middle; +} +.reports-table--cols .col-tags, +.reports-table--cols .report-tags { + white-space: normal; +} +.th-inner { + display: flex; + align-items: center; + gap: 4px; + padding-right: 8px; + min-height: 1.5rem; +} +.data-table th.th-draggable { + cursor: grab; + user-select: none; +} +.data-table th.th-draggable:active { + cursor: grabbing; +} +.th-drag { + color: var(--muted); + font-size: 11px; + letter-spacing: -2px; + user-select: none; + flex-shrink: 0; +} +.th-label { flex: 1; min-width: 0; } +.th-draggable.th-dragging { opacity: .45; } +.th-draggable.th-dragging .th-label { + color: var(--accent); + font-weight: 600; +} +.data-table th.th-drop-target { + box-shadow: inset 0 -3px 0 var(--accent); + background: var(--row-hover); +} +.col-drag-ghost { + position: fixed; + top: -1000px; + left: -1000px; + z-index: 9999; + padding: 6px 12px; + border-radius: 8px; + background: var(--accent); + color: #fff; + font-size: 13px; + font-weight: 600; + box-shadow: 0 6px 18px rgba(0, 0, 0, .35); + pointer-events: none; + white-space: nowrap; +} +.col-resizer { + position: absolute; + top: 0; + right: 0; + width: 7px; + height: 100%; + cursor: col-resize; + user-select: none; + z-index: 2; +} +.col-resizer:hover, +.col-resizer:active { + background: rgba(61, 139, 253, .35); +} +.report-tree-head .col-resizer { display: none; } +.reports-pagination { margin-top: 16px; } +.pagination-inner { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.page-btn:disabled { opacity: .45; cursor: not-allowed; } +.page-info { font-size: 13px; color: var(--muted); } +.data-table th.sortable { + cursor: pointer; + user-select: none; + white-space: nowrap; +} +.data-table th.sortable:hover { color: var(--accent); } +.data-table th.sortable--active { color: var(--accent); } +.sort-ind { display: inline-block; min-width: 1em; font-size: 11px; opacity: .85; } +.sort-idle { opacity: .35; } +.col-tags { + min-width: 6rem; + max-width: 11rem; + text-align: right; + vertical-align: middle; +} +.col-tags .report-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + justify-content: flex-end; +} +.data-table th.col-tags { text-align: right; } +.report-tag { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + line-height: 1.4; + color: #fff; + background: var(--tag-bg, #5c6b82); +} +button.report-tag--filter { + border: none; + cursor: pointer; + font: inherit; + color: inherit; + line-height: 1.3; +} +button.report-tag--filter:hover { + filter: brightness(1.12); +} +.reports-filter-banner { + margin: 8px 0 0; + padding: 10px 12px; + border-radius: 8px; + background: var(--surface2); + border: 1px solid var(--border); +} +.reports-filter-banner a { font-weight: 600; } + +.reports-search-row { + display: flex; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-end; + gap: 10px 12px; + width: 100%; + margin: 12px 0 8px; + box-sizing: border-box; +} +.reports-search-label { + font-size: 13px; + color: var(--muted); + flex: 0 0 auto; + margin: 0; + line-height: 1.2; + align-self: center; +} +.reports-search-field { + position: relative; + flex: 0 0 40%; + width: 40%; + max-width: 40%; + align-self: center; +} +.reports-search-row .btn { + flex: 0 0 auto; + align-self: center; + margin-left: 0; +} +.reports-search-input { + width: 100%; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + font-size: 14px; +} +.reports-search-input:focus { + outline: 2px solid var(--accent); + outline-offset: 1px; +} +.reports-search-suggest { + position: absolute; + z-index: 20; + left: 0; + right: 0; + top: calc(100% + 4px); + margin: 0; + padding: 4px 0; + list-style: none; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, .25); + max-height: 240px; + overflow-y: auto; +} +.reports-search-suggest[hidden] { display: none; } +.suggest-item { + display: block; + width: 100%; + text-align: left; + padding: 8px 12px; + border: none; + background: transparent; + color: var(--text); + font-size: 13px; + cursor: pointer; +} +.suggest-item:hover, +.suggest-item:focus-visible { + background: var(--row-hover); + outline: none; +} + +.stack-block-head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} +.stack-block-head h2 { margin: 0; } +.btn-similar { + margin-left: 0; + white-space: nowrap; + font-size: 13px; +} + +.detail-filter-link { + color: var(--text); + text-decoration: none; + border-radius: 6px; + padding: 2px 4px; + margin: -2px -4px; + transition: background .12s ease, color .12s ease; +} +.detail-filter-link:hover, +.detail-filter-link:focus-visible { + background: var(--row-hover); + color: var(--accent); + outline: none; +} +.detail-filter-link--tag { + display: inline-block; + padding: 2px 8px; + margin: 0; + border-radius: 999px; + border: none; + font-size: 11px; + font-weight: 600; + line-height: 1.4; + color: #fff; + background: var(--tag-bg, #5c6b82); +} +.detail-filter-link--tag:hover, +.detail-filter-link--tag:focus-visible { + color: #fff; + background: var(--tag-bg, #5c6b82); + filter: brightness(1.12); + outline: none; +} +.detail-abi-row, +.detail-meta-row { + line-height: 1.8; +} +.detail-meta-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.exc { color: var(--danger); font-weight: 600; } +.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin: 20px 0; } +.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 16px; } +.card canvas { + width: 100%; + max-width: 100%; + height: 180px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface2); +} +.cards--lift .card--lift { + transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease; +} +.cards--lift .card--lift:hover { + transform: translateY(-4px); + box-shadow: 0 10px 24px rgba(0, 0, 0, .22); + border-color: var(--accent); +} +[data-theme="light"] .cards--lift .card--lift:hover { + box-shadow: 0 10px 22px rgba(26, 35, 50, .14); +} +.detail-header { + display: flex; + align-items: flex-start; + gap: 14px; + margin-bottom: 8px; +} +.back-link { + display: flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + border-radius: 10px; + border: 1px solid var(--border); + color: var(--text); + text-decoration: none; + flex-shrink: 0; + transition: background .15s ease, border-color .15s ease, color .15s ease; +} +.back-link:hover { + background: var(--row-hover); + border-color: var(--accent); + color: var(--accent); + text-decoration: none; +} +.back-icon { + display: block; + width: 10px; + height: 10px; + margin-left: 3px; + border-left: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: rotate(45deg); +} +.detail-tree { + margin-top: 20px; + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; +} +.detail-tree-item + .detail-tree-item { + border-top: 1px solid var(--border); +} +.detail-tree-row { + display: flex; + align-items: stretch; + width: 100%; + cursor: pointer; + transition: background .12s ease; +} +.detail-tree-row:hover, +.detail-tree-row:focus-within, +.detail-tree-row.report-row--open { + background: var(--row-hover); +} +.detail-tree-caption { + flex: 1; + display: flex; + align-items: center; + padding: 10px 12px; + font-weight: 600; +} +.detail-tree-body { + padding: 0 14px 14px 2.75rem; + background: var(--surface2); + border-top: 1px solid var(--border); +} +.detail-tree-body[hidden] { display: none; } +.star-rating { display: inline-flex; gap: 2px; line-height: 1; } +.star { + width: 14px; + height: 14px; + clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%); + background: var(--border); +} +.star--on { background: #f59e0b; } +[data-theme="light"] .star--on { background: #d97706; } +.muted { color: var(--muted); } +.alert { background: rgba(248,113,113,.15); color: var(--danger); padding: 10px; border-radius: 8px; } +.alert--ok { background: rgba(52,211,153,.15); color: var(--ok, #34d399); } + +.btn-primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; + box-shadow: + 0 0 0 1px rgba(61, 139, 253, .35), + 0 3px 12px rgba(0, 0, 0, .3), + 0 0 22px rgba(61, 139, 253, .32); +} +.btn-primary:hover, +.btn-primary:focus-visible { + filter: brightness(1.08); + box-shadow: + 0 0 0 1px rgba(61, 139, 253, .6), + 0 5px 18px rgba(0, 0, 0, .34), + 0 0 30px rgba(61, 139, 253, .5); +} +.btn-danger { + background: rgba(248, 113, 113, .14); + border-color: var(--danger); + color: var(--danger); + box-shadow: + 0 0 0 1px rgba(248, 113, 113, .28), + 0 3px 12px rgba(0, 0, 0, .28), + 0 0 18px rgba(248, 113, 113, .22); +} +[data-theme="light"] .btn-danger { + background: rgba(220, 38, 38, .08); + box-shadow: + 0 0 0 1px rgba(220, 38, 38, .3), + 0 3px 10px rgba(15, 23, 42, .12), + 0 0 16px rgba(220, 38, 38, .18); +} +.btn-danger:hover, +.btn-danger:focus-visible { + background: rgba(248, 113, 113, .22); + box-shadow: + 0 0 0 1px rgba(248, 113, 113, .55), + 0 5px 16px rgba(0, 0, 0, .32), + 0 0 26px rgba(248, 113, 113, .42); +} +.build-actions .toolbar-actions .btn, +.build-actions .toolbar-actions a.btn, +.build-log-actions .btn, +.build-log-actions a.btn { + margin-left: 0; +} +.build-ssh-panel { + margin-top: 12px; + padding: 12px 14px; + border-radius: 8px; + border: 1px solid var(--accent); + background: rgba(61, 139, 253, .08); + box-shadow: 0 0 18px rgba(61, 139, 253, .15); +} +.build-ssh-panel pre { + margin: 8px 0 0; + padding: 10px 12px; + background: var(--surface2); + border-radius: 6px; + overflow-x: auto; + font-size: 13px; +} + +/* Builder status glyphs (list + detail) */ +.build-status-line, +.build-status-cell-inner { + display: inline-flex; + align-items: center; + gap: 8px; + vertical-align: middle; +} +.build-status-icon { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} +.build-status-spinner { + display: block; + width: 15px; + height: 15px; + border: 2px solid rgba(61, 139, 253, .22); + border-top-color: var(--accent); + border-radius: 50%; + animation: build-status-spin .85s linear infinite; + box-shadow: 0 0 10px rgba(61, 139, 253, .35); +} +[data-theme="light"] .build-status-spinner { + border-color: rgba(37, 99, 235, .18); + border-top-color: var(--accent); + box-shadow: 0 0 8px rgba(37, 99, 235, .28); +} +@keyframes build-status-spin { + to { transform: rotate(360deg); } +} +.build-status-icon--passed { + width: 17px; + height: 17px; + border-radius: 50%; + background: linear-gradient(145deg, #34d399, #16a34a); + box-shadow: + 0 0 0 1px rgba(34, 197, 94, .45), + 0 0 12px rgba(34, 197, 94, .4); + position: relative; +} +.build-status-icon--passed::after { + content: ''; + position: absolute; + left: 5px; + top: 3px; + width: 4px; + height: 7px; + border: solid #fff; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} +.build-status-icon--failed { + width: 18px; + height: 16px; + position: relative; + clip-path: polygon(50% 0%, 6% 92%, 94% 92%); + background: linear-gradient(180deg, #fb7185, var(--danger)); + box-shadow: 0 0 12px rgba(248, 113, 113, .45); +} +.build-status-fail-mark { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding-top: 5px; + font-size: 11px; + font-weight: 800; + line-height: 1; + color: #fff; + text-shadow: 0 1px 0 rgba(0, 0, 0, .25); + pointer-events: none; +} + +.build-col-controls { width: 4.5rem; } +.build-row-controls { vertical-align: middle; } +.build-transport-actions { + display: inline-flex; + align-items: center; + gap: 6px; +} +.build-transport-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + border-radius: 50%; + border: 1px solid var(--border); + background: var(--surface); + cursor: pointer; + box-shadow: + 0 0 0 1px rgba(61, 139, 253, .12), + 0 2px 8px rgba(0, 0, 0, .25), + 0 0 14px rgba(61, 139, 253, .1); + transition: box-shadow .18s ease, border-color .18s ease, transform .12s ease; +} +.build-transport-btn:hover:not(:disabled), +.build-transport-btn:focus-visible:not(:disabled) { + border-color: var(--accent); + box-shadow: + 0 0 0 1px rgba(61, 139, 253, .45), + 0 0 18px rgba(61, 139, 253, .35); + outline: none; +} +.build-transport-btn:active:not(:disabled) { transform: translateY(1px); } +.build-transport-btn:disabled { + opacity: .35; + cursor: not-allowed; + box-shadow: none; +} +.build-transport-play::before { + content: ''; + display: block; + width: 0; + height: 0; + margin-left: 3px; + border-top: 6px solid transparent; + border-bottom: 6px solid transparent; + border-left: 10px solid var(--accent); + filter: drop-shadow(0 0 4px rgba(61, 139, 253, .45)); +} +.build-transport-stop { + border-color: rgba(248, 113, 113, .55); + box-shadow: + 0 0 0 1px rgba(248, 113, 113, .25), + 0 2px 8px rgba(0, 0, 0, .25), + 0 0 12px rgba(248, 113, 113, .18); +} +.build-transport-stop::before { + content: ''; + display: block; + width: 9px; + height: 9px; + border-radius: 2px; + background: var(--danger); + box-shadow: 0 0 8px rgba(248, 113, 113, .55); +} + +.tag-edit-btn { + flex-shrink: 0; + margin-left: 2px; + padding: 0 5px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--muted); + font-size: 13px; + line-height: 1.2; + cursor: pointer; + vertical-align: middle; +} +.tag-edit-btn:hover, +.tag-edit-btn:focus-visible { + background: rgba(61, 139, 253, .2); + color: var(--accent); + outline: none; +} + +.tag-editor { + margin: 20px 0 24px; + padding: 16px 18px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; +} +.tag-editor-head h2 { margin: 0 0 4px; font-size: 1.1rem; } +.tag-editor-hint { margin: 0 0 12px; font-size: 13px; } +.tag-editor-preview { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-height: 1.5rem; + margin-bottom: 12px; +} +.tag-editor-list { + list-style: none; + margin: 0 0 12px; + padding: 0; +} +.tag-editor-item { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid var(--border); +} +.tag-editor-item:last-child { border-bottom: none; } +.tag-color-edit { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--muted); +} +.tag-color-edit input[type="color"] { + width: 2rem; + height: 1.6rem; + padding: 0; + border: 1px solid var(--border); + border-radius: 4px; + cursor: pointer; +} +.tag-editor-add { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 10px 14px; + margin-bottom: 12px; +} +.tag-field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + color: var(--muted); +} +.tag-field input[type="text"] { + padding: 8px 10px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + min-width: 10rem; +} +.tag-editor-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; +} +.tag-editor-status--err { color: var(--danger); } +.tag-preset-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-bottom: 12px; + font-size: 13px; +} +.tag-preset-chips { display: flex; flex-wrap: wrap; gap: 6px; } +.tag-preset-chip { + padding: 4px 10px; + border: 1px solid var(--border); + border-radius: 999px; + font-size: 12px; + font-weight: 600; + color: #fff; + background: var(--tag-bg, #5c6b82); + cursor: pointer; +} +.tag-preset-chip:hover { filter: brightness(1.12); } +.tag-preset-group { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} +.tag-preset-group:last-child { margin-bottom: 0; } +.tag-preset-group-label { font-size: 12px; min-width: 5.5rem; } + +.tag-filter-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin: 0 0 12px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-2, rgba(255, 255, 255, .03)); +} +.tag-filter-label { font-size: 13px; } +.tag-filter-chips { display: flex; flex-wrap: wrap; gap: 6px; } +.tag-filter-chip { + display: inline-block; + padding: 4px 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + color: #fff; + background: var(--tag-bg, #5c6b82); + text-decoration: none; + border: 2px solid transparent; + opacity: 0.72; +} +.tag-filter-chip:hover { filter: brightness(1.1); opacity: 1; } +.tag-filter-chip--active { + opacity: 1; + border-color: var(--accent, #3d8bfd); + box-shadow: 0 0 0 1px var(--accent, #3d8bfd); +} +.tag-mode-select-wrap { margin-left: auto; } + +.report-tag { + position: relative; + padding-right: 8px; +} +.report-tag .tag-remove { + margin-left: 4px; + padding: 0 3px; + border: none; + border-radius: 4px; + background: rgba(0, 0, 0, .25); + color: #fff; + font-size: 14px; + line-height: 1; + cursor: pointer; + vertical-align: middle; +} +.report-tag .tag-remove:hover { background: rgba(0, 0, 0, .45); } + +.tag-modal { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} +.tag-modal[hidden] { + display: none !important; + pointer-events: none !important; +} +.tag-modal-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, .55); + pointer-events: auto; +} +.tag-modal[hidden] .tag-modal-backdrop { + pointer-events: none; +} +.tag-modal-panel { + position: relative; + width: min(420px, 100%); + max-height: 90vh; + overflow-y: auto; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: 0 16px 48px rgba(0, 0, 0, .4); +} +.tag-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + border-bottom: 1px solid var(--border); +} +.tag-modal-header h2 { margin: 0; font-size: 1.05rem; } +.tag-modal-close { + border: none; + background: transparent; + color: var(--muted); + font-size: 1.4rem; + line-height: 1; + cursor: pointer; + padding: 4px 8px; + border-radius: 6px; +} +.tag-modal-close:hover { color: var(--text); background: var(--row-hover); } +.tag-editor--modal { margin: 0; border: none; border-radius: 0; } + +.console-home-intro { + margin: 0 0 20px; + color: var(--muted); + max-width: 42rem; + line-height: 1.5; +} +.console-home-cards { + margin: 0 0 28px; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); +} +.console-home-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + text-decoration: none; + color: var(--text); + cursor: pointer; + font: inherit; + text-align: left; + width: 100%; +} +.console-home-card--btn { + border: 1px solid var(--border); + background: var(--surface); +} +.console-home-card h2 { + margin: 0; + font-size: 1.05rem; + font-weight: 600; +} +.console-home-card p { + margin: 0; + font-size: 0.88rem; + line-height: 1.45; +} +.console-home-card .nav-icon { + opacity: 0.9; +} +.console-home-more { + margin: 8px 0 12px; + font-size: 0.95rem; + font-weight: 600; + color: var(--muted); +} +.graphs-app .graphs-quick-links, +.console-quick-links { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 12px; + align-items: center; +} +.console-quick-link { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-radius: 8px; + text-decoration: none; + color: var(--text); + background: var(--surface-2, rgba(255, 255, 255, 0.06)); + font-size: 0.9rem; + line-height: 1.2; + border: 1px solid var(--border, rgba(255, 255, 255, 0.08)); +} +.console-quick-link:hover { + background: var(--row-hover, rgba(255, 255, 255, 0.1)); + color: var(--text); +} +.console-quick-link .nav-icon { + flex-shrink: 0; +} +.nav-icon--git { + width: 18px; + height: 18px; +} +.nav-icon--git::before { + content: ""; + position: absolute; + left: 2px; + top: 2px; + width: 7px; + height: 7px; + border: 2px solid currentColor; + border-radius: 50%; + box-sizing: border-box; +} +.nav-icon--git::after { + content: ""; + position: absolute; + left: 8px; + top: 5px; + width: 10px; + height: 10px; + border: 2px solid currentColor; + border-left: none; + border-bottom: none; + border-radius: 0 6px 0 0; + box-sizing: border-box; +} +.nav-icon--builder { + width: 18px; + height: 18px; + border: 2px solid currentColor; + border-radius: 3px; + box-sizing: border-box; + position: relative; +} +.nav-icon--builder::before { + content: ""; + position: absolute; + left: 3px; + top: 7px; + width: 10px; + height: 2px; + background: currentColor; + box-shadow: 0 -4px 0 currentColor, 0 4px 0 currentColor; +} +.nav-icon--remote { + width: 18px; + height: 18px; +} +.nav-icon--remote::before { + content: ""; + position: absolute; + left: 2px; + top: 8px; + width: 14px; + height: 2px; + background: currentColor; + border-radius: 1px; +} +.nav-icon--remote::after { + content: ""; + position: absolute; + left: 5px; + top: 3px; + width: 8px; + height: 8px; + border: 2px solid currentColor; + border-radius: 2px; + box-sizing: border-box; +} +.nav-icon--link { + width: 18px; + height: 18px; +} +.nav-icon--link::before { + content: ""; + position: absolute; + left: 1px; + top: 9px; + width: 10px; + height: 10px; + border: 2px solid currentColor; + border-radius: 3px; + transform: rotate(-45deg); + box-sizing: border-box; +} +.nav-icon--link::after { + content: ""; + position: absolute; + left: 8px; + top: 2px; + width: 10px; + height: 10px; + border: 2px solid currentColor; + border-radius: 3px; + transform: rotate(-45deg); + box-sizing: border-box; +} +.tag-field--wide { + flex: 1 1 280px; + min-width: 200px; +} +.tag-field--wide input { + width: 100%; +} +.tag-field--check span { + display: flex; + align-items: center; + gap: 0.35rem; + white-space: nowrap; +} +.tag-field--check input[type="checkbox"] { + width: auto; + margin: 0; +} +.graph-quick-link { font-size: 0.9rem; } +.graphs-scope { margin-top: 20px; } +.graphs-scope-title { + margin: 0 0 12px; + font-size: 1.05rem; + font-weight: 600; + color: var(--text); +} +.graphs-app .graphs-grid { + display: grid; + grid-template-columns: repeat(var(--graphs-columns, 2), minmax(0, 1fr)); + gap: 16px; + width: 100%; +} +.graphs-grid .card.card--lift.graph-brick--clickable { + cursor: pointer; + min-height: 12rem; + transition: box-shadow 0.15s ease, border-color 0.15s ease; +} +.graphs-grid .card.card--lift.graph-brick--clickable:hover, +.graphs-grid .card.card--lift.graph-brick--clickable:focus-visible { + box-shadow: 0 0 0 1px var(--accent, #3d8bfd); + outline: none; +} +.graphs-grid .card.card--lift.graph-brick--clickable h3::after { + content: " ↗"; + font-size: 0.75em; + color: var(--muted, #94a3b8); + font-weight: 400; +} +.graphs-grid .card.card--lift.graph-brick--empty { + display: none; +} +.graphs-grid .card.card--lift.graph-brick--dragging { + opacity: 0.55; + outline: 2px dashed var(--accent); +} +.graphs-grid .card.card--lift h3.graph-brick-head { + display: flex; + align-items: center; + gap: 8px; +} +.graphs-session-notice { + margin: 0 0 12px; + padding: 10px 14px; + border-radius: 8px; + border: 1px solid rgba(245, 158, 11, 0.45); + background: rgba(245, 158, 11, 0.12); + color: var(--text); + font-size: 0.9rem; + line-height: 1.45; +} +.graphs-session-notice[hidden] { + display: none; +} +.graph-brick-drag-handle { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + margin: -2px 0; + padding: 0; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface2); + color: var(--text); + font: inherit; + line-height: 1; + cursor: grab; + user-select: none; + touch-action: none; +} +.graph-brick-drag-handle:hover { + border-color: var(--accent); + color: var(--accent); + background: var(--row-hover); +} +.graph-brick-drag-handle:active { + cursor: grabbing; +} +.graph-brick-drag-grip { + font-size: 12px; + letter-spacing: -2px; + pointer-events: none; +} +.graph-brick-tip { + position: absolute; + z-index: 2; + margin: 0; + padding: 4px 8px; + border-radius: 6px; + background: var(--surface2); + border: 1px solid var(--border); + font-size: 0.8rem; + pointer-events: none; + white-space: nowrap; +} +.ticket-create-dialog { + border: 1px solid var(--border); + border-radius: 12px; + padding: 0; + margin: auto; + max-width: 36rem; + width: calc(100% - 32px); + background: var(--surface); + color: var(--text); + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45); +} +.ticket-create-dialog::backdrop { + background: rgba(8, 12, 20, 0.72); +} +.ticket-create-form { + display: flex; + flex-direction: column; + margin: 0; +} +.ticket-create-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 18px; + border-bottom: 1px solid var(--border); +} +.ticket-create-header h2 { + margin: 0; + font-size: 1.05rem; + font-weight: 600; +} +.ticket-create-close { + border: none; + background: transparent; + color: var(--muted); + font-size: 1.4rem; + line-height: 1; + padding: 4px 8px; + border-radius: 8px; + cursor: pointer; +} +.ticket-create-close:hover { + color: var(--text); + background: var(--row-hover); +} +.ticket-create-body { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px 18px; +} +.ticket-create-body label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.9rem; + color: var(--muted); +} +.ticket-create-body input, +.ticket-create-body textarea { + width: 100%; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + font: inherit; +} +.ticket-create-footer { + padding: 12px 18px 16px; + border-top: 1px solid var(--border); +} +.ticket-create-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 8px; +} +.graphs-grid canvas { width: 100%; height: auto; max-width: 100%; min-height: 120px; } +.graph-detail-overlay { + position: fixed; + inset: 0; + z-index: 2000; + background: rgba(8, 12, 20, 0.92); + display: flex; + flex-direction: column; + pointer-events: auto; +} +.graph-detail-overlay[hidden] { + display: none !important; + pointer-events: none !important; +} +.graph-detail-panel { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + max-height: 100vh; + background: var(--bg, #0f1419); +} +.graph-detail-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 20px; + border-bottom: 1px solid var(--border, rgba(255, 255, 255, 0.1)); + flex-shrink: 0; +} +.graph-detail-header-text { + flex: 1; + min-width: 0; +} +.graph-detail-header h2 { + margin: 0; + font-size: 1.15rem; + font-weight: 600; +} +.graph-detail-stats { + margin: 4px 0 0; + font-size: 0.88rem; +} +.graph-detail-canvas-wrap { + position: relative; + flex: 1; + overflow: auto; + padding: 16px 20px 24px; +} +.graph-detail-canvas-wrap canvas { + display: block; + width: 100%; + max-width: 100%; + height: auto; +} +.graph-detail-body { + max-width: 960px; +} +.graph-detail-kpi { + font-size: 3rem; +} +.graph-detail-tooltip { + position: absolute; + z-index: 3; + margin: 0; + padding: 6px 10px; + border-radius: 6px; + background: var(--surface-2, #1e293b); + border: 1px solid var(--border, rgba(255, 255, 255, 0.15)); + color: var(--text); + font-size: 0.85rem; + pointer-events: none; + white-space: nowrap; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); +} +.graph-detail-table { + font-size: 1rem; + max-width: 720px; +} +.graph-kpi { + font-size: 2rem; + font-weight: 700; + margin: 12px 0 0; + color: var(--accent, #3d8bfd); +} +.graph-breakdown { font-size: 0.92rem; } +.graph-breakdown-row { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 6px 0; + border-bottom: 1px solid var(--border, rgba(255,255,255,0.08)); +} +.graph-breakdown-row a { color: var(--accent, #3d8bfd); text-decoration: none; } +.graph-breakdown-row a:hover { text-decoration: underline; } +.graphs-footnote { margin-top: 20px; font-size: 0.88rem; } + +.tag-pill { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 600; + background: rgba(61, 139, 253, .18); + color: var(--accent); +} +.tag-pill--warn { + background: rgba(245, 158, 11, .18); + color: #fbbf24; +} +[data-theme="light"] .tag-pill--warn { + color: #b45309; +} +.ra-device-row--needs-wl { + background: rgba(245, 158, 11, .06); +} +#ra-devices-table .report-row[data-device-id] { + cursor: pointer; +} +#ra-devices-table .col-actions, +#ra-devices-table .ra-device-actions { + white-space: nowrap; +} +.reports-table--cols .col-actions { + min-width: 160px; +} +.ra-device-actions { + white-space: nowrap; +} +.ra-detail-links { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} +.ra-whitelist-hint { + max-width: 72ch; + line-height: 1.5; +} + +/* Live cast pages */ +.live-page { + min-height: 100vh; + background: var(--bg-shell, #0f1419); +} +.live-shell { + max-width: 880px; + margin: 0 auto; + padding: 24px 16px 48px; +} +.live-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} +.live-brand { + font-weight: 600; + color: var(--text-primary, #e2e8f0); + text-decoration: none; +} +.live-card h1 { + margin-top: 0; +} +.live-meta-list { + list-style: none; + padding: 0; + margin: 12px 0; +} +.live-meta-list li { + padding: 4px 0; + color: var(--text-muted, #94a3b8); +} +.live-player-wrap, +.live-player-shell { + margin-top: 16px; + min-height: 200px; + border-radius: 12px; + background: rgba(15, 23, 42, 0.55); + padding: 16px; +} +.live-player-placeholder { + font-size: 1.1rem; + margin: 0 0 8px; +} +.live-preview { + width: 100%; + max-height: 360px; + border-radius: 10px; + background: #000; + margin: 12px 0; +} +.live-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} +.live-timer-label { + font-variant-numeric: tabular-nums; +} + +/* Cookie consent */ +.cookie-consent { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 1200; + padding: 12px; + pointer-events: none; +} +.cookie-consent-inner { + pointer-events: auto; + max-width: 720px; + margin: 0 auto; + padding: 16px 18px; + border-radius: 14px; + background: var(--card-bg, rgba(30, 41, 59, 0.96)); + border: 1px solid rgba(148, 163, 184, 0.25); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); +} +.cookie-consent-title { + margin: 0 0 6px; + font-weight: 600; +} +.cookie-consent-text { + margin: 0 0 12px; + font-size: 0.92rem; + line-height: 1.45; +} +.cookie-consent-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +/* Nav icons: live + education */ +.nav-icon--live { + width: 18px; + height: 16px; +} +.nav-icon--education { + width: 18px; + height: 16px; +} +.nav-icon--live::before { + content: ''; + position: absolute; + inset: 7px 11px 11px 7px; + border: 2px solid currentColor; + border-radius: 4px; +} +.nav-icon--live::after { + content: ''; + position: absolute; + width: 8px; + height: 8px; + border-radius: 50%; + background: #34d399; + top: 6px; + right: 6px; + box-shadow: 0 0 0 2px var(--nav-bg, #111827); +} +.nav-icon--education::before { + content: ''; + position: absolute; + left: 8px; + right: 8px; + bottom: 8px; + height: 10px; + border: 2px solid currentColor; + border-top: none; + border-radius: 0 0 4px 4px; +} +.nav-icon--education::after { + content: ''; + position: absolute; + left: 10px; + right: 10px; + top: 8px; + height: 8px; + border: 2px solid currentColor; + border-radius: 8px 8px 0 0; +} + +.tag-pill--ok { + background: rgba(52, 211, 153, 0.15); + color: #6ee7b7; +} + +.twofa-qr-wrap { + margin: 12px 0 16px; + text-align: center; +} +.twofa-qr { + border-radius: 8px; + background: #fff; + padding: 6px; +} +.twofa-qr-wrap figcaption { + margin-top: 8px; + font-size: 0.88rem; +} + +.live-share { + margin-top: 16px; + padding: 16px; +} +.live-share h2 { + margin: 0 0 8px; + font-size: 1.05rem; +} +.live-stats-nerds { + margin-top: 12px; + padding: 10px 12px; + border-radius: 8px; + background: rgba(15, 23, 42, 0.72); + border: 1px solid rgba(148, 163, 184, 0.25); + font-size: 0.78rem; + line-height: 1.45; + white-space: pre-wrap; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} +[data-theme="light"] .live-stats-nerds { + background: rgba(241, 245, 249, 0.95); + border-color: rgba(100, 116, 139, 0.35); +} +.live-player-wrap video.live-preview { + width: 100%; + max-height: 60vh; + background: #000; + border-radius: 8px; +} +.platform-footer__left a { + color: var(--accent, #5b8def); + text-decoration: none; +} +.platform-footer__left a:hover, +.platform-footer__left a:focus-visible { + text-decoration: underline; +} diff --git a/assets/i18n/en.json b/assets/i18n/en.json new file mode 100644 index 0000000..204b525 --- /dev/null +++ b/assets/i18n/en.json @@ -0,0 +1,218 @@ +{ + "app_name": "Android Cast Issues", + "nav.home": "Home", + "nav.reports": "Issues", + "nav.tickets": "Tickets", + "nav.logout": "Logout", + "nav.toggle": "Navigation", + "nav.home_desc": "Console home and workspace cards.", + "nav.reports_desc": "Browse and triage issues.", + "nav.tickets_desc": "Roadmap tasks, QA items, and tags.", + "nav.graphs": "Graphs", + "nav.graphs_desc": "Sessions, issues, and device activity.", + "nav.remote": "Remote access", + "nav.remote_desc": "WireGuard sessions and device reachability.", + "nav.access": "Access", + "nav.access_desc": "Roles, privilege sets, and operators.", + "nav.builder": "Builder", + "nav.builder_desc": "Docker CI for APK baking and OTA artifacts.", + "theme.label": "Theme", + "theme.dark": "Dark", + "theme.light": "Light", + "lang.label": "Language", + "lang.en": "English", + "lang.ru": "Russian", + "home.title": "Console", + "home.intro": "Issue triage, tickets, and session metrics — pick a workspace below.", + "home.card_issues": "Issues", + "home.card_issues_desc": "Browse and triage issues, tags, and fingerprints.", + "home.card_new_issue_desc": "Log a new issue or task in one step.", + "home.card_new_ticket_desc": "Create a roadmap or QA ticket in one step.", + "home.card_tickets_desc": "Roadmap tasks, QA items, and workflow tags.", + "home.card_graphs": "Graphs", + "home.card_graphs_desc": "Sessions, issues, and device activity over time.", + "home.card_short_links": "Short links", + "home.card_short_links_desc": "Mint bearer tokens and shorten URLs for s.f0xx.org.", + "home.more": "All services", + "reports.title": "Issues", + "reports.by_time": "By time", + "reports.grouped": "Grouped", + "reports.per_page": "Per page", + "reports.search": "Search", + "reports.search_placeholder": "Keywords (exceptions, devices, stack traces…)", + "reports.clear": "Clear", + "reports.loading": "Loading…", + "reports.empty": "No reports yet.", + "reports.status_showing": "Showing {from}–{to} of {total}", + "reports.invalid_response": "Invalid response", + "reports.load_failed": "Load failed", + "reports.network_error": "Network error", + "reports.mode_disabled_filter": "Disabled while a filter is active", + "filter.banner_similar": "Similar reports for #{id}", + "filter.banner_device": "Device: “{value}”", + "filter.banner_abi": "ABI: {value}", + "filter.banner_sdk": "SDK {value}", + "filter.banner_os": "Android {value}", + "filter.banner_app": "App {value}", + "filter.banner_build": "Build {value}", + "filter.banner_search": "Search: “{value}”", + "filter.clear": "Clear filters", + "pagination.previous": "← Previous", + "pagination.next": "Next →", + "pagination.previous_range": "← Previous ({before}/{through})", + "pagination.next_range": "Next ({start}/{end}) →", + "col.device": "Device", + "col.app": "App", + "col.os": "OS", + "col.os_ver": "OS ver", + "col.received": "Received", + "col.rating": "Rating", + "col.count": "Count", + "col.fingerprint": "Fingerprint", + "col.type": "Type", + "col.last_generated": "Last generated", + "col.last_received": "Last received", + "col.tags": "Tags", + "col.expand": "Expand", + "col.drag": "Drag column", + "col.drag_reorder": "Drag column to reorder", + "graphs.brick_drag": "Drag chart to reorder", + "graphs.session_deficit": "Session charts are empty. On the device: Developer settings → enable “Grab session stats”, cast a session, then wait for upload to graph_upload.php.", + "row.show_summary": "Show summary", + "row.open_report": "Click to open full report", + "row.edit_tags": "Edit tags", + "tag.new": "new", + "tag.java": "Java", + "tag.ndk": "NDK", + "tag.android": "Android", + "tag.edit_title": "Edit tags", + "tag.close": "Close", + "tag.label": "Label", + "tag.color": "Color", + "tag.add": "Add", + "tag.add_tag": "Add tag", + "tag.save": "Save", + "tag.save_tags": "Save tags", + "tag.suggestions": "Suggestions:", + "tag.placeholder": "e.g. regression", + "tag.hint_auto": "Auto tags (NDK / Java / Android / new) appear in the list. Add custom labels below.", + "tag.hint_admin": "Sign in as admin to edit custom tags.", + "tag.none_custom": "No custom tags yet.", + "tag.none_preview": "No custom tags", + "tag.remove": "Remove tag", + "tag.saving": "Saving…", + "tag.saved": "Saved", + "tag.save_failed": "Save failed", + "tag.workflow": "Workflow", + "tag.filter_label": "Filter by tag", + "tag.filter_mode": "Match", + "tag.filter_and": "All tags (AND)", + "tag.filter_or": "Any tag (OR)", + "detail.back": "Back to list", + "detail.title": "Issue report", + "detail.report_meta": "Report {id} · {type}", + "detail.tags": "Tags", + "detail.device": "Device", + "detail.app": "App", + "detail.timing": "Timing", + "detail.abis": "ABIs:", + "detail.build": "Build:", + "detail.generated": "Generated:", + "detail.received": "Received:", + "detail.fingerprint": "Fingerprint:", + "detail.filter_device": "Show reports for similar devices", + "detail.filter_os": "Show reports on this OS", + "detail.filter_version": "Show reports for this app version", + "detail.filter_build": "Show reports from this build", + "detail.java_exception": "Java exception", + "detail.native_crash": "Native issue", + "detail.find_similar": "Find similar issues", + "detail.thread": "Thread:", + "detail.signal": "Signal:", + "detail.session_context": "Session context (issue)", + "detail.raw_json": "Raw JSON", + "login.sign_in_hint": "Sign in to view anonymous issue reports", + "login.username": "Username", + "login.password": "Password", + "login.submit": "Sign in", + "login.hint_default": "Default: admin / admin", + "login.register": "Register", + "login.register_soon": "(coming soon)", + "login.error": "Invalid credentials", + "register.title": "Create account", + "register.hint": "We will email a verification link before you can sign in.", + "register.email": "Email", + "register.username": "Username (optional)", + "register.password": "Password", + "register.submit": "Register", + "register.back_login": "Back to sign in", + "verify.title": "Email verification", + "verify.ok": "Your email is verified. You can sign in and enroll two-factor authentication.", + "verify.fail": "Invalid or expired link.", + "verify.sign_in": "Sign in", + "verify.register_again": "Register again", + "twofa.title": "Two-factor authentication", + "twofa.hint": "Enter the 6-digit code from your authenticator app.", + "twofa.code": "Authentication code", + "twofa.submit": "Continue", + "twofa.cancel": "Cancel", + "twofa.error": "Invalid code", + "security.title": "Account security", + "security.totp_intro": "Protect your account with a 6-digit authenticator app (recommended for alpha).", + "security.start_totp": "Set up authenticator", + "security.scan_qr": "Scan this QR code with your authenticator app, then enter a code to confirm.", + "security.manual_secret": "Can't scan?", + "security.confirm_totp": "Confirm enrollment", + "security.totp_enabled": "Authenticator app is enrolled.", + "security.remove_totp": "Remove authenticator", + "security.back_console": "Back to console", + "nav.security": "Security", + "footer.copyright": "© Anton Afanaasyeu, {year}", + "tickets.title": "Tickets", + "issues.new": "New issue", + "tickets.new": "New ticket", + "ticket.create": "Create", + "ticket.cancel": "Cancel", + "ticket.title": "Title", + "ticket.brief": "Brief", + "ticket.body": "Description", + "tickets.empty": "No tickets yet.", + "tickets.col_issue": "Issue", + "tickets.col_opened": "Opened", + "tickets.col_env": "App / OS", + "tickets.filter_tag": "Status tag", + "tickets.filter_all": "All", + "ticket.back": "Back to tickets", + "ticket.detail_title": "Ticket", + "ticket.id_label": "ID", + "ticket.tag_hint": "Must include ticket and a status tag (open, in progress, closed, …).", + "ticket.tag_readonly": "Only the owner or an admin can edit tags.", + "ticket.issue": "Issue", + "ticket.caption": "Caption", + "ticket.summary": "Summary", + "ticket.description": "Description", + "ticket.workflow": "Workflow", + "ticket.owner": "Owner", + "ticket.verified_by": "Verified by", + "ticket.timing": "Timing", + "ticket.opened": "Opened", + "ticket.created": "Created", + "ticket.updated": "Updated", + "ticket.save": "Save ticket", + "ticket.source": "Source / context", + "ticket.lifecycle": "Lifecycle", + "ticket.assignees": "Assignees", + "ticket.assignee_add": "Add assignee", + "ticket.comments": "Comments", + "ticket.comment_add": "Add comment", + "ticket.comment_post": "Post comment", + "ticket.attachments": "Attachments", + "ticket.attach_link": "Add link", + "ticket.attach_link_hint": "Google Docs, Sheets, Drive, Meet, or any HTTPS URL.", + "ticket.attach_link_btn": "Attach link", + "ticket.attach_file": "Upload file", + "ticket.attach_file_hint": "Max 16 MiB — images, logs, pdf, zip, …", + "ticket.attach_file_btn": "Upload", + "ticket.link_title": "Title (optional)", + "ticket.file_pick": "File" +} diff --git a/assets/i18n/ru.json b/assets/i18n/ru.json new file mode 100644 index 0000000..b72d81c --- /dev/null +++ b/assets/i18n/ru.json @@ -0,0 +1,196 @@ +{ + "app_name": "Сбои Android Cast", + "nav.home": "Главная", + "nav.reports": "Задачи (Issues)", + "nav.tickets": "Задачи", + "tickets.title": "Задачи", + "issues.new": "Новая проблема", + "tickets.new": "Новый тикет", + "ticket.create": "Создать", + "ticket.cancel": "Отмена", + "ticket.title": "Заголовок", + "ticket.brief": "Кратко", + "ticket.body": "Описание", + "tickets.empty": "Задач пока нет.", + "ticket.detail_title": "Задача", + "nav.logout": "Выход", + "nav.toggle": "Навигация", + "nav.home_desc": "Главная консоли и карточки разделов.", + "nav.reports_desc": "Просмотр и разбор issues.", + "nav.tickets_desc": "Задачи roadmap, QA и теги.", + "nav.graphs": "Графики", + "nav.graphs_desc": "Сессии, issues и активность устройств.", + "nav.remote": "Удалённый доступ", + "nav.remote_desc": "Сессии WireGuard и доступ к устройствам.", + "nav.access": "Доступ", + "nav.access_desc": "Роли, наборы привилегий и операторы.", + "nav.builder": "Сборщик", + "nav.builder_desc": "Docker CI для сборки APK и OTA.", + "theme.label": "Тема", + "theme.dark": "Тёмная", + "theme.light": "Светлая", + "lang.label": "Язык", + "lang.en": "English", + "lang.ru": "Русский", + "home.title": "Консоль", + "home.intro": "Сбои, задачи и метрики сессий — выберите раздел ниже.", + "home.card_issues": "Проблемы", + "home.card_issues_desc": "Просмотр и разбор issues, тегов и отпечатков.", + "home.card_new_issue_desc": "Создать новую проблему или задачу.", + "home.card_new_ticket_desc": "Создать тикет roadmap или QA за один шаг.", + "home.card_tickets_desc": "Задачи, QA и теги workflow.", + "home.card_graphs": "Графики", + "home.card_graphs_desc": "Сессии, issues и активность устройств.", + "home.card_short_links": "Короткие ссылки", + "home.card_short_links_desc": "Токены и сокращение URL для s.f0xx.org.", + "home.more": "Все сервисы", + "reports.title": "Issues", + "reports.by_time": "По времени", + "reports.grouped": "Группы", + "reports.per_page": "На странице", + "reports.search": "Поиск", + "reports.search_placeholder": "Ключевые слова (исключения, устройства, стеки…)", + "reports.clear": "Очистить", + "reports.loading": "Загрузка…", + "reports.empty": "Отчётов пока нет.", + "reports.status_showing": "Показано {from}–{to} из {total}", + "reports.invalid_response": "Некорректный ответ", + "reports.load_failed": "Ошибка загрузки", + "reports.network_error": "Сетевая ошибка", + "reports.mode_disabled_filter": "Недоступно при активном фильтре", + "filter.banner_similar": "Похожие отчёты для №{id}", + "filter.banner_device": "Устройство: «{value}»", + "filter.banner_abi": "ABI: {value}", + "filter.banner_sdk": "SDK {value}", + "filter.banner_os": "Android {value}", + "filter.banner_app": "Приложение {value}", + "filter.banner_build": "Сборка {value}", + "filter.banner_search": "Поиск: «{value}»", + "filter.clear": "Сбросить фильтры", + "pagination.previous": "← Назад", + "pagination.next": "Вперёд →", + "pagination.previous_range": "← Назад ({before}/{through})", + "pagination.next_range": "Вперёд ({start}/{end}) →", + "col.device": "Устройство", + "col.app": "Приложение", + "col.os": "ОС", + "col.os_ver": "Версия ОС", + "col.received": "Получен", + "col.rating": "Рейтинг", + "col.count": "Кол-во", + "col.fingerprint": "Отпечаток", + "col.type": "Тип", + "col.last_generated": "Создан", + "col.last_received": "Получен", + "col.tags": "Метки", + "col.expand": "Развернуть", + "col.drag": "Перетащить столбец", + "col.drag_reorder": "Перетащите для смены порядка столбцов", + "graphs.brick_drag": "Перетащите график для смены порядка", + "graphs.session_deficit": "Графики сессий пусты. На устройстве: Настройки разработчика → «Grab session stats», проведите сессию трансляции и дождитесь загрузки в graph_upload.php.", + "row.show_summary": "Показать сводку", + "row.open_report": "Нажмите, чтобы открыть полный отчёт", + "row.edit_tags": "Редактировать метки", + "tag.new": "новый", + "tag.java": "Java", + "tag.ndk": "NDK", + "tag.android": "Android", + "tag.edit_title": "Редактирование меток", + "tag.close": "Закрыть", + "tag.label": "Название", + "tag.color": "Цвет", + "tag.add": "Добавить", + "tag.add_tag": "Добавить метку", + "tag.save": "Сохранить", + "tag.save_tags": "Сохранить метки", + "tag.suggestions": "Подсказки:", + "tag.hint_auto": "Автометки (NDK / Java / Android / новый) в списке. Ниже можно добавить свои.", + "tag.hint_admin": "Войдите как администратор, чтобы редактировать метки.", + "tag.none_custom": "Пользовательских меток пока нет.", + "tag.none_preview": "Нет пользовательских меток", + "tag.remove": "Удалить метку", + "tag.saving": "Сохранение…", + "tag.saved": "Сохранено", + "tag.save_failed": "Не удалось сохранить", + "tag.workflow": "Статус", + "tag.filter_label": "Фильтр по метке", + "tag.filter_mode": "Совпадение", + "tag.filter_and": "Все метки (И)", + "tag.filter_or": "Любая метка (ИЛИ)", + "detail.back": "К списку", + "detail.title": "Issue-отчёт", + "detail.report_meta": "Отчёт {id} · {type}", + "detail.tags": "Метки", + "detail.device": "Устройство", + "detail.app": "Приложение", + "detail.timing": "Время", + "detail.abis": "ABI:", + "detail.build": "Сборка:", + "detail.generated": "Создан:", + "detail.received": "Получен:", + "detail.fingerprint": "Отпечаток:", + "detail.filter_device": "Показать отчёты для похожих устройств", + "detail.filter_os": "Показать отчёты с этой ОС", + "detail.filter_version": "Показать отчёты для этой версии приложения", + "detail.filter_build": "Показать отчёты этой сборки", + "detail.java_exception": "Исключение Java", + "detail.native_crash": "Нативный issue", + "detail.find_similar": "Найти похожие отчёты", + "detail.thread": "Поток:", + "detail.signal": "Сигнал:", + "detail.session_context": "Контекст сессии (issue)", + "detail.raw_json": "Исходный JSON", + "login.sign_in_hint": "Войдите для просмотра анонимных issue-отчётов", + "login.username": "Имя пользователя", + "login.password": "Пароль", + "login.submit": "Войти", + "login.hint_default": "По умолчанию: admin / admin", + "login.register": "Регистрация", + "login.register_soon": "(скоро)", + "login.error": "Неверные учётные данные", + "register.title": "Создать аккаунт", + "register.hint": "Мы отправим ссылку для подтверждения email перед входом.", + "register.email": "Email", + "register.username": "Имя пользователя (необязательно)", + "register.password": "Пароль", + "register.submit": "Зарегистрироваться", + "register.back_login": "Назад ко входу", + "verify.title": "Подтверждение email", + "verify.ok": "Email подтверждён. Войдите и настройте двухфакторную аутентификацию.", + "verify.fail": "Недействительная или просроченная ссылка.", + "verify.sign_in": "Войти", + "verify.register_again": "Зарегистрироваться снова", + "twofa.title": "Двухфакторная аутентификация", + "twofa.hint": "Введите 6-значный код из приложения-аутентификатора.", + "twofa.code": "Код аутентификации", + "twofa.submit": "Продолжить", + "twofa.cancel": "Отмена", + "twofa.error": "Неверный код", + "security.title": "Безопасность аккаунта", + "security.totp_intro": "Защитите аккаунт приложением-аутентификатором (рекомендуется для альфы).", + "security.start_totp": "Настроить аутентификатор", + "security.scan_qr": "Отсканируйте QR-код и введите код для подтверждения.", + "security.manual_secret": "Не удаётся сканировать?", + "security.confirm_totp": "Подтвердить настройку", + "security.totp_enabled": "Приложение-аутентификатор подключено.", + "security.remove_totp": "Отключить аутентификатор", + "security.back_console": "Назад в консоль", + "nav.security": "Безопасность", + "footer.copyright": "© Anton Afanaasyeu, {year}", + "nav.tickets": "Тикеты", + "ticket.lifecycle": "Жизненный цикл", + "ticket.assignees": "Исполнители", + "ticket.assignee_add": "Добавить", + "ticket.comments": "Комментарии", + "ticket.comment_add": "Новый комментарий", + "ticket.comment_post": "Отправить", + "ticket.attachments": "Вложения", + "ticket.attach_link": "Ссылка", + "ticket.attach_link_hint": "Google Docs, Таблицы, Drive, Meet или любой HTTPS URL.", + "ticket.attach_link_btn": "Прикрепить", + "ticket.attach_file": "Файл", + "ticket.attach_file_hint": "До 16 МБ — изображения, логи, pdf, zip…", + "ticket.attach_file_btn": "Загрузить", + "ticket.link_title": "Название (необяз.)", + "ticket.file_pick": "Файл" +} diff --git a/assets/js/analytics.js b/assets/js/analytics.js new file mode 100644 index 0000000..be542e6 --- /dev/null +++ b/assets/js/analytics.js @@ -0,0 +1,157 @@ +/** + * Google Analytics 4 for Android Cast web platform (hub + crash console). + * Set window.ANDROIDCAST_ANALYTICS before this script loads: + * { measurementId: 'G-XXXXXXXX', platform: 'hub'|'crashes', debug: false } + */ +(function (global) { + 'use strict'; + + var cfg = global.ANDROIDCAST_ANALYTICS || {}; + var measurementId = (cfg.measurementId || '').trim(); + var platform = cfg.platform || 'web'; + var debug = !!cfg.debug; + + function log() { + if (debug && global.console) { + global.console.log.apply(global.console, ['[analytics]'].concat([].slice.call(arguments))); + } + } + + function enabled() { + if (global.AndroidCastCookieConsent && !global.AndroidCastCookieConsent.allowsAnalytics()) { + var choice = global.AndroidCastCookieConsent.getChoice && global.AndroidCastCookieConsent.getChoice(); + if (choice && choice !== 'all') { + return false; + } + } + return measurementId.length > 0 && /^G-[A-Z0-9]+$/i.test(measurementId); + } + + function ensureGtag(cb) { + if (!enabled()) { + return; + } + global.dataLayer = global.dataLayer || []; + if (!global.gtag) { + global.gtag = function () { + global.dataLayer.push(arguments); + }; + } + if (global.__acAnalyticsLoaded) { + if (cb) cb(); + return; + } + var s = document.createElement('script'); + s.async = true; + s.src = 'https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent(measurementId); + s.onload = function () { + global.__acAnalyticsLoaded = true; + global.gtag('js', new Date()); + global.gtag('config', measurementId, { + send_page_view: false, + anonymize_ip: true + }); + log('loaded', measurementId); + if (cb) cb(); + }; + document.head.appendChild(s); + } + + function pagePath() { + var base = (cfg.basePath || '').replace(/\/$/, ''); + var path = global.location.pathname || '/'; + if (base && path.indexOf(base) === 0) { + path = path.slice(base.length) || '/'; + } + return path; + } + + function pageView(extra) { + if (!enabled()) return; + ensureGtag(function () { + var params = { + page_title: document.title || '', + page_location: global.location.href, + page_path: pagePath(), + platform: platform + }; + if (extra) { + for (var k in extra) { + if (Object.prototype.hasOwnProperty.call(extra, k)) params[k] = extra[k]; + } + } + global.gtag('event', 'page_view', params); + log('page_view', params); + }); + } + + function trackEvent(name, params) { + if (!enabled() || !name) return; + ensureGtag(function () { + var payload = params || {}; + payload.platform = platform; + global.gtag('event', name, payload); + log('event', name, payload); + }); + } + + function bindConsoleView() { + var body = document.body; + if (!body) return; + var view = body.getAttribute('data-view'); + if (view) { + trackEvent('console_view', { view_name: view }); + } + } + + function bindHubInteractions() { + document.addEventListener('click', function (ev) { + var t = ev.target; + if (!(t instanceof Element)) return; + var card = t.closest('.hub-card, .hub-card-link'); + if (card) { + var label = card.getAttribute('data-analytics-label') + || (card.textContent || '').trim().slice(0, 64); + trackEvent('hub_nav_click', { link_text: label }); + return; + } + if (t.closest('[data-open-docs], #hub-compass-layer, .hub-logo-layer--compass')) { + trackEvent('hub_open_docs', { source: 'compass' }); + } + if (t.closest('[data-close-docs]')) { + trackEvent('hub_close_docs', {}); + } + }); + } + + function init() { + if (!enabled()) { + log('disabled (no measurement id or cookie consent)'); + return; + } + pageView(); + if (platform === 'crashes' || String(platform).indexOf('live_') === 0) { + bindConsoleView(); + } else if (platform === 'hub') { + bindHubInteractions(); + } + } + + global.addEventListener('ac-cookie-consent', function () { + if (enabled()) { + init(); + } + }); + + global.AndroidCastAnalytics = { + enabled: enabled, + pageView: pageView, + trackEvent: trackEvent + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(typeof window !== 'undefined' ? window : this); diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..9ce9c3e --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,2067 @@ +(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, '"'); + } + + 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 '' + escapeHtml(s.slice(0, 12)) + (s.length > 12 ? '…' : '') + ''; + } + return escapeHtml(v ?? '') || ''; + } + + function formatStars(n) { + const score = Math.max(0, Math.min(5, Number(n) || 0)); + let html = + ''; + for (let i = 1; i <= 5; i++) { + html += ''; + } + return html + ''; + } + + 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 ''; + 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 ( + '' + ); + } + return '' + label + ''; + }) + .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 = + ''; + order.forEach((k) => { + html += + ''; + }); + html += + ''; + 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(' · ') + + ' — Clear filters'; + } + + 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 = + ''; + cols.forEach((c) => { + const active = sortActive(c.key); + const arrow = + active ? (dir === 'asc' ? '▲' : '▼') : ''; + 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 += + '' + + (grouped + ? '' + : '') + + '' + + escapeHtml(colLabel(c)) + + ' ' + + (grouped ? '' : '') + + ''; + }); + html += + 'Tags'; + 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 = '
'; + html += + ''; + html += + 'Page ' + + page + + ' / ' + + pages + + ' · ' + + total + + ' reports'; + html += + ''; + html += '
'; + 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 = + '' + + escapeHtml(t('reports.empty')) + + ''; + 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 += ''; + html += + ''; + cols.forEach((c) => { + if (c.key === 'cnt') { + html += '' + escapeHtml(row.cnt) + ''; + } else { + html += '' + formatCell(c, row) + ''; + } + }); + const tagEditBtn = + !grouped && row.id && canTagEdit() + ? '' + : ''; + html += + '
' + + buildTags(row, grouped) + + tagEditBtn + + '
'; + html += ''; + + const briefLines = row.brief || []; + let panelOpen = '
'; + if (openUrl) { + panelOpen = + '
'; + } + html += + '' + + panelOpen + + '
'; + briefLines.forEach((line) => { + html += '

' + escapeHtml(line) + '

'; + }); + if (openUrl) { + html += '

Click to open full report

'; + } + html += '
'; + }); + 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) => + '
  • ' + ) + .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 = + '' + + escapeHtml(tag.label || tag.id); + if (removable) { + html += + ''; + } + return html + ''; + } + + 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 = '
  • No custom tags yet.
  • '; + return; + } + els.list.innerHTML = state.tags + .map( + (t, i) => + '
  • ' + + renderTagPill(t, true) + + '
  • ' + ) + .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('') + : 'No custom tags'; + } + + function presetChipHtml(p) { + return ( + '' + ); + } + + 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 += + '
    ' + + escapeHtml(t('tag.workflow')) + + '
    ' + + wf.map(presetChipHtml).join('') + + '
    '; + } + if (hist.length) { + html += + '
    ' + + escapeHtml(t('tag.suggestions')) + + '
    ' + + hist.map(presetChipHtml).join('') + + '
    '; + } + 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('') + : 'No custom tags'; + } catch { + preview.innerHTML = ''; + } + } + + 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 = ''; + return; + } + assigneeEditor.innerHTML = assignees + .map(function (a, idx) { + const role = a.role ? ' (' + escapeHtml(a.role) + ')' : ''; + return ( + '' + + escapeHtml(a.username) + + role + + ' ' + ); + }) + .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 = + '' + + escapeHtml(att.title || att.external_url) + + ''; + } else if (att.download_url) { + const size = + att.size_bytes > 0 + ? ' (' + (att.size_bytes / 1024).toFixed(1) + ' KB)' + : ''; + main = + '' + + escapeHtml(att.title || att.original_name || 'file') + + '' + + size; + } else { + main = '' + escapeHtml(att.title || '') + ''; + } + li.innerHTML = + '' + + label + + ' ' + + main + + ' ' + + escapeHtml(att.created_by || '') + + '' + + ' '; + 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 = + '
    ' + + escapeHtml(c.author_username) + + ' ' + + escapeHtml(when) + + '
    ';
    +            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 += '';
    +        }
    +        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 (
    +            '' +
    +            escapeHtml(tag.label) +
    +            ''
    +          );
    +        })
    +        .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);
    +})();
    diff --git a/assets/js/cookie_consent.js b/assets/js/cookie_consent.js
    new file mode 100644
    index 0000000..dd73075
    --- /dev/null
    +++ b/assets/js/cookie_consent.js
    @@ -0,0 +1,79 @@
    +/**
    + * Cookie consent banner — stores choice in localStorage (ac_cookie_consent).
    + * Values: all | necessary | reject
    + */
    +(function (global) {
    +  'use strict';
    +
    +  var STORAGE_KEY = 'ac_cookie_consent';
    +  var banner = null;
    +
    +  function getChoice() {
    +    try {
    +      var v = localStorage.getItem(STORAGE_KEY);
    +      if (v === 'all' || v === 'necessary' || v === 'reject') {
    +        return v;
    +      }
    +    } catch (e) { /* ignore */ }
    +    return null;
    +  }
    +
    +  function setChoice(value) {
    +    try {
    +      localStorage.setItem(STORAGE_KEY, value);
    +    } catch (e) { /* ignore */ }
    +    global.dispatchEvent(new CustomEvent('ac-cookie-consent', { detail: { choice: value } }));
    +    hideBanner();
    +  }
    +
    +  function allowsAnalytics() {
    +    return getChoice() === 'all';
    +  }
    +
    +  function allowsPreferenceCookies() {
    +    var c = getChoice();
    +    return c === 'all' || c === 'necessary';
    +  }
    +
    +  function hideBanner() {
    +    if (banner) {
    +      banner.hidden = true;
    +    }
    +  }
    +
    +  function ensureBanner() {
    +    if (banner || getChoice()) {
    +      return;
    +    }
    +    banner = document.getElementById('cookie-consent-banner');
    +    if (!banner) {
    +      return;
    +    }
    +    banner.hidden = false;
    +    var allBtn = document.getElementById('cookie-consent-all');
    +    var necBtn = document.getElementById('cookie-consent-necessary');
    +    var rejBtn = document.getElementById('cookie-consent-reject');
    +    if (allBtn) {
    +      allBtn.addEventListener('click', function () { setChoice('all'); });
    +    }
    +    if (necBtn) {
    +      necBtn.addEventListener('click', function () { setChoice('necessary'); });
    +    }
    +    if (rejBtn) {
    +      rejBtn.addEventListener('click', function () { setChoice('reject'); });
    +    }
    +  }
    +
    +  global.AndroidCastCookieConsent = {
    +    getChoice: getChoice,
    +    allowsAnalytics: allowsAnalytics,
    +    allowsPreferenceCookies: allowsPreferenceCookies,
    +    setChoice: setChoice
    +  };
    +
    +  if (document.readyState === 'loading') {
    +    document.addEventListener('DOMContentLoaded', ensureBanner);
    +  } else {
    +    ensureBanner();
    +  }
    +})(typeof window !== 'undefined' ? window : this);
    diff --git a/assets/js/i18n.js b/assets/js/i18n.js
    new file mode 100644
    index 0000000..c858bb0
    --- /dev/null
    +++ b/assets/js/i18n.js
    @@ -0,0 +1,207 @@
    +/**
    + * Crash console i18n — JSON catalogs (en default), same key style as Android strings.xml.
    + */
    +(function (global) {
    +  const STORAGE_KEY = 'crash_console_lang';
    +  const SUPPORTED = ['en', 'ru'];
    +  const DEFAULT_LANG = 'en';
    +
    +  /** @type {Record} */
    +  const LANG_DISPLAY = {
    +    en: { flag: '\uD83C\uDDEC\uD83C\uDDE7', code: 'EN' },
    +    ru: { flag: '\uD83C\uDDF7\uD83C\uDDFA', code: 'RU' },
    +  };
    +
    +  let lang = DEFAULT_LANG;
    +  let catalog = {};
    +  let fallback = {};
    +  let readyResolve;
    +  const ready = new Promise((resolve) => {
    +    readyResolve = resolve;
    +  });
    +
    +  function basePath() {
    +    const body = document.body;
    +    return body ? body.getAttribute('data-base-path') || '' : '';
    +  }
    +
    +  /** Close stray modal layers so fixed backdrops never block login/console UI. */
    +  function dismissPlatformOverlays() {
    +    document.querySelectorAll('#graph-detail-overlay, #tag-modal, #hub-docs-modal').forEach((el) => {
    +      el.hidden = true;
    +      el.setAttribute('aria-hidden', 'true');
    +    });
    +    const dialog = document.getElementById('ticket-create-dialog');
    +    if (dialog && dialog.open && typeof dialog.close === 'function') {
    +      dialog.close();
    +    }
    +  }
    +
    +  function storedLang() {
    +    try {
    +      const v = localStorage.getItem(STORAGE_KEY);
    +      return SUPPORTED.includes(v) ? v : DEFAULT_LANG;
    +    } catch {
    +      return DEFAULT_LANG;
    +    }
    +  }
    +
    +  function persistLang(code) {
    +    try {
    +      localStorage.setItem(STORAGE_KEY, code);
    +    } catch { /* ignore */ }
    +  }
    +
    +  function fetchCatalog(code) {
    +    const url = basePath() + '/assets/i18n/' + encodeURIComponent(code) + '.json';
    +    return fetch(url, { credentials: 'same-origin' }).then((res) => {
    +      if (!res.ok) throw new Error('i18n load failed: ' + code);
    +      return res.json();
    +    });
    +  }
    +
    +  /**
    +   * @param {string} key
    +   * @param {Record|undefined} params
    +   */
    +  function t(key, params) {
    +    let s = catalog[key] ?? fallback[key] ?? key;
    +    if (params) {
    +      Object.keys(params).forEach((k) => {
    +        s = s.split('{' + k + '}').join(String(params[k]));
    +      });
    +    }
    +    return s;
    +  }
    +
    +  function apply(root) {
    +    const scope = root || document;
    +    scope.querySelectorAll('[data-i18n]').forEach((el) => {
    +      const key = el.getAttribute('data-i18n');
    +      if (!key) return;
    +      if (el.hasAttribute('data-i18n-html')) {
    +        el.innerHTML = t(key);
    +      } else {
    +        el.textContent = t(key);
    +      }
    +    });
    +    scope.querySelectorAll('[data-i18n-placeholder]').forEach((el) => {
    +      const key = el.getAttribute('data-i18n-placeholder');
    +      if (key) el.setAttribute('placeholder', t(key));
    +    });
    +    scope.querySelectorAll('[data-i18n-title]').forEach((el) => {
    +      const key = el.getAttribute('data-i18n-title');
    +      if (key) el.setAttribute('title', t(key));
    +    });
    +    scope.querySelectorAll('[data-i18n-aria]').forEach((el) => {
    +      const key = el.getAttribute('data-i18n-aria');
    +      if (key) el.setAttribute('aria-label', t(key));
    +    });
    +    scope.querySelectorAll('select[data-i18n-options]').forEach((sel) => {
    +      const prefix = sel.getAttribute('data-i18n-options');
    +      if (!prefix) return;
    +      sel.querySelectorAll('option[data-i18n]').forEach((opt) => {
    +        const k = opt.getAttribute('data-i18n');
    +        if (k) opt.textContent = t(k);
    +      });
    +    });
    +    scope.querySelectorAll('.platform-footer__left[data-i18n], footer[data-i18n="footer.copyright"]').forEach((footer) => {
    +      const year = footer.getAttribute('data-year') || String(new Date().getFullYear());
    +      footer.textContent = t(footer.getAttribute('data-i18n') || 'footer.copyright', { year });
    +    });
    +    scope.querySelectorAll('[data-i18n="detail.report_meta"]').forEach((el) => {
    +      el.textContent = t('detail.report_meta', {
    +        id: el.getAttribute('data-i18n-id') || '',
    +        type: el.getAttribute('data-i18n-type') || '',
    +      });
    +    });
    +  }
    +
    +  function langOptionLabel(code) {
    +    const meta = LANG_DISPLAY[code] || LANG_DISPLAY[DEFAULT_LANG];
    +    return meta.flag + ' ' + meta.code;
    +  }
    +
    +  function syncLangSelect() {
    +    document.querySelectorAll('.lang-select').forEach((sel) => {
    +      SUPPORTED.forEach((code) => {
    +        const opt = sel.querySelector('option[value="' + code + '"]');
    +        if (opt) opt.textContent = langOptionLabel(code);
    +      });
    +      if (sel.value !== lang) sel.value = lang;
    +    });
    +    syncLocaleDisplay(lang);
    +  }
    +
    +  function syncLocaleDisplay(code) {
    +    const meta = LANG_DISPLAY[code] || LANG_DISPLAY[DEFAULT_LANG];
    +    document.querySelectorAll('.locale-flag').forEach((el) => {
    +      el.textContent = meta.flag;
    +    });
    +    document.querySelectorAll('.locale-code').forEach((el) => {
    +      el.textContent = meta.code;
    +    });
    +  }
    +
    +  async function setLang(code, options) {
    +    const opts = options || {};
    +    if (!SUPPORTED.includes(code)) code = DEFAULT_LANG;
    +    fallback = await fetchCatalog(DEFAULT_LANG);
    +    catalog = code === DEFAULT_LANG ? { ...fallback } : await fetchCatalog(code);
    +    lang = code;
    +    document.documentElement.setAttribute('lang', code);
    +    persistLang(code);
    +    syncLangSelect();
    +    if (!opts.silent) {
    +      apply(document);
    +      document.dispatchEvent(new CustomEvent('crash-i18n-change', { detail: { lang: code } }));
    +    }
    +  }
    +
    +  function getLang() {
    +    return lang;
    +  }
    +
    +  function initLangSelect() {
    +    document.querySelectorAll('.lang-select').forEach((sel) => {
    +      sel.value = lang;
    +      if (sel.dataset.i18nBound === '1') return;
    +      sel.dataset.i18nBound = '1';
    +      sel.addEventListener('change', () => {
    +        setLang(sel.value).catch(() => setLang(DEFAULT_LANG));
    +      });
    +    });
    +  }
    +
    +  async function init() {
    +    dismissPlatformOverlays();
    +    lang = storedLang();
    +    document.documentElement.setAttribute('lang', lang);
    +    await setLang(lang, { silent: true });
    +    apply(document);
    +    initLangSelect();
    +    syncLocaleDisplay(lang);
    +    readyResolve();
    +  }
    +
    +  global.CrashI18n = {
    +    t,
    +    apply,
    +    setLang,
    +    getLang,
    +    init,
    +    ready,
    +    SUPPORTED,
    +  };
    +
    +  if (document.readyState === 'loading') {
    +    document.addEventListener('DOMContentLoaded', () => {
    +      init().catch(() => {
    +        lang = DEFAULT_LANG;
    +        readyResolve();
    +      });
    +    });
    +  } else {
    +    init().catch(() => readyResolve());
    +  }
    +})(typeof window !== 'undefined' ? window : globalThis);
    diff --git a/assets/js/nav_shell.js b/assets/js/nav_shell.js
    new file mode 100644
    index 0000000..895d39f
    --- /dev/null
    +++ b/assets/js/nav_shell.js
    @@ -0,0 +1,83 @@
    +/**
    + * Collapsible left nav: click toggle + horizontal drag on handle (open on drag right).
    + */
    +(function () {
    +  'use strict';
    +
    +  var DRAG_THRESHOLD_PX = 20;
    +
    +  function setOpen(nav, handle, open) {
    +    nav.classList.toggle('open', open);
    +    if (handle) {
    +      handle.setAttribute('aria-expanded', open ? 'true' : 'false');
    +    }
    +  }
    +
    +  function bindNavShell(paneId, handleId) {
    +    var nav = document.getElementById(paneId);
    +    var handle = document.getElementById(handleId);
    +    if (!nav || !handle) return;
    +
    +    setOpen(nav, handle, nav.classList.contains('open'));
    +
    +    var drag = null;
    +    var suppressClick = false;
    +
    +    handle.addEventListener('click', function (ev) {
    +      if (suppressClick) {
    +        suppressClick = false;
    +        ev.preventDefault();
    +        return;
    +      }
    +      setOpen(nav, handle, !nav.classList.contains('open'));
    +    });
    +
    +    handle.addEventListener('pointerdown', function (ev) {
    +      if (ev.button !== 0) return;
    +      drag = { x: ev.clientX, moved: false };
    +      handle.setPointerCapture(ev.pointerId);
    +      handle.classList.add('is-dragging');
    +      ev.preventDefault();
    +    });
    +
    +    handle.addEventListener('pointermove', function (ev) {
    +      if (!drag || !handle.hasPointerCapture(ev.pointerId)) return;
    +      var dx = ev.clientX - drag.x;
    +      if (Math.abs(dx) < 4) return;
    +      drag.moved = true;
    +      if (dx > DRAG_THRESHOLD_PX) {
    +        setOpen(nav, handle, true);
    +        drag.x = ev.clientX;
    +      } else if (dx < -DRAG_THRESHOLD_PX) {
    +        setOpen(nav, handle, false);
    +        drag.x = ev.clientX;
    +      }
    +    });
    +
    +    function endDrag(ev) {
    +      if (!drag) return;
    +      if (drag.moved) suppressClick = true;
    +      drag = null;
    +      handle.classList.remove('is-dragging');
    +      if (handle.hasPointerCapture(ev.pointerId)) {
    +        handle.releasePointerCapture(ev.pointerId);
    +      }
    +    }
    +
    +    handle.addEventListener('pointerup', endDrag);
    +    handle.addEventListener('pointercancel', endDrag);
    +  }
    +
    +  window.initNavShell = bindNavShell;
    +
    +  function autoInit() {
    +    bindNavShell('nav-pane', 'nav-handle');
    +    bindNavShell('landing-nav-pane', 'landing-nav-handle');
    +  }
    +
    +  if (document.readyState === 'loading') {
    +    document.addEventListener('DOMContentLoaded', autoInit);
    +  } else {
    +    autoInit();
    +  }
    +})();
    diff --git a/platform/footer.php b/platform/footer.php
    new file mode 100644
    index 0000000..d7dee69
    --- /dev/null
    +++ b/platform/footer.php
    @@ -0,0 +1,241 @@
    + or platform_render_footer(['use_i18n' => false]);
    + *
    + * Layout (each row omitted when empty):
    + *   [ top — full width ]
    + *   [ left | right ]  — main row (copyright defaults on left when left unset)
    + *   [ bottom — full width ]
    + */
    +
    +function platform_footer_h(mixed $value): string
    +{
    +    return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
    +}
    +
    +/** @return array */
    +function platform_footer_config(): array
    +{
    +    static $loaded = null;
    +    if ($loaded !== null) {
    +        return $loaded;
    +    }
    +    $path = __DIR__ . '/footer.config.php';
    +    if (!is_file($path)) {
    +        $path = __DIR__ . '/footer.config.example.php';
    +    }
    +    $loaded = require $path;
    +    return is_array($loaded) ? $loaded : [];
    +}
    +
    +/** @param array $cfg */
    +function platform_footer_year_end(array $cfg): int
    +{
    +    if (array_key_exists('year', $cfg) && $cfg['year'] !== null) {
    +        return (int) $cfg['year'];
    +    }
    +
    +    return (int) date('Y');
    +}
    +
    +/**
    + * Year label: single year or range (e.g. 2026–2026) when year_start is set.
    + *
    + * @param array $cfg
    + */
    +function platform_footer_year_label(array $cfg): string
    +{
    +    $end = platform_footer_year_end($cfg);
    +    $start = isset($cfg['year_start']) ? (int) $cfg['year_start'] : 0;
    +    if ($start > 0) {
    +        if (!empty($cfg['year_through_current'])) {
    +            return $start . ' - current';
    +        }
    +        if ($start === $end) {
    +            return (string) $start;
    +        }
    +
    +        return $start . "\u{2013}" . $end;
    +    }
    +
    +    return (string) $end;
    +}
    +
    +/**
    + * Default left line: © holder[, year|year-range].
    + *
    + * @param array $cfg
    + */
    +function platform_footer_default_left(array $cfg): string
    +{
    +    $symbol = trim((string) ($cfg['copyright_symbol'] ?? "\u{00A9}"));
    +    $holderName = trim((string) ($cfg['holder_name'] ?? ''));
    +    $holderUrl = trim((string) ($cfg['holder_url'] ?? ''));
    +    $holder = trim((string) ($cfg['holder'] ?? ''));
    +    $showYear = (bool) ($cfg['show_year'] ?? true);
    +    $parts = [];
    +    if ($symbol !== '') {
    +        $parts[] = $symbol;
    +    }
    +    if ($holderName !== '') {
    +        $parts[] = $holderName;
    +    } elseif ($holder !== '') {
    +        $parts[] = $holder;
    +    }
    +    $line = implode(' ', $parts);
    +    if (!$showYear) {
    +        return $line;
    +    }
    +    $yearLabel = platform_footer_year_label($cfg);
    +    if ($line === '') {
    +        return $yearLabel;
    +    }
    +
    +    return $line . ', ' . $yearLabel;
    +}
    +
    +/** Render left footer HTML (holder link when configured). */
    +function platform_footer_render_left_html(array $cfg, array $opts): ?string
    +{
    +    $raw = platform_footer_resolve_left($cfg, $opts);
    +    if ($raw === null) {
    +        return null;
    +    }
    +    $holderName = trim((string) ($cfg['holder_name'] ?? ''));
    +    $holderUrl = trim((string) ($cfg['holder_url'] ?? ''));
    +    if (platform_footer_field($cfg, $opts, 'left') === null
    +        && $holderName !== '' && $holderUrl !== '') {
    +        $text = platform_footer_h($raw);
    +        $name = platform_footer_h($holderName);
    +        $link = '' . $name . '';
    +        if (str_contains($text, $name)) {
    +            return substr_replace($text, $link, strpos($text, $name), strlen($name));
    +        }
    +        return $link . ($text !== '' ? ' ' . $text : '');
    +    }
    +
    +    return platform_footer_h($raw);
    +}
    +
    +/** True when default left uses holder_name + holder_url (skip i18n text replacement). */
    +function platform_footer_left_has_link(array $cfg, array $opts): bool
    +{
    +    if (platform_footer_field($cfg, $opts, 'left') !== null) {
    +        return false;
    +    }
    +    $holderName = trim((string) ($cfg['holder_name'] ?? ''));
    +    $holderUrl = trim((string) ($cfg['holder_url'] ?? ''));
    +
    +    return $holderName !== '' && $holderUrl !== '';
    +}
    +
    +/**
    + * Resolve optional row text (null/empty = omit row). Supports per-render overrides in $opts.
    + *
    + * @param array $cfg
    + * @param array $opts
    + */
    +function platform_footer_field(array $cfg, array $opts, string $key): ?string
    +{
    +    if (array_key_exists($key, $opts)) {
    +        $v = $opts[$key];
    +        if ($v === null || $v === false) {
    +            return null;
    +        }
    +        $t = trim((string) $v);
    +
    +        return $t === '' ? null : $t;
    +    }
    +    if (!array_key_exists($key, $cfg)) {
    +        return null;
    +    }
    +    $v = $cfg[$key];
    +    if ($v === null || $v === false) {
    +        return null;
    +    }
    +    $t = trim((string) $v);
    +
    +    return $t === '' ? null : $t;
    +}
    +
    +/**
    + * @param array $cfg
    + * @param array $opts
    + */
    +function platform_footer_resolve_left(array $cfg, array $opts): ?string
    +{
    +    $custom = platform_footer_field($cfg, $opts, 'left');
    +    if ($custom !== null) {
    +        return $custom;
    +    }
    +    $built = platform_footer_default_left($cfg);
    +
    +    return $built === '' ? null : $built;
    +}
    +
    +/** @deprecated Use platform_footer_default_left(); kept for scripts/tests. */
    +function platform_footer_plain_text(array $cfg, int $year): string
    +{
    +    $cfg = array_merge($cfg, ['year' => $year]);
    +
    +    return platform_footer_default_left($cfg);
    +}
    +
    +/**
    + * @param array{use_i18n?: bool, extra_class?: string, top?: ?string, left?: ?string, right?: ?string, bottom?: ?string} $opts
    + */
    +function platform_render_footer(array $opts = []): void
    +{
    +    $cfg = platform_footer_config();
    +    $top = platform_footer_field($cfg, $opts, 'top');
    +    $left = platform_footer_resolve_left($cfg, $opts);
    +    $right = platform_footer_field($cfg, $opts, 'right');
    +    $bottom = platform_footer_field($cfg, $opts, 'bottom');
    +
    +    $baseClass = (string) ($opts['extra_class'] ?? ($cfg['footer_class'] ?? 'bottom-bar'));
    +    $classes = trim($baseClass . ' platform-footer');
    +    if ($top === null && $bottom === null && $right === null) {
    +        $classes .= ' platform-footer--single-row';
    +    }
    +
    +    $useI18n = $opts['use_i18n'] ?? (bool) ($cfg['use_i18n'] ?? true);
    +    $i18nKey = (string) ($cfg['i18n_key'] ?? 'footer.copyright');
    +    $leftUsesDefault = platform_footer_field($cfg, $opts, 'left') === null;
    +    $leftHasLink = platform_footer_left_has_link($cfg, $opts);
    +    $yearEnd = platform_footer_year_end($cfg);
    +    $leftHtml = platform_footer_render_left_html($cfg, $opts);
    +
    +    echo '
    '; + + if ($top !== null) { + echo ''; + } + + if ($left !== null || $right !== null) { + echo ''; + } + + if ($bottom !== null) { + echo ''; + } + + echo '
    '; +} diff --git a/src/AnalyticsHead.php b/src/AnalyticsHead.php new file mode 100644 index 0000000..f520eee --- /dev/null +++ b/src/AnalyticsHead.php @@ -0,0 +1,26 @@ + $measurementId, + 'platform' => $platform, + 'basePath' => $basePath, + 'debug' => (bool) cfg('analytics.debug', false), + ]; + $json = json_encode($config, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP); + if ($json === false) { + return; + } + echo '', "\n"; + echo '', "\n"; + } +}