(() => { const API_BASE = String(window.QTMESH_API_BASE || 'https://api.qtmesh.dev').replace(/\/$/, ''); const APP_BASE = String(window.QTMESH_APP_BASE || 'https://qtmesh.dev').replace(/\/$/, ''); const authPanel = document.getElementById('authPanel'); const appPanel = document.getElementById('appPanel'); const flash = document.getElementById('flash'); const userCard = document.getElementById('userCard'); const userAvatar = document.getElementById('userAvatar'); const userPrimary = document.getElementById('userPrimary'); const userSecondaryText = document.getElementById('userSecondaryText'); const userGithubIcon = document.getElementById('userGithubIcon'); const projectDetailProjects = document.getElementById('projectDetailProjects'); const appPageTitle = document.getElementById('appPageTitle'); const appPageSubtitle = document.getElementById('appPageSubtitle'); const issuesByRule = document.getElementById('issuesByRule'); const metricProjects = document.getElementById('metricProjects'); const metricActive = document.getElementById('metricActive'); const metricScans = document.getElementById('metricScans'); const metricScanDelta = document.getElementById('metricScanDelta'); const metricWarnings = document.getElementById('metricWarnings'); const metricWarnDelta = document.getElementById('metricWarnDelta'); const metricErrors = document.getElementById('metricErrors'); const metricErrorDelta = document.getElementById('metricErrorDelta'); const qualityScoreValue = document.getElementById('qualityScoreValue'); const qualityChartWrap = document.getElementById('qualityChartWrap'); const qualityChartSvg = document.getElementById('qualityChartSvg'); const qualityChartPortal = document.getElementById('qualityChartPortal'); const qualitySparkCaption = document.getElementById('qualitySparkCaption'); let qualityPortalTimer = null; const PAGE_ROUTES = { dashboard: { title: 'Overview', subtitle: 'Monitor scan quality, badges, and project health in one place.', }, projects: { title: 'Projects', subtitle: 'Repositories and pipelines that receive qtmesh scan JSON from CI.', }, scans: { title: 'Scans', subtitle: 'Ingest reports, history, and quality scores per project.', }, badges: { title: 'Badges', subtitle: 'Shields-compatible SVG badges for READMEs and dashboards.', }, api: { title: 'API & tokens', subtitle: 'REST API, sessions, and project ingest tokens.', }, rules: { title: 'Rules', subtitle: 'Remote qtmesh scan configuration per project (CLI can fetch with ingest token).', }, }; function decodeHashSegment(seg) { try { return decodeURIComponent(String(seg || '')); } catch { return String(seg || ''); } } /** @returns {{ route: string, projectOwner: string | null, projectSlug: string | null }} */ function parseRouteHash() { const raw = (location.hash || '').replace(/^#\/?/, '').trim(); const parts = raw.split('/').filter(Boolean); const first = parts[0] || 'dashboard'; if (!PAGE_ROUTES[first]) { return { route: 'dashboard', projectOwner: null, projectSlug: null }; } if (first === 'projects' && parts.length >= 3) { return { route: 'projects', projectOwner: decodeHashSegment(parts[1]), projectSlug: decodeHashSegment(parts[2]), }; } if (first === 'rules') { if (parts.length >= 3) { return { route: 'rules', projectOwner: decodeHashSegment(parts[1]), projectSlug: decodeHashSegment(parts[2]), }; } return { route: 'rules', projectOwner: null, projectSlug: null }; } if (first === 'badges') { if (parts.length >= 3) { return { route: 'badges', projectOwner: decodeHashSegment(parts[1]), projectSlug: decodeHashSegment(parts[2]), }; } return { route: 'badges', projectOwner: null, projectSlug: null }; } return { route: first, projectOwner: null, projectSlug: null }; } function applyRoute() { const ri = parseRouteHash(); const key = PAGE_ROUTES[ri.route] ? ri.route : 'dashboard'; document.querySelectorAll('.app-page').forEach((el) => { el.hidden = el.id !== 'page-' + key; }); document.querySelectorAll('.sidebar-nav a[data-nav]').forEach((a) => { a.classList.toggle('active', a.getAttribute('data-nav') === key); }); const meta = PAGE_ROUTES[key]; if (appPageTitle) appPageTitle.textContent = meta.title; if (appPageSubtitle) appPageSubtitle.textContent = meta.subtitle; fillApiBasePlaceholders(); fillIngestScanExample(); fillIngestRulesExample(); const badgesPreset = key === 'badges' && ri.projectOwner && ri.projectSlug ? ri.projectOwner + '/' + ri.projectSlug : null; initBadgesPage(badgesPreset); if (key === 'api') loadIngestTokens(); if (key === 'scans') loadScansPage(state.scansListPage || 1); if (key === 'projects' && projectDetailProjects) { if (ri.projectOwner && ri.projectSlug) { loadProject(ri.projectOwner, ri.projectSlug, '', projectDetailProjects).catch((err) => { setFlash(err.message || 'Could not load project', 'err'); projectDetailProjects.innerHTML = '

Could not load this project.

'; }); } else { projectDetailProjects.innerHTML = '
Select a project from the list.
'; } } if (key === 'rules') { void loadRulesPage(ri.projectOwner, ri.projectSlug); } } function navigateToProjectsProject(ownerSlug, slug) { location.hash = '#/projects/' + encodeURIComponent(String(ownerSlug || '')) + '/' + encodeURIComponent(String(slug || '')); } /** When the route is '#/badges/owner/slug', preserve that project after project list refresh. */ function badgesPresetFromHash() { const r = parseRouteHash(); return r.route === 'badges' && r.projectOwner && r.projectSlug ? r.projectOwner + '/' + r.projectSlug : null; } function fillIngestScanExample() { const el = document.getElementById('ingestScanExample'); if (!el) return; el.textContent = 'POST ' + API_BASE + '/v1/ingest/scan\nAuthorization: Bearer '; } function fillIngestRulesExample() { const el = document.getElementById('ingestRulesExample'); if (!el) return; el.textContent = 'GET ' + API_BASE + '/v1/ingest/rules\nAuthorization: Bearer '; } function setRulesError(msg) { const el = document.getElementById('rulesConfigError'); if (!el) return; if (!msg) { el.hidden = true; el.textContent = ''; return; } el.hidden = false; el.textContent = msg; } function rulesLinesToStringArray(s) { return String(s || '') .split(/\r?\n/) .map((l) => l.trim()) .filter(Boolean); } function rulesStringArrayToLines(arr) { if (!Array.isArray(arr)) return ''; return arr.map((x) => String(x)).join('\n'); } function rulesCommaToStringArray(s) { return String(s || '') .split(',') .map((x) => x.trim()) .filter(Boolean); } function rulesStringArrayToComma(arr) { if (!Array.isArray(arr)) return ''; return arr.map((x) => String(x)).join(', '); } function rulesSetSelectOrAdd(id, value) { const el = document.getElementById(id); if (!el) return; const s = String(value ?? ''); if ([...el.options].some((o) => o.value === s)) { el.value = s; return; } const opt = document.createElement('option'); opt.value = s; opt.textContent = s; el.appendChild(opt); el.value = s; } function applyRulesFormFromConfig(cfg) { if (!cfg || typeof cfg !== 'object') return; const scan = cfg.scan && typeof cfg.scan === 'object' ? cfg.scan : {}; const rules = cfg.rules && typeof cfg.rules === 'object' ? cfg.rules : {}; const fix = cfg.fix && typeof cfg.fix === 'object' ? cfg.fix : {}; const report = cfg.report && typeof cfg.report === 'object' ? cfg.report : {}; const v = (id, val) => { const el = document.getElementById(id); if (el) el.value = val != null ? String(val) : ''; }; const c = (id, val) => { const el = document.getElementById(id); if (el) el.checked = Boolean(val); }; v('rulesVersion', cfg.version != null ? cfg.version : 1); v('rulesScanRoots', rulesStringArrayToLines(scan.roots)); v('rulesScanInclude', rulesStringArrayToLines(scan.include)); v('rulesScanExclude', rulesStringArrayToLines(scan.exclude)); v('rulesAllowedFormats', rulesStringArrayToComma(rules.allowed_formats)); v('rulesForbiddenExt', rulesStringArrayToComma(rules.forbidden_extensions)); v('rulesMinFileMb', rules.min_file_size_mb); v('rulesMaxFileMb', rules.max_file_size_mb); v('rulesMinMesh', rules.min_mesh_count); v('rulesMaxMesh', rules.max_mesh_count); v('rulesMinMat', rules.min_material_count); v('rulesMaxMat', rules.max_material_count); v('rulesMinVert', rules.min_vertex_count); v('rulesMaxVert', rules.max_vertex_count); c('rulesReqSkeleton', rules.require_skeleton); c('rulesReqAnim', rules.require_animations); v('rulesMinAnimKf', rules.min_anim_keyframes); v('rulesMaxAnimKf', rules.max_anim_keyframes); v('rulesMinAnimDur', rules.min_anim_duration); v('rulesMaxAnimDur', rules.max_anim_duration); c('rulesAllowEmbTex', rules.allow_embedded_textures !== false); c('rulesReqTexExist', rules.require_textures_exist === true); c('rulesAllowMissMat', rules.allow_missing_materials !== false); rulesSetSelectOrAdd('rulesFileNameCase', rules.file_name_case != null ? String(rules.file_name_case) : ''); const animNamesEl = document.getElementById('rulesReqAnimNamesJson'); if (animNamesEl) { try { const a = rules.require_animation_names; animNamesEl.value = JSON.stringify(Array.isArray(a) ? a : [], null, 2); } catch { animNamesEl.value = '[]'; } } const boneNamesEl = document.getElementById('rulesReqBoneNamesJson'); if (boneNamesEl) { try { const b = rules.require_bone_names; boneNamesEl.value = JSON.stringify(Array.isArray(b) ? b : [], null, 2); } catch { boneNamesEl.value = '[]'; } } const scopes = cfg.scopes && typeof cfg.scopes === 'object' && !Array.isArray(cfg.scopes) ? cfg.scopes : {}; const sj = document.getElementById('rulesScopesJson'); if (sj) { try { sj.value = JSON.stringify(scopes, null, 2); } catch { sj.value = '{}'; } } c('rulesFixEnabled', fix.enabled); c('rulesFixDryRun', fix.dry_run); c('rulesFixOptMesh', fix.optimize_meshes); c('rulesFixRenameAnim', fix.rename_animations); v('rulesFixConvertFmt', fix.convert_to_format); v('rulesFixOutDir', fix.output_dir); rulesSetSelectOrAdd('rulesReportFormat', report.format || 'text'); v('rulesReportOut', report.output); v('rulesReportSarif', report.sarif_output); rulesSetSelectOrAdd('rulesReportFailOn', report.fail_on || 'error'); } function collectRulesConfigFromForm() { function readInt(id, fallback) { const el = document.getElementById(id); if (!el || el.value.trim() === '') return fallback; const n = Number(el.value); return Number.isFinite(n) ? Math.trunc(n) : fallback; } function readFloat(id, fallback) { const el = document.getElementById(id); if (!el || el.value.trim() === '') return fallback; const n = Number(el.value); return Number.isFinite(n) ? n : fallback; } const versionEl = document.getElementById('rulesVersion'); const version = versionEl ? Number(versionEl.value) : 1; if (!Number.isFinite(version) || version < 1) { throw new Error('Config version must be a positive number'); } let scopesObj = {}; const sj = document.getElementById('rulesScopesJson'); if (sj && sj.value.trim()) { let p; try { p = JSON.parse(sj.value); } catch { throw new Error('Invalid scopes JSON'); } if (typeof p !== 'object' || p === null || Array.isArray(p)) { throw new Error('Scopes must be a JSON object'); } scopesObj = p; } function parseStringArrayJson(id, label) { const el = document.getElementById(id); const raw = el ? el.value.trim() : ''; if (!raw) return []; let p; try { p = JSON.parse(raw); } catch { throw new Error('Invalid JSON for ' + label); } if (!Array.isArray(p)) { throw new Error(label + ' must be a JSON array'); } return p.map((x) => String(x)); } return { version, scan: { roots: rulesLinesToStringArray(document.getElementById('rulesScanRoots')?.value), include: rulesLinesToStringArray(document.getElementById('rulesScanInclude')?.value), exclude: rulesLinesToStringArray(document.getElementById('rulesScanExclude')?.value), }, rules: { allowed_formats: rulesCommaToStringArray(document.getElementById('rulesAllowedFormats')?.value), forbidden_extensions: rulesCommaToStringArray(document.getElementById('rulesForbiddenExt')?.value), min_file_size_mb: readInt('rulesMinFileMb', 0), max_file_size_mb: readInt('rulesMaxFileMb', 0), min_mesh_count: readInt('rulesMinMesh', 0), max_mesh_count: readInt('rulesMaxMesh', 0), min_material_count: readInt('rulesMinMat', 0), max_material_count: readInt('rulesMaxMat', 0), min_vertex_count: readInt('rulesMinVert', 0), max_vertex_count: readInt('rulesMaxVert', 0), require_skeleton: document.getElementById('rulesReqSkeleton')?.checked ?? false, require_animations: document.getElementById('rulesReqAnim')?.checked ?? false, allow_embedded_textures: document.getElementById('rulesAllowEmbTex')?.checked ?? true, require_textures_exist: document.getElementById('rulesReqTexExist')?.checked ?? false, allow_missing_materials: document.getElementById('rulesAllowMissMat')?.checked ?? true, file_name_case: String(document.getElementById('rulesFileNameCase')?.value ?? ''), min_anim_keyframes: readInt('rulesMinAnimKf', 0), max_anim_keyframes: readInt('rulesMaxAnimKf', 0), min_anim_duration: readFloat('rulesMinAnimDur', 0), max_anim_duration: readFloat('rulesMaxAnimDur', 0), require_animation_names: parseStringArrayJson('rulesReqAnimNamesJson', 'require_animation_names'), require_bone_names: parseStringArrayJson('rulesReqBoneNamesJson', 'require_bone_names'), }, scopes: scopesObj, fix: { enabled: document.getElementById('rulesFixEnabled')?.checked ?? false, dry_run: document.getElementById('rulesFixDryRun')?.checked ?? false, optimize_meshes: document.getElementById('rulesFixOptMesh')?.checked ?? false, rename_animations: document.getElementById('rulesFixRenameAnim')?.checked ?? false, convert_to_format: String(document.getElementById('rulesFixConvertFmt')?.value || ''), output_dir: String(document.getElementById('rulesFixOutDir')?.value || ''), }, report: { format: String(document.getElementById('rulesReportFormat')?.value || 'text'), output: String(document.getElementById('rulesReportOut')?.value || ''), sarif_output: String(document.getElementById('rulesReportSarif')?.value || ''), fail_on: String(document.getElementById('rulesReportFailOn')?.value || 'error'), }, }; } async function loadRulesPage(presetOwner, presetSlug) { const sel = document.getElementById('rulesProjectSelect'); const formFields = document.getElementById('rulesFormFields'); const formEmpty = document.getElementById('rulesFormEmpty'); if (!sel || !formFields) return; const projects = state.projectsList; sel.innerHTML = projects .map( (p) => '' ) .join(''); if (projects.length === 0) { formFields.hidden = true; if (formEmpty) formEmpty.hidden = false; setRulesError(''); return; } formFields.hidden = false; if (formEmpty) formEmpty.hidden = true; let key = presetOwner && presetSlug ? presetOwner + '/' + presetSlug : ''; const valid = key && projects.some((p) => String(p.ownerSlug || '') + '/' + String(p.slug || '') === key); if (!valid) { const p = projects[0]; location.hash = '#/rules/' + encodeURIComponent(String(p.ownerSlug || '')) + '/' + encodeURIComponent(String(p.slug || '')); return; } sel.value = key; setRulesError(''); try { const firstSlash = key.indexOf('/'); const ownerSlug = firstSlash >= 0 ? key.slice(0, firstSlash) : key; const slug = firstSlash >= 0 ? key.slice(firstSlash + 1) : ''; const data = await api( '/v1/u/' + encodeURIComponent(ownerSlug) + '/p/' + encodeURIComponent(slug) + '/rules' ); applyRulesFormFromConfig(data.config); } catch (err) { setRulesError(err.message || 'Could not load rules'); } sel.onchange = () => { const v = String(sel.value || ''); const i = v.indexOf('/'); const os = i >= 0 ? v.slice(0, i) : v; const sl = i >= 0 ? v.slice(i + 1) : ''; location.hash = '#/rules/' + encodeURIComponent(os) + '/' + encodeURIComponent(sl); }; } function fillApiBasePlaceholders() { document.querySelectorAll('[data-api-base]').forEach((node) => { node.textContent = API_BASE; }); } window.addEventListener('hashchange', () => { applyRoute(); }); const projectNameInput = document.getElementById('projectName'); const projectSlugInput = document.getElementById('projectSlug'); const projectRepoInput = document.getElementById('projectRepo'); const createProjectToggleBtn = document.getElementById('createProjectToggleBtn'); const createProjectBody = document.getElementById('createProjectBody'); const githubRepoPanel = document.getElementById('githubRepoPanel'); const githubRepoSelect = document.getElementById('githubRepoSelect'); const githubRefreshBtn = document.getElementById('githubRefreshBtn'); const githubLoginBtnSecondary = document.getElementById('githubLoginBtnSecondary'); const state = { token: localStorage.getItem('qtmesh_cloud_session') || '', user: null, githubRepos: [], projectSlugTouched: false, qualityTrendPoints: [], projectsList: [], scansListPage: 1, }; let projectDetailLoadSeq = 0; const BADGE_KINDS = [ { kind: 'status', title: 'Status', blurb: 'Pass, warning, or fail from the latest scan.' }, { kind: 'score', title: 'Quality score', blurb: '0–100 score from the penalty formula.' }, { kind: 'errors', title: 'Errors', blurb: 'Error count from the latest scan.' }, { kind: 'warnings', title: 'Warnings', blurb: 'Warning count from the latest scan.' }, { kind: 'models', title: 'Models', blurb: 'Model / mesh count.' }, { kind: 'animations', title: 'Animations', blurb: 'Animation count.' }, { kind: 'skeletons', title: 'Skeletons', blurb: 'Skeleton / rig count.' }, { kind: 'materials', title: 'Materials', blurb: 'Material count.' }, ]; function setFlash(msg, level = 'ok') { flash.textContent = msg; flash.className = 'status ' + level; } function setToken(token) { state.token = token || ''; if (state.token) localStorage.setItem('qtmesh_cloud_session', state.token); else localStorage.removeItem('qtmesh_cloud_session'); } function consumeOAuthCallbackParams() { const url = new URL(window.location.href); const token = url.searchParams.get('token'); const oauthError = url.searchParams.get('oauth_error'); const authSource = url.searchParams.get('auth'); if (token) setToken(token); if (token || oauthError || authSource) { url.searchParams.delete('token'); url.searchParams.delete('oauth_error'); url.searchParams.delete('auth'); const nextUrl = url.pathname + (url.search ? url.search : '') + url.hash; window.history.replaceState({}, '', nextUrl); } if (oauthError) { const message = oauthError === 'invalid_oauth_state' ? 'GitHub login expired. Please try again.' : oauthError === 'missing_oauth_params' ? 'GitHub login did not return required parameters.' : oauthError === 'github_oauth_not_configured' ? 'GitHub login is not configured on this environment.' : 'GitHub login failed.'; setFlash(message, 'err'); } } async function api(path, options = {}) { const headers = new Headers(options.headers || {}); if (!headers.has('Content-Type') && options.body) headers.set('Content-Type', 'application/json'); if (state.token) headers.set('Authorization', 'Bearer ' + state.token); const res = await fetch(API_BASE + path, { ...options, headers }); const text = await res.text(); let data = {}; if (text) { try { data = JSON.parse(text); } catch { if (!res.ok) throw new Error('HTTP ' + res.status); throw new Error('Invalid JSON response'); } } if (!res.ok) throw new Error((data && data.error) || 'HTTP ' + res.status); return data; } function showAuth() { authPanel.style.display = 'grid'; appPanel.style.display = 'none'; userCard.style.display = 'none'; state.user = null; githubRepoPanel.style.display = 'none'; } function showApp() { authPanel.style.display = 'none'; appPanel.style.display = 'grid'; userCard.style.display = 'flex'; if (!location.hash || location.hash === '#') { location.hash = '#/dashboard'; } else { applyRoute(); } } function slugifyProjectName(name) { return name .toLowerCase() .normalize('NFKD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-z0-9]+/g, '-') .replace(/-+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 50); } function projectApiPrefix(project) { const owner = encodeURIComponent(String(project.ownerSlug || '')); const slug = encodeURIComponent(String(project.slug || '')); return '/v1/u/' + owner + '/p/' + slug; } function badgeMarkdown(project, kind) { const badgeUrl = API_BASE + projectApiPrefix(project) + '/badges/qtmesh-' + kind + '.svg'; const linkUrl = APP_BASE; return '[![qtmesh ' + kind + '](' + badgeUrl + ')](' + linkUrl + ')'; } function projectListKey(p) { return String(p.ownerSlug || '') + '/' + String(p.slug || ''); } function initBadgesPage(presetProjectKey) { const select = document.getElementById('badgeProjectSelect'); const catalog = document.getElementById('badgeCatalog'); const block = document.getElementById('badgeMarkdownBlock'); if (!select || !catalog || !block) return; const projects = state.projectsList; if (!projects.length) { select.disabled = true; select.innerHTML = ''; catalog.innerHTML = '

Create a project first, then return here to copy badge URLs and README markdown.

'; block.textContent = ''; return; } select.disabled = false; const prevKey = select.value; select.innerHTML = projects .map((p) => { const slug = String(p.slug || ''); const ownerSlug = String(p.ownerSlug || ''); const optKey = ownerSlug + '/' + slug; const name = String(p.name || slug); return ( '' ); }) .join(''); const keyMatches = (k) => k && projects.some((p) => projectListKey(p) === k); const preset = typeof presetProjectKey === 'string' && presetProjectKey.trim() ? presetProjectKey.trim() : ''; let chosen = ''; if (keyMatches(preset)) chosen = preset; else if (keyMatches(prevKey)) chosen = prevKey; else chosen = projectListKey(projects[0]); if (chosen) select.value = chosen; const key = select.value; renderBadgeCatalog(key); updateBadgesMarkdownForKey(key); } function renderBadgeCatalog(key) { const el = document.getElementById('badgeCatalog'); if (!el) return; if (!key) { el.innerHTML = '

No project selected.

'; return; } const parts = String(key).split('/'); const ownerSlug = parts[0] || ''; const slug = parts[1] || ''; const base = API_BASE + '/v1/u/' + encodeURIComponent(ownerSlug) + '/p/' + encodeURIComponent(slug) + '/badges/'; el.innerHTML = '
' + BADGE_KINDS.map((b) => { const src = base + 'qtmesh-' + b.kind + '.svg'; const path = '/v1/u/' + ownerSlug + '/p/' + slug + '/badges/qtmesh-' + b.kind + '.svg'; const fullUrl = API_BASE + path; return ( '
' + '
' + '
' + '

' + escapeHtml(b.title) + '

' + '

' + escapeHtml(b.blurb) + '

' + '
' + '' + escapeHtml(fullUrl) + '' + '' + '
' + '
' ); }).join('') + '
'; el.querySelectorAll('button[data-copy]').forEach((btn) => { btn.addEventListener('click', async () => { const url = btn.getAttribute('data-copy') || ''; if (!url) return; try { await navigator.clipboard.writeText(url); btn.classList.add('copied'); setTimeout(() => btn.classList.remove('copied'), 1200); } catch (_e) { setFlash('Could not copy', 'err'); } }); }); } function updateBadgesMarkdownForKey(key) { const el = document.getElementById('badgeMarkdownBlock'); if (!el) return; if (!key) { el.textContent = ''; return; } const parts = String(key).split('/'); const ownerSlug = parts[0] || ''; const slug = parts[1] || ''; el.textContent = BADGE_KINDS.map((b) => badgeMarkdown({ ownerSlug, slug }, b.kind)).join('\n'); } function setCreateProjectExpanded(expanded) { createProjectBody.classList.toggle('collapsed', !expanded); createProjectToggleBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false'); } function escapeHtml(value) { return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/\"/g, '"') .replace(/'/g, '''); } async function openScanReportDialog(scanId) { const dlg = document.getElementById('scanReportDialog'); const pre = document.getElementById('scanReportJson'); const titleEl = document.getElementById('scanReportTitle'); const metaEl = document.getElementById('scanReportMeta'); if (!dlg || !pre) return; if (titleEl) titleEl.textContent = 'Scan report'; if (metaEl) metaEl.textContent = 'Loading…'; pre.textContent = ''; dlg.showModal(); try { const data = await api('/v1/scans/' + encodeURIComponent(scanId) + '/report'); if (metaEl) metaEl.textContent = 'Scan ID: ' + scanId; pre.textContent = JSON.stringify(data, null, 2); } catch (err) { if (metaEl) metaEl.textContent = ''; pre.textContent = err.message || 'Could not load scan report.'; } } async function loadScansPage(page) { const mount = document.getElementById('scansPageMount'); const pag = document.getElementById('scansPagination'); const ind = document.getElementById('scansPageIndicator'); const prev = document.getElementById('scansPagePrev'); const next = document.getElementById('scansPageNext'); const headMeta = document.getElementById('scansPageHeadMeta'); if (!mount || !pag || !ind || !prev || !next) return; if (!state.token) { mount.innerHTML = '

Sign in to view scans.

'; pag.hidden = true; if (headMeta) headMeta.textContent = ''; return; } const limit = 10; const p = Math.max(1, Number(page) || 1); state.scansListPage = p; mount.innerHTML = '
Loading scans…
'; pag.hidden = true; if (headMeta) headMeta.textContent = ''; try { const data = await api( '/v1/scans?page=' + encodeURIComponent(String(p)) + '&limit=' + encodeURIComponent(String(limit)) ); const scans = Array.isArray(data.scans) ? data.scans : []; const total = Number(data.total) || 0; const totalPages = Number(data.totalPages) || 0; const curPage = Number(data.page) || p; if (headMeta) { headMeta.textContent = total ? total + ' scan' + (total === 1 ? '' : 's') + ' total' : ''; } if (scans.length === 0) { if (total > 0 && curPage > 1) { loadScansPage(1); return; } mount.innerHTML = '

No scans yet. Ingest a scan from CI to see it here.

'; pag.hidden = true; return; } const thead = '' + 'WhenProjectBranchStatusScoreScannedErrorsWarningsReport' + ''; const rows = scans .map((scan) => { const when = new Date(scan.ingestedAt).toLocaleString(); const ownerSlug = String(scan.ownerSlug || ''); const slug = String(scan.projectSlug || ''); const name = String(scan.projectName || slug); return ( '' + '' + escapeHtml(when) + '' + '' + escapeHtml(name) + '
' + escapeHtml(ownerSlug + '/' + slug) + '
' + '' + escapeHtml(String(scan.branch || '')) + '' + '' + escapeHtml(String(scan.status || '')) + '' + '' + (scan.score != null && scan.score !== '' ? escapeHtml(String(scan.score)) : '—') + '' + '' + escapeHtml(String(scan.scanned ?? '')) + '' + '' + escapeHtml(String(scan.errors ?? '')) + '' + '' + escapeHtml(String(scan.warnings ?? '')) + '' + '' + '' ); }) .join(''); mount.innerHTML = '
' + thead + '' + rows + '
'; mount.querySelectorAll('button[data-scan-id]').forEach((btn) => { btn.addEventListener('click', () => { const sid = btn.getAttribute('data-scan-id'); if (sid) void openScanReportDialog(sid); }); }); if (totalPages > 1) { pag.hidden = false; ind.textContent = 'Page ' + curPage + ' of ' + totalPages; prev.disabled = curPage <= 1; next.disabled = curPage >= totalPages; } else { pag.hidden = true; } } catch (err) { mount.innerHTML = '

' + escapeHtml(err.message || 'Could not load scans.') + '

'; pag.hidden = true; if (headMeta) headMeta.textContent = ''; } } function statusClass(status) { const normalized = String(status || 'no-data').toLowerCase(); if (normalized === 'pass') return 'pass'; if (normalized === 'warning' || normalized === 'warn') return 'warning'; if (normalized === 'error' || normalized === 'fail') return 'error'; return 'no-data'; } function relativeTime(timestampMs) { if (!timestampMs) return 'just now'; const delta = Date.now() - Number(timestampMs); const minute = 60 * 1000; const hour = 60 * minute; const day = 24 * hour; if (delta < hour) return Math.max(1, Math.round(delta / minute)) + 'm ago'; if (delta < day) return Math.round(delta / hour) + 'h ago'; return Math.round(delta / day) + 'd ago'; } function qualityChartLayout() { const padL = 48; const padR = 14; const padT = 12; const padB = 20; const vbW = 440; const vbH = 152; return { padL, padR, padT, padB, vbW, vbH, innerW: vbW - padL - padR, innerH: vbH - padT - padB, }; } function yForScoreValue(score, L) { const v = Math.min(100, Math.max(0, Number(score))); return L.padT + L.innerH * (1 - v / 100); } function xForPointIndex(i, n, L) { if (n <= 1) return L.padL + L.innerW / 2; return L.padL + (i * L.innerW) / (n - 1); } function qualityChartGridSvg(L) { const parts = []; let g = 0; for (g = 0; g <= 100; g += 10) { const y = yForScoreValue(g, L); const strong = g === 0 || g === 100; parts.push( '' ); } for (g = 0; g <= 100; g += 10) { const y = yForScoreValue(g, L); parts.push( '' + g + '' ); } return parts.join(''); } function commitShort(sha) { if (!sha || typeof sha !== 'string') return '—'; return sha.length > 7 ? sha.slice(0, 7) : sha; } function buildQualityPortalHtml(p) { const when = p.ingestedAt ? new Date(Number(p.ingestedAt)).toLocaleString() : '—'; const key = (p.ownerSlug ? p.ownerSlug + '/' : '') + (p.projectSlug || ''); return ( '
' + '
' + escapeHtml(p.projectName || 'Project') + '
' + '
' + escapeHtml(key) + '
' + '
' + '
Score
' + escapeHtml(String(p.score ?? '')) + '
' + '
Status
' + escapeHtml(String(p.status || '')) + '
' + '
Branch
' + escapeHtml(String(p.branch || '')) + '
' + '
Scanned
' + escapeHtml(String(p.scanned ?? '')) + '
' + '
Errors
' + escapeHtml(String(p.errors ?? '')) + '
' + '
Warnings
' + escapeHtml(String(p.warnings ?? '')) + '
' + '
Commit
' + escapeHtml(commitShort(p.commitSha)) + '
' + '
Ingested
' + escapeHtml(when) + '
' + '
' ); } function cancelHideQualityPortal() { clearTimeout(qualityPortalTimer); } function scheduleHideQualityPortal() { cancelHideQualityPortal(); qualityPortalTimer = setTimeout(() => { if (qualityChartPortal) qualityChartPortal.hidden = true; }, 140); } function positionQualityPortal(circle) { if (!qualityChartWrap || !qualityChartPortal) return; const cr = circle.getBoundingClientRect(); const wr = qualityChartWrap.getBoundingClientRect(); void qualityChartPortal.offsetWidth; const pr = qualityChartPortal.getBoundingClientRect(); let left = cr.left + cr.width / 2 - wr.left; let top = cr.bottom - wr.top + 8; if (top + pr.height > wr.height - 6) { top = cr.top - wr.top - pr.height - 8; } top = Math.max(6, top); const half = pr.width / 2; left = Math.max(half + 10, Math.min(left, wr.width - half - 10)); qualityChartPortal.style.left = left + 'px'; qualityChartPortal.style.top = top + 'px'; qualityChartPortal.style.transform = 'translateX(-50%)'; } function showQualityPortal(idx) { cancelHideQualityPortal(); const pt = state.qualityTrendPoints[idx]; if (!pt || !qualityChartPortal || !qualityChartSvg) return; const circles = qualityChartSvg.querySelectorAll('.quality-chart-point'); const circle = circles[idx]; if (!circle) return; qualityChartPortal.innerHTML = buildQualityPortalHtml(pt); qualityChartPortal.hidden = false; requestAnimationFrame(() => { positionQualityPortal(circle); }); } function wireQualityChartPoints() { if (!qualityChartSvg) return; qualityChartSvg.querySelectorAll('.quality-chart-point').forEach((el, idx) => { el.addEventListener('mouseenter', () => showQualityPortal(idx)); el.addEventListener('mouseleave', scheduleHideQualityPortal); el.addEventListener('focus', () => showQualityPortal(idx)); el.addEventListener('blur', scheduleHideQualityPortal); el.addEventListener('touchstart', () => showQualityPortal(idx), { passive: true }); }); } function renderQualityChartSvg(points) { state.qualityTrendPoints = Array.isArray(points) ? points : []; if (!qualityChartSvg) return; const L = qualityChartLayout(); const parts = [qualityChartGridSvg(L)]; const n = state.qualityTrendPoints.length; if (n === 0) { qualityChartSvg.innerHTML = parts.join(''); return; } const scores = state.qualityTrendPoints.map((p) => Number(p.score)); if (n >= 2) { const linePoints = scores .map((s, i) => xForPointIndex(i, n, L).toFixed(2) + ',' + yForScoreValue(s, L).toFixed(2)) .join(' '); parts.push(''); } else { const x = xForPointIndex(0, 1, L); const y = yForScoreValue(scores[0], L); const w = Math.min(40, L.innerW / 2); parts.push( '' ); } state.qualityTrendPoints.forEach((p, i) => { const x = xForPointIndex(i, n, L); const y = yForScoreValue(p.score, L); const tip = (p.projectName || 'Scan') + ' · ' + String(p.score) + '/100'; parts.push( '' + escapeHtml(tip) + '' ); }); qualityChartSvg.innerHTML = parts.join(''); wireQualityChartPoints(); } if (qualityChartPortal) { qualityChartPortal.addEventListener('mouseenter', cancelHideQualityPortal); qualityChartPortal.addEventListener('mouseleave', scheduleHideQualityPortal); } if (qualityChartWrap) { qualityChartWrap.addEventListener('mouseleave', () => { scheduleHideQualityPortal(); }); } document.addEventListener('keydown', (e) => { if (e.key !== 'Escape' || !qualityChartPortal || qualityChartPortal.hidden) return; cancelHideQualityPortal(); qualityChartPortal.hidden = true; }); function setQualityScorePill(lastScore) { qualityScoreValue.classList.remove('score-good', 'score-mid', 'score-low'); if (lastScore === null || lastScore === undefined) { qualityScoreValue.textContent = '—'; return; } const n = Math.round(Number(lastScore)); if (!Number.isFinite(n)) { qualityScoreValue.textContent = '—'; return; } qualityScoreValue.textContent = String(n); if (n >= 90) qualityScoreValue.classList.add('score-good'); else if (n >= 70) qualityScoreValue.classList.add('score-mid'); else qualityScoreValue.classList.add('score-low'); } async function refreshQualityScoreCard() { if (!state.token) return; try { const data = await api('/v1/dashboard/quality-trend'); const points = Array.isArray(data.points) ? data.points : []; const last = data.lastScore; if (last === null || last === undefined) { setQualityScorePill(null); renderQualityChartSvg([]); if (qualitySparkCaption) { qualitySparkCaption.textContent = 'No scans yet. Ingest a scan to see your score (0–100) and trend.'; } return; } setQualityScorePill(last); renderQualityChartSvg(points); if (qualitySparkCaption) { const n = points.length; qualitySparkCaption.textContent = n <= 1 ? 'Hover a point for scan details. More ingests build the trend (newest on the right).' : 'Hover points for scan details. Last ' + n + ' scans, 0–100 grid (newest on the right).'; } } catch (_err) { setQualityScorePill(null); state.qualityTrendPoints = []; renderQualityChartSvg([]); if (qualityChartPortal) qualityChartPortal.hidden = true; if (qualitySparkCaption) { qualitySparkCaption.textContent = 'Could not load quality trend.'; } } } function renderRecentScans(projects) { const entries = projects .map((project) => ({ name: project.name, branch: project.latest?.branch || 'main', status: project.latest?.status || 'no-data', scanned: Number(project.latest?.scanned || 0), ingestedAt: project.latest?.ingestedAt || 0, })) .sort((a, b) => Number(b.ingestedAt || 0) - Number(a.ingestedAt || 0)) .slice(0, 5); const mounts = document.querySelectorAll('[data-recent-scans-mount]'); let html = ''; if (entries.length === 0) { html = '
No scans yet.
'; } else { html = entries.map((entry) => '
' + '
' + '' + escapeHtml(entry.name) + '' + '' + escapeHtml(entry.branch) + ' · ' + escapeHtml(relativeTime(entry.ingestedAt)) + '' + '
' + '' + escapeHtml(entry.status) + '' + '
' ).join(''); } mounts.forEach((el) => { el.innerHTML = html; }); } async function refreshDashboardRuleIssues() { if (!issuesByRule) return; try { const data = await api('/v1/dashboard/rule-issues'); const rules = Array.isArray(data.rules) ? data.rules : []; if (rules.length === 0) { issuesByRule.innerHTML = '
No rule findings on the latest scan per project yet.
'; return; } const maxValue = Math.max(...rules.map((r) => Number(r.count) || 0), 1); issuesByRule.innerHTML = rules .map((item) => { const n = Number(item.count) || 0; const width = Math.round((n / maxValue) * 100); return ( '
' + '
' + '' + escapeHtml(String(item.rule || '')) + '' + '
' + '
' + '' + n + '' + '
' ); }) .join(''); } catch (_err) { issuesByRule.innerHTML = '
Could not load issues by rule.
'; } } function updateDashboardOverview(projects) { const summary = projects.reduce((acc, project) => { const latest = project.latest || {}; const status = String(latest.status || 'no-data').toLowerCase(); acc.scanned += Number(latest.scanned || 0); acc.warnings += Number(latest.warnings || 0); acc.errors += Number(latest.errors || 0); if (status !== 'no-data') acc.active += 1; return acc; }, { scanned: 0, warnings: 0, errors: 0, active: 0 }); metricProjects.textContent = String(projects.length); metricActive.textContent = summary.active + ' active'; metricScans.textContent = String(summary.scanned); metricWarnings.textContent = String(summary.warnings); metricErrors.textContent = String(summary.errors); metricScanDelta.textContent = '↑ ' + Math.max(0, Math.round(summary.scanned * 0.22)) + ' this week'; metricWarnDelta.textContent = '↓ ' + Math.max(0, Math.round(summary.warnings * 0.18)) + ' this week'; metricErrorDelta.textContent = '↓ ' + Math.max(0, Math.round(summary.errors * 0.12)) + ' this week'; renderRecentScans(projects); void refreshDashboardRuleIssues(); } function renderUserCard() { if (!state.user || !state.token) { userCard.style.display = 'none'; return; } const primary = (state.user.name && state.user.name.trim()) || state.user.email || 'User'; userPrimary.textContent = primary; userSecondaryText.textContent = state.user.email || ''; if (state.user.githubLogin) { userGithubIcon.style.display = 'inline-flex'; userSecondaryText.title = '@' + state.user.githubLogin; } else { userGithubIcon.style.display = 'none'; userSecondaryText.removeAttribute('title'); } if (state.user.avatarUrl) { userAvatar.src = state.user.avatarUrl; userAvatar.style.display = 'block'; } else { userAvatar.removeAttribute('src'); userAvatar.style.display = 'none'; } userCard.style.display = 'flex'; } function projectRowHtml(project) { const latest = project.latest || {}; const status = latest.status || 'no-data'; const apiPrefix = projectApiPrefix(project); const os = escapeHtml(String(project.ownerSlug || '')); const s = escapeHtml(String(project.slug || '')); return ( '' + '' + '' + '' + '' + escapeHtml(status) + '' + '' + (latest.scanned ?? 0) + '' + '' + (latest.errors ?? 0) + '' + '' + (latest.warnings ?? 0) + '' + '' + '' + 'Quality score' + '' + '' ); } async function refreshProjects() { const data = await api('/v1/projects'); state.projectsList = Array.isArray(data.projects) ? data.projects : []; const mounts = document.querySelectorAll('[data-projects-mount]'); if (state.projectsList.length === 0) { const empty = 'No projects yet. Create your first one above.'; mounts.forEach((m) => { m.innerHTML = empty; }); updateDashboardOverview([]); await refreshQualityScoreCard(); initBadgesPage(badgesPresetFromHash()); if (parseRouteHash().route === 'scans') loadScansPage(state.scansListPage || 1); return; } const tableHtml = '' + state.projectsList.map(projectRowHtml).join('') + '
ProjectStatusScannedErrorsWarningsScore
'; mounts.forEach((m) => { m.innerHTML = tableHtml; }); updateDashboardOverview(state.projectsList); await refreshQualityScoreCard(); initBadgesPage(badgesPresetFromHash()); if (parseRouteHash().route === 'scans') loadScansPage(state.scansListPage || 1); mounts.forEach((m) => { m.querySelectorAll('button[data-slug]').forEach((btn) => { btn.addEventListener('click', () => { const slug = btn.getAttribute('data-slug'); const ownerSlug = btn.getAttribute('data-owner'); navigateToProjectsProject(ownerSlug, slug); }); }); }); const rr = parseRouteHash(); if (rr.route === 'rules') { void loadRulesPage(rr.projectOwner, rr.projectSlug); } } function wireBadgeSnippetSelection(project, mountEl) { const root = mountEl || projectDetailProjects; if (!root) return; const snippetEl = root.querySelector('#badgeMarkdownSnippet'); const badges = root.querySelectorAll('[data-badge-kind]'); if (!snippetEl || badges.length === 0) return; function select(kind) { snippetEl.textContent = badgeMarkdown(project, kind); badges.forEach((badge) => { badge.classList.toggle('active', badge.getAttribute('data-badge-kind') === kind); }); } select('status'); badges.forEach((badge) => { badge.addEventListener('click', () => select(badge.getAttribute('data-badge-kind'))); }); } async function loadProject(ownerSlug, slug, ingestToken = '', detailMount) { const mount = detailMount ?? projectDetailProjects; if (!mount) return; const seq = ++projectDetailLoadSeq; const apiPrefix = '/v1/u/' + encodeURIComponent(String(ownerSlug || '')) + '/p/' + encodeURIComponent(String(slug || '')); const data = await api(apiPrefix); if (seq !== projectDetailLoadSeq) return; const scans = data.recentScans || []; const tokenSection = ingestToken ? '

Project ingest token (shown once)

' + ingestToken + '
' : ''; mount.innerHTML = '
' + '
' + data.project.name + '
' + data.project.slug + '
' + '
role: ' + data.project.role + '
' + '
' + tokenSection + '
' + 'status' + 'score' + 'errors' + 'warnings' + 'models' + 'animations' + 'skeletons' + 'materials' + '
' + '

Badge markdown

' + '
Click one of the badges to switch the snippet.
' + '
' +
      '

Recent scans

' + (scans.length === 0 ? '
No scans yet.
' : '' + scans.map((scan) => '').join('') + '
WhenStatusScoreBranchScannedErrorsWarnings
' + new Date(scan.ingestedAt).toLocaleString() + '' + scan.status + '' + (scan.score ?? '-') + '' + scan.branch + '' + scan.scanned + '' + scan.errors + '' + scan.warnings + '
') + '

Ingest endpoint

POST ' +
      API_BASE +
      '/v1/ingest/scan\nAuthorization: Bearer <project ingest token>
'; wireBadgeSnippetSelection(data.project, mount); } function clearProjectForm() { projectNameInput.value = ''; projectSlugInput.value = ''; projectRepoInput.value = ''; state.projectSlugTouched = false; } function selectedGithubRepo() { const index = Number(githubRepoSelect.value); if (!Number.isInteger(index) || index < 0 || index >= state.githubRepos.length) return null; return state.githubRepos[index]; } function syncFormWithSelectedGithubRepo() { const repo = selectedGithubRepo(); if (!repo) return; projectNameInput.value = repo.name; projectSlugInput.value = slugifyProjectName(repo.name); projectRepoInput.value = repo.htmlUrl; state.projectSlugTouched = false; } function renderGithubRepoOptions() { githubRepoSelect.innerHTML = ''; if (state.githubRepos.length === 0) { const option = document.createElement('option'); option.value = '-1'; option.textContent = 'No public repositories found'; githubRepoSelect.appendChild(option); return; } state.githubRepos.forEach((repo, index) => { const option = document.createElement('option'); option.value = String(index); option.textContent = repo.fullName; githubRepoSelect.appendChild(option); }); githubRepoSelect.value = '0'; syncFormWithSelectedGithubRepo(); } async function refreshGithubRepos() { if (!state.user || !state.user.githubLogin) { githubRepoPanel.style.display = 'none'; return; } githubRepoPanel.style.display = 'block'; githubRefreshBtn.disabled = true; try { const login = encodeURIComponent(state.user.githubLogin); const url = 'https://api.github.com/users/' + login + '/repos?per_page=100&sort=updated'; const res = await fetch(url, { headers: { Accept: 'application/vnd.github+json', }, }); if (!res.ok) throw new Error('Could not fetch repositories from GitHub'); const repos = await res.json(); if (!Array.isArray(repos)) throw new Error('Invalid repository list response'); state.githubRepos = repos .filter((repo) => typeof repo?.name === 'string' && typeof repo?.html_url === 'string') .map((repo) => ({ name: repo.name, fullName: repo.full_name || repo.name, htmlUrl: repo.html_url, })); renderGithubRepoOptions(); } catch (err) { state.githubRepos = []; renderGithubRepoOptions(); setFlash(err.message || 'Failed to load GitHub repositories', 'err'); } finally { githubRefreshBtn.disabled = false; } } async function createProject(payload) { const data = await api('/v1/projects', { method: 'POST', body: JSON.stringify(payload) }); setFlash('Project created. Save ingest token now.', 'ok'); clearProjectForm(); await refreshProjects(); await loadIngestTokens(); location.hash = '#/projects/' + encodeURIComponent(String(data.project.ownerSlug || '')) + '/' + encodeURIComponent(String(data.project.slug || '')); applyRoute(); await loadProject(data.project.ownerSlug, data.project.slug, data.ingestToken, projectDetailProjects); return data; } function showTokenReveal(token) { const pre = document.getElementById('tokenRevealValue'); const dlg = document.getElementById('tokenRevealDialog'); if (!pre || !dlg) return; pre.textContent = token; dlg.showModal(); } async function onRegenerateIngestToken(ownerSlug, projectSlug) { if (!ownerSlug || !projectSlug) return; if (!window.confirm('Regenerate all ingest tokens for this project? Existing tokens stop working immediately.')) { return; } try { const data = await api( '/v1/u/' + encodeURIComponent(ownerSlug) + '/p/' + encodeURIComponent(projectSlug) + '/tokens/regenerate', { method: 'POST' } ); showTokenReveal(data.ingestToken); await loadIngestTokens(); } catch (err) { setFlash(err.message || 'Regenerate failed', 'err'); } } async function loadIngestTokens() { const mount = document.getElementById('ingestTokensTable'); if (!mount || !state.token) return; try { const data = await api('/v1/ingest-tokens'); const tokens = data.tokens || []; if (tokens.length === 0) { mount.innerHTML = '

No active ingest tokens. Create a project to get a token (shown once in the project panel), or use Regenerate after opening a project on the Projects page.

'; return; } mount.innerHTML = '' + tokens .map((t) => { const ownerSlug = String(t.ownerSlug || ''); const slug = String(t.projectSlug || ''); const created = t.createdAt ? new Date(Number(t.createdAt)).toLocaleString() : '—'; return ( '' + '' + '' + '' + '' ); }) .join('') + '
ProjectLabelCreatedActions
' + escapeHtml(t.projectName) + '
' + escapeHtml(ownerSlug + '/' + slug) + '
' + escapeHtml(t.name) + '' + escapeHtml(created) + '' + '' + '
'; mount.querySelectorAll('[data-token-regenerate-project]').forEach((btn) => { btn.addEventListener('click', () => { onRegenerateIngestToken( btn.getAttribute('data-token-regenerate-owner'), btn.getAttribute('data-token-regenerate-project') ); }); }); } catch (_err) { mount.innerHTML = '

Could not load ingest tokens.

'; } } function setAuthenticatedUser(user) { state.user = user || null; renderUserCard(); } document.getElementById('registerBtn').addEventListener('click', async () => { try { const payload = { name: document.getElementById('regName').value.trim(), email: document.getElementById('regEmail').value.trim(), password: document.getElementById('regPassword').value, }; const data = await api('/v1/auth/register', { method: 'POST', body: JSON.stringify(payload) }); setToken(data.token); setAuthenticatedUser(data.user); showApp(); await refreshProjects(); await refreshGithubRepos(); setFlash('Account created.'); } catch (err) { setFlash(err.message || 'Register failed', 'err'); } }); document.getElementById('loginBtn').addEventListener('click', async () => { try { const payload = { email: document.getElementById('loginEmail').value.trim(), password: document.getElementById('loginPassword').value, }; const data = await api('/v1/auth/login', { method: 'POST', body: JSON.stringify(payload) }); setToken(data.token); setAuthenticatedUser(data.user); showApp(); await refreshProjects(); await refreshGithubRepos(); setFlash('Logged in.'); } catch (err) { setFlash(err.message || 'Login failed', 'err'); } }); function startGithubLogin() { const path = window.location.pathname; const hash = window.location.hash && window.location.hash !== '#' ? window.location.hash : '#/dashboard'; const returnTo = window.location.origin + path + hash; window.location.assign(API_BASE + '/v1/auth/github/start?return_to=' + encodeURIComponent(returnTo)); } document.getElementById('githubLoginBtn').addEventListener('click', startGithubLogin); if (githubLoginBtnSecondary) { githubLoginBtnSecondary.addEventListener('click', startGithubLogin); } document.getElementById('logoutBtn').addEventListener('click', async () => { try { await api('/v1/auth/logout', { method: 'POST' }); } catch (_err) {} setToken(''); state.githubRepos = []; state.projectsList = []; showAuth(); if (projectDetailProjects) { projectDetailProjects.innerHTML = '
Select a project from the list.
'; } setFlash('Logged out.'); }); projectNameInput.addEventListener('input', () => { if (!state.projectSlugTouched || projectSlugInput.value.trim() === '') { projectSlugInput.value = slugifyProjectName(projectNameInput.value); state.projectSlugTouched = false; } }); projectSlugInput.addEventListener('input', () => { const normalized = slugifyProjectName(projectSlugInput.value); projectSlugInput.value = normalized; const generated = slugifyProjectName(projectNameInput.value); state.projectSlugTouched = normalized !== '' && normalized !== generated; }); githubRepoSelect.addEventListener('change', () => { syncFormWithSelectedGithubRepo(); }); githubRefreshBtn.addEventListener('click', async () => { await refreshGithubRepos(); }); const badgeProjectSelect = document.getElementById('badgeProjectSelect'); if (badgeProjectSelect) { badgeProjectSelect.addEventListener('change', () => { const key = badgeProjectSelect.value; renderBadgeCatalog(key); updateBadgesMarkdownForKey(key); }); } const scansPagePrev = document.getElementById('scansPagePrev'); const scansPageNext = document.getElementById('scansPageNext'); if (scansPagePrev) { scansPagePrev.addEventListener('click', () => { const cur = state.scansListPage || 1; if (cur > 1) loadScansPage(cur - 1); }); } if (scansPageNext) { scansPageNext.addEventListener('click', () => { loadScansPage((state.scansListPage || 1) + 1); }); } createProjectToggleBtn.addEventListener('click', () => { const expanded = createProjectToggleBtn.getAttribute('aria-expanded') === 'true'; setCreateProjectExpanded(!expanded); }); document.getElementById('createProjectBtn').addEventListener('click', async () => { try { await createProject({ name: projectNameInput.value.trim(), slug: projectSlugInput.value.trim().toLowerCase(), repoUrl: projectRepoInput.value.trim() || null, }); } catch (err) { setFlash(err.message || 'Project creation failed', 'err'); } }); const rulesSaveBtn = document.getElementById('rulesSaveBtn'); if (rulesSaveBtn) { rulesSaveBtn.addEventListener('click', async () => { const sel = document.getElementById('rulesProjectSelect'); if (!sel) return; const v = String(sel.value || ''); const i = v.indexOf('/'); const ownerSlug = i >= 0 ? v.slice(0, i) : v; const slug = i >= 0 ? v.slice(i + 1) : ''; let parsed; try { parsed = collectRulesConfigFromForm(); } catch (err) { setRulesError(err.message || 'Invalid form'); return; } try { await api( '/v1/u/' + encodeURIComponent(ownerSlug) + '/p/' + encodeURIComponent(slug) + '/rules', { method: 'PUT', body: JSON.stringify({ config: parsed }) } ); setFlash('Rules saved.'); setRulesError(''); } catch (err) { setRulesError(err.message || 'Save failed'); } }); } const tokenRevealCopy = document.getElementById('tokenRevealCopy'); const tokenRevealClose = document.getElementById('tokenRevealClose'); if (tokenRevealCopy) { tokenRevealCopy.addEventListener('click', async () => { const pre = document.getElementById('tokenRevealValue'); if (!pre || !pre.textContent) return; try { await navigator.clipboard.writeText(pre.textContent); setFlash('Copied to clipboard.'); } catch (_e) { setFlash('Could not copy', 'err'); } }); } if (tokenRevealClose) { tokenRevealClose.addEventListener('click', () => { const dlg = document.getElementById('tokenRevealDialog'); if (dlg) dlg.close(); }); } const scanReportClose = document.getElementById('scanReportClose'); const scanReportCopy = document.getElementById('scanReportCopy'); if (scanReportClose) { scanReportClose.addEventListener('click', () => { const dlg = document.getElementById('scanReportDialog'); if (dlg) dlg.close(); }); } if (scanReportCopy) { scanReportCopy.addEventListener('click', async () => { const pre = document.getElementById('scanReportJson'); if (!pre || !pre.textContent) return; try { await navigator.clipboard.writeText(pre.textContent); setFlash('JSON copied.'); } catch (_e) { setFlash('Could not copy', 'err'); } }); } (async function boot() { consumeOAuthCallbackParams(); setCreateProjectExpanded(false); if (!state.token) { showAuth(); return; } try { const me = await api('/v1/auth/me'); setAuthenticatedUser(me.user); showApp(); } catch (_err) { setToken(''); showAuth(); return; } try { await refreshProjects(); await refreshGithubRepos(); } catch (err) { setFlash(err.message || 'Could not load dashboard data', 'err'); } })(); })();