1
0
mirror of git://f0xx.org/ac/ac-platform-web synced 2026-07-29 00:59:10 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-23 12:29:29 +02:00
commit aef9e33b73
11 changed files with 5702 additions and 0 deletions

5
README.md Normal file
View File

@@ -0,0 +1,5 @@
# ac-platform-web
Shared CSS/JS/i18n for BE consoles.
Remote: `git://f0xx.org/ac/ac-platform-web`

2423
assets/css/app.css Normal file

File diff suppressed because it is too large Load Diff

218
assets/i18n/en.json Normal file
View File

@@ -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"
}

196
assets/i18n/ru.json Normal file
View File

@@ -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": "Файл"
}

157
assets/js/analytics.js Normal file
View File

@@ -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);

2067
assets/js/app.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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);

207
assets/js/i18n.js Normal file
View File

@@ -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<string, { flag: string, code: string }>} */
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<string, string|number>|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);

83
assets/js/nav_shell.js Normal file
View File

@@ -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();
}
})();

241
platform/footer.php Normal file
View File

@@ -0,0 +1,241 @@
<?php
declare(strict_types=1);
/**
* Shared footer fragment for Android Cast web consoles and hub.
*
* Configure: examples/platform/footer.config.php (see footer.config.example.php).
* Usage: <?php platform_render_footer(); ?> 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<string, mixed> */
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<string, mixed> $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. 20262026) when year_start is set.
*
* @param array<string, mixed> $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<string, mixed> $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 = '<a href="' . platform_footer_h($holderUrl) . '">' . $name . '</a>';
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<string, mixed> $cfg
* @param array<string, mixed> $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<string, mixed> $cfg
* @param array<string, mixed> $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 '<footer class="' . platform_footer_h($classes) . '">';
if ($top !== null) {
echo '<div class="platform-footer__top">' . platform_footer_h($top) . '</div>';
}
if ($left !== null || $right !== null) {
echo '<div class="platform-footer__main">';
if ($left !== null) {
echo '<div class="platform-footer__left"';
if ($useI18n && $i18nKey !== '' && $leftUsesDefault && !$leftHasLink) {
echo ' data-i18n="' . platform_footer_h($i18nKey) . '"'
. ' data-year="' . platform_footer_h(platform_footer_year_label($cfg)) . '"'
. ' data-year-end="' . $yearEnd . '"';
}
echo '>' . ($leftHtml ?? platform_footer_h($left)) . '</div>';
}
if ($right !== null) {
echo '<div class="platform-footer__right">' . platform_footer_h($right) . '</div>';
}
echo '</div>';
}
if ($bottom !== null) {
echo '<div class="platform-footer__bottom">' . platform_footer_h($bottom) . '</div>';
}
echo '</footer>';
}

26
src/AnalyticsHead.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/** Emits GA4 config + shared analytics.js for PHP views. */
final class AnalyticsHead {
public static function render(string $platform = 'crashes'): void {
$enabled = (bool) cfg('analytics.enabled', false);
$measurementId = trim((string) cfg('analytics.measurement_id', ''));
if (!$enabled || $measurementId === '') {
return;
}
$basePath = Auth::basePath();
$config = [
'measurementId' => $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 '<script>window.ANDROIDCAST_ANALYTICS=', $json, ';</script>', "\n";
echo '<script src="', h($basePath), '/assets/js/analytics.js" defer></script>', "\n";
}
}