Files
boc/landvex-quixzoom-cms/public/index.html
T
Bernt 6989a98d75 feat: Passwordless cross-device authentication
- Arkitektur: docs/auth/passwordless-architecture.md
- Backend: iom/quixzoom-auth-service/ (FastAPI + Redis)
- Webb: quixzoom-market-pages/se/login/ (QR-kod + polling)
- App: iom/quixzoom-app/src/features/auth/ (push + deep links)

Flöde: QR-kod → app-godkännande → webb-inloggad
2026-07-07 07:11:50 +00:00

795 lines
21 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Global Content Network — Landvex/QuiXzoom</title>
<style>
:root {
--primary: #2563eb;
--primary-dark: #1d4ed8;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-300: #d1d5db;
--gray-600: #4b5563;
--gray-800: #1f2937;
--gray-900: #111827;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--gray-50);
color: var(--gray-800);
line-height: 1.6;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 0 24px;
}
/* Header */
.header {
background: var(--gray-900);
color: white;
padding: 16px 0;
position: sticky;
top: 0;
z-index: 100;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 20px;
font-weight: 700;
display: flex;
align-items: center;
gap: 12px;
}
.logo-icon {
width: 32px;
height: 32px;
background: var(--primary);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.nav {
display: flex;
gap: 8px;
}
.nav-btn {
padding: 8px 16px;
border: none;
background: transparent;
color: var(--gray-300);
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.nav-btn:hover, .nav-btn.active {
background: rgba(255,255,255,0.1);
color: white;
}
/* Dashboard Grid */
.dashboard {
padding: 32px 0;
}
.page-title {
font-size: 28px;
font-weight: 700;
margin-bottom: 8px;
}
.page-subtitle {
color: var(--gray-600);
margin-bottom: 32px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.stat-card {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.stat-label {
font-size: 14px;
color: var(--gray-600);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 32px;
font-weight: 700;
color: var(--gray-900);
}
.stat-change {
font-size: 13px;
margin-top: 4px;
}
.stat-change.positive { color: var(--success); }
.stat-change.negative { color: var(--danger); }
.stat-change.warning { color: var(--warning); }
/* Content Sections */
.section {
background: white;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.section-title {
font-size: 18px;
font-weight: 600;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover {
background: var(--primary-dark);
}
.btn-secondary {
background: var(--gray-100);
color: var(--gray-800);
}
.btn-secondary:hover {
background: var(--gray-200);
}
/* Tables */
.table-container {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
th {
text-align: left;
padding: 12px;
font-weight: 600;
color: var(--gray-600);
border-bottom: 2px solid var(--gray-200);
white-space: nowrap;
}
td {
padding: 12px;
border-bottom: 1px solid var(--gray-100);
}
tr:hover {
background: var(--gray-50);
}
/* Status badges */
.badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
}
.badge-published {
background: #d1fae5;
color: #065f46;
}
.badge-draft {
background: var(--gray-100);
color: var(--gray-600);
}
.badge-review {
background: #fef3c7;
color: #92400e;
}
.badge-outdated {
background: #fee2e2;
color: #991b1b;
}
.badge-ai {
background: #dbeafe;
color: #1e40af;
}
/* Progress bars */
.progress-bar {
width: 100%;
height: 8px;
background: var(--gray-200);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s;
}
.progress-fill.high { background: var(--success); }
.progress-fill.medium { background: var(--warning); }
.progress-fill.low { background: var(--danger); }
/* Site cards */
.sites-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.site-card {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
border-left: 4px solid var(--primary);
}
.site-card.tier-global {
border-left-color: var(--primary);
}
.site-card.tier-national {
border-left-color: var(--success);
}
.site-card.tier-regional {
border-left-color: var(--warning);
}
.site-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 12px;
}
.site-domain {
font-weight: 600;
font-size: 16px;
}
.site-tier {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 2px 8px;
border-radius: 4px;
background: var(--gray-100);
color: var(--gray-600);
}
.site-languages {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.lang-tag {
font-size: 12px;
padding: 2px 8px;
background: var(--gray-100);
border-radius: 4px;
color: var(--gray-600);
}
/* Workflow steps */
.workflow {
display: flex;
gap: 8px;
margin-top: 16px;
}
.workflow-step {
flex: 1;
text-align: center;
padding: 12px;
border-radius: 8px;
font-size: 12px;
}
.workflow-step.pending {
background: var(--gray-100);
color: var(--gray-600);
}
.workflow-step.active {
background: #dbeafe;
color: var(--primary);
font-weight: 600;
}
.workflow-step.completed {
background: #d1fae5;
color: var(--success);
}
/* Responsive */
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: 1fr;
}
.sites-grid {
grid-template-columns: 1fr;
}
.nav {
display: none;
}
}
/* Loading */
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid var(--gray-200);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.hidden {
display: none !important;
}
</style>
</head>
<body>
<header class="header">
<div class="container header-content">
<div class="logo">
<div class="logo-icon">🌐</div>
<span>Global Content Network</span>
</div>
<nav class="nav">
<button class="nav-btn active" data-view="dashboard">Dashboard</button>
<button class="nav-btn" data-view="sites">Sites</button>
<button class="nav-btn" data-view="content">Content</button>
<button class="nav-btn" data-view="translations">Translations</button>
<button class="nav-btn" data-view="glossary">Glossary</button>
</nav>
</div>
</header>
<main class="dashboard container">
<!-- Dashboard View -->
<div id="dashboard-view">
<h1 class="page-title">Dashboard</h1>
<p class="page-subtitle">Overview of your global content network</p>
<div class="stats-grid" id="stats-grid">
<div class="stat-card">
<div class="stat-label">Total Pages</div>
<div class="stat-value" id="stat-pages">-</div>
<div class="stat-change">Across all brands</div>
</div>
<div class="stat-card">
<div class="stat-label">Translations</div>
<div class="stat-value" id="stat-translations">-</div>
<div class="stat-change">Total localized versions</div>
</div>
<div class="stat-card">
<div class="stat-label">Outdated</div>
<div class="stat-value" id="stat-outdated">-</div>
<div class="stat-change warning">Need attention</div>
</div>
<div class="stat-card">
<div class="stat-label">Languages</div>
<div class="stat-value" id="stat-languages">-</div>
<div class="stat-change positive">Active locales</div>
</div>
</div>
<div class="section">
<div class="section-header">
<h2 class="section-title">Translation Status by Language</h2>
</div>
<div id="language-coverage">
<div class="loading"></div>
</div>
</div>
<div class="section">
<div class="section-header">
<h2 class="section-title">Recent Activity</h2>
</div>
<div class="table-container">
<table id="activity-table">
<thead>
<tr>
<th>Time</th>
<th>Action</th>
<th>Entity</th>
<th>Language</th>
<th>Domain</th>
</tr>
</thead>
<tbody id="activity-body">
<tr><td colspan="5"><div class="loading"></div></td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Sites View -->
<div id="sites-view" class="hidden">
<h1 class="page-title">Sites</h1>
<p class="page-subtitle">All sites in the global network</p>
<div class="sites-grid" id="sites-grid">
<div class="loading"></div>
</div>
</div>
<!-- Content View -->
<div id="content-view" class="hidden">
<h1 class="page-title">Content</h1>
<p class="page-subtitle">Manage source content</p>
<div class="section">
<div class="section-header">
<h2 class="section-title">Pages</h2>
<button class="btn btn-primary" onclick="showCreatePage()">+ New Page</button>
</div>
<div class="table-container">
<table id="pages-table">
<thead>
<tr>
<th>Title</th>
<th>Brand</th>
<th>Type</th>
<th>Status</th>
<th>Version</th>
<th>Updated</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="pages-body">
<tr><td colspan="7"><div class="loading"></div></td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Translations View -->
<div id="translations-view" class="hidden">
<h1 class="page-title">Translations</h1>
<p class="page-subtitle">Manage localized content</p>
<div class="section">
<div class="section-header">
<h2 class="section-title">Translation Queue</h2>
</div>
<div class="table-container">
<table id="jobs-table">
<thead>
<tr>
<th>Page</th>
<th>Language</th>
<th>Domain</th>
<th>Status</th>
<th>Priority</th>
<th>Created</th>
</tr>
</thead>
<tbody id="jobs-body">
<tr><td colspan="6"><div class="loading"></div></td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Glossary View -->
<div id="glossary-view" class="hidden">
<h1 class="page-title">Glossary</h1>
<p class="page-subtitle">Approved terminology across languages</p>
<div class="section">
<div class="section-header">
<h2 class="section-title">Terms</h2>
<button class="btn btn-primary" onclick="showCreateTerm()">+ New Term</button>
</div>
<div class="table-container">
<table id="glossary-table">
<thead>
<tr>
<th>Term</th>
<th>Brand</th>
<th>Category</th>
<th>Status</th>
<th>Translations</th>
</tr>
</thead>
<tbody id="glossary-body">
<tr><td colspan="5"><div class="loading"></div></td></tr>
</tbody>
</table>
</div>
</div>
</div>
</main>
<script>
// State
let currentView = 'dashboard';
const API_BASE = '/api';
// Navigation
document.querySelectorAll('.nav-btn').forEach(btn => {
btn.addEventListener('click', () => {
const view = btn.dataset.view;
switchView(view);
});
});
function switchView(view) {
currentView = view;
// Update nav
document.querySelectorAll('.nav-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.view === view);
});
// Show/hide views
document.querySelectorAll('[id$="-view"]').forEach(el => {
el.classList.toggle('hidden', !el.id.startsWith(view));
});
// Load data
if (view === 'dashboard') loadDashboard();
if (view === 'sites') loadSites();
if (view === 'content') loadContent();
if (view === 'translations') loadTranslations();
if (view === 'glossary') loadGlossary();
}
// API helpers
async function api(endpoint) {
const res = await fetch(`${API_BASE}${endpoint}`);
return res.json();
}
// Load dashboard data
async function loadDashboard() {
try {
const analytics = await api('/analytics/translation-status');
document.getElementById('stat-pages').textContent = analytics.totalPages;
document.getElementById('stat-translations').textContent = analytics.totalTranslations;
document.getElementById('stat-outdated').textContent = analytics.outdatedPages;
document.getElementById('stat-languages').textContent = Object.keys(analytics.coverage.byLanguage).length;
// Language coverage
const coverageHtml = Object.entries(analytics.coverage.byLanguage).map(([lang, data]) => {
const pct = Math.round((data.published / data.total) * 100) || 0;
const color = pct >= 80 ? 'high' : pct >= 50 ? 'medium' : 'low';
return `
<div style="margin-bottom: 16px;">
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span style="font-weight: 500;">${lang}</span>
<span style="color: var(--gray-600);">${data.published}/${data.total} published</span>
</div>
<div class="progress-bar">
<div class="progress-fill ${color}" style="width: ${pct}%"></div>
</div>
</div>
`;
}).join('');
document.getElementById('language-coverage').innerHTML = coverageHtml || '<p style="color: var(--gray-600);">No translations yet</p>';
// Recent activity
const changelog = await api('/changelog?limit=10');
const activityBody = document.getElementById('activity-body');
activityBody.innerHTML = changelog.map(entry => `
<tr>
<td>${new Date(entry.performedAt).toLocaleString()}</td>
<td><span class="badge badge-${entry.action === 'published' ? 'published' : 'draft'}">${entry.action}</span></td>
<td>${entry.entityType} #${entry.entityId}</td>
<td>${entry.languageCode || '-'}</td>
<td>${entry.domain || '-'}</td>
</tr>
`).join('') || '<tr><td colspan="5" style="text-align: center; color: var(--gray-600);">No activity yet</td></tr>';
} catch (err) {
console.error('Failed to load dashboard:', err);
}
}
// Load sites
async function loadSites() {
try {
const sites = await api('/sites');
const grid = document.getElementById('sites-grid');
grid.innerHTML = sites.map(site => `
<div class="site-card tier-${site.tier}">
<div class="site-header">
<div>
<div class="site-domain">${site.domain}</div>
<div style="font-size: 13px; color: var(--gray-600); margin-top: 2px;">${site.brand}</div>
</div>
<span class="site-tier">${site.tier}</span>
</div>
<div style="font-size: 13px; color: var(--gray-600);">
Default: ${site.defaultLanguage}
</div>
<div class="site-languages">
${site.languages.map(l => `<span class="lang-tag">${l.code}</span>`).join('')}
</div>
<div style="margin-top: 12px; font-size: 13px;">
${Object.entries(site.features).filter(([_, v]) => v).map(([k]) => k).join(', ')}
</div>
</div>
`).join('');
} catch (err) {
console.error('Failed to load sites:', err);
}
}
// Load content
async function loadContent() {
try {
const data = await api('/pages?limit=50');
const tbody = document.getElementById('pages-body');
tbody.innerHTML = data.pages.map(page => `
<tr>
<td><strong>${page.title}</strong><br><span style="font-size: 12px; color: var(--gray-600);">${page.slug}</span></td>
<td><span class="badge badge-${page.brand === 'landvex' ? 'draft' : 'ai'}">${page.brand}</span></td>
<td>${page.contentType}</td>
<td><span class="badge badge-${page.status}">${page.status}</span></td>
<td>v${page.version}</td>
<td>${new Date(page.updatedAt).toLocaleDateString()}</td>
<td>
<button class="btn btn-secondary" style="padding: 4px 8px; font-size: 12px;" onclick="viewPage(${page.id})">View</button>
${page.needsTranslationUpdate ? '<span style="color: var(--danger); margin-left: 8px;">⚠️</span>' : ''}
</td>
</tr>
`).join('') || '<tr><td colspan="7" style="text-align: center;">No pages yet</td></tr>';
} catch (err) {
console.error('Failed to load content:', err);
}
}
// Load translations
async function loadTranslations() {
try {
const jobs = await api('/jobs?limit=50');
const tbody = document.getElementById('jobs-body');
tbody.innerHTML = jobs.map(job => `
<tr>
<td>Page #${job.pageId}</td>
<td>${job.targetLanguage}</td>
<td>${job.targetDomain}</td>
<td><span class="badge badge-${job.status === 'completed' ? 'published' : job.status === 'failed' ? 'outdated' : 'review'}">${job.status}</span></td>
<td>${job.priority}</td>
<td>${new Date(job.createdAt).toLocaleDateString()}</td>
</tr>
`).join('') || '<tr><td colspan="6" style="text-align: center;">No jobs in queue</td></tr>';
} catch (err) {
console.error('Failed to load translations:', err);
}
}
// Load glossary
async function loadGlossary() {
try {
const terms = await api('/glossary');
const tbody = document.getElementById('glossary-body');
tbody.innerHTML = terms.map(term => {
const translations = term.translations || {};
const translationCount = Object.keys(translations).length;
return `
<tr>
<td><strong>${term.term}</strong></td>
<td><span class="badge badge-${term.brand === 'both' ? 'published' : 'draft'}">${term.brand}</span></td>
<td>${term.category}</td>
<td><span class="badge badge-${term.status}">${term.status}</span></td>
<td>${translationCount} languages</td>
</tr>
`;
}).join('') || '<tr><td colspan="5" style="text-align: center;">No glossary entries yet</td></tr>';
} catch (err) {
console.error('Failed to load glossary:', err);
}
}
// Actions
function showCreatePage() {
alert('Create page functionality - implement modal form');
}
function showCreateTerm() {
alert('Create term functionality - implement modal form');
}
function viewPage(id) {
alert(`View page ${id} - implement detail view`);
}
// Initial load
loadDashboard();
</script>
</body>
</html>