SysOps: approved_backup — 2026-08-16 11:01 UTC
This commit is contained in:
+196
-48
@@ -12,22 +12,30 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" />
|
||||
<link rel="manifest" href="/static/manifest.json?v=2" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="manifest" href="/static/manifest.json?v=3" />
|
||||
<meta name="application-name" content="Foodlinkk" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<link rel="icon" href="/static/icons/icon-192.png" type="image/png" />
|
||||
<link rel="icon" href="/static/icons/app-icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/static/icons/app-icon.svg" />
|
||||
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png" sizes="180x180" />
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png" sizes="192x192" />
|
||||
<link rel="stylesheet" href="/static/css/palantir-theme.css?v=4" />
|
||||
<link rel="stylesheet" href="/static/css/pulse-theme.css?v=2" />
|
||||
<link rel="stylesheet" href="/static/css/topnav-neo.css?v=6" />
|
||||
<link rel="stylesheet" href="/static/css/vertical-tabs.css?v=9" />
|
||||
<link rel="stylesheet" href="/static/css/mobile.css?v=5" />
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=33" />
|
||||
<script defer src="/static/js/herman-assistant.js?v=16"></script>
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=34" />
|
||||
<script defer src="/static/js/herman-assistant.js?v=17"></script>
|
||||
<script defer src="/static/js/agents-warroom.js?v=4"></script>
|
||||
<script defer src="/static/js/alpine.min.js?v=2"></script>
|
||||
<script defer src="/static/js/htmx.min.js?v=2"></script>
|
||||
{% block extra_head %}{% endblock %}
|
||||
<link rel="stylesheet" href="/static/css/app-sidebar-neo.css?v=11" />
|
||||
<link rel="stylesheet" href="/static/css/app-sidebar-neo.css?v=12" />
|
||||
<link rel="stylesheet" href="/static/css/cockpit-typography.css?v=1" />
|
||||
<link rel="stylesheet" href="/static/css/herman-assistant.css?v=16" />
|
||||
<!-- mobile last: wins over sidebar/dashboard cascade -->
|
||||
<link rel="stylesheet" href="/static/css/mobile.css?v=7" />
|
||||
<link rel="stylesheet" href="/static/css/user-avatar.css?v=3" />
|
||||
</head>
|
||||
<body x-data="appShell()" x-init="initApp()" :class="{ 'nav-open': navOpen, 'drawer-open': drawerOpen, 'brand-cucina': brand === 'cucina', 'brand-foodlinkk': brand === 'foodlinkk' }">
|
||||
<div class="app-shell">
|
||||
@@ -51,36 +59,36 @@
|
||||
<div class="persona-nav">
|
||||
<div class="app-sidebar-group" x-show="brand === 'foodlinkk'">
|
||||
<div class="app-sidebar-label">Foodlinkk B2B</div>
|
||||
<a href="/export-intel" class="nav-pill nav-pill-wereldexport {% if request.url.path.startswith('/export-intel') %}active{% endif %}"><span class="sidebar-nav-icon">🌍</span><span class="sidebar-nav-label">Wereldexport</span></a>
|
||||
<a href="/clients" class="nav-pill nav-pill-clients {% if request.url.path.startswith('/clients') %}active{% endif %}"><span class="sidebar-nav-icon">👥</span><span class="sidebar-nav-label" data-i18n="nav.clients">Klanten</span></a>
|
||||
<a href="/deals" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/deals') %}active{% endif %}"><span class="sidebar-nav-icon">🤝</span><span class="sidebar-nav-label" data-i18n="nav.deals">Deals</span></a>
|
||||
<a href="/products" class="nav-pill nav-pill-products {% if request.url.path.startswith('/products') %}active{% endif %}"><span class="sidebar-nav-icon">📦</span><span class="sidebar-nav-label" data-i18n="nav.products">Producten</span></a>
|
||||
<a href="/suppliers" class="nav-pill nav-pill-suppliers {% if request.url.path.startswith('/suppliers') %}active{% endif %}"><span class="sidebar-nav-icon">🏭</span><span class="sidebar-nav-label" data-i18n="nav.suppliers">Leveranciers</span></a>
|
||||
<a href="/reports" class="nav-pill nav-pill-reports {% if request.url.path.startswith('/reports') %}active{% endif %}"><span class="sidebar-nav-icon">📊</span><span class="sidebar-nav-label" data-i18n="nav.reports">Rapporten</span></a>
|
||||
<a href="/export-intel" data-perm="export" class="nav-pill nav-pill-wereldexport {% if request.url.path.startswith('/export-intel') %}active{% endif %}"><span class="sidebar-nav-icon">🌍</span><span class="sidebar-nav-label">Wereldexport</span></a>
|
||||
<a href="/clients" data-perm="clients" class="nav-pill nav-pill-clients {% if request.url.path.startswith('/clients') %}active{% endif %}"><span class="sidebar-nav-icon">👥</span><span class="sidebar-nav-label" data-i18n="nav.clients">Klanten</span></a>
|
||||
<a href="/deals" data-perm="deals" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/deals') %}active{% endif %}"><span class="sidebar-nav-icon">🤝</span><span class="sidebar-nav-label" data-i18n="nav.deals">Deals</span></a>
|
||||
<a href="/products" data-perm="products" class="nav-pill nav-pill-products {% if request.url.path.startswith('/products') %}active{% endif %}"><span class="sidebar-nav-icon">📦</span><span class="sidebar-nav-label" data-i18n="nav.products">Producten</span></a>
|
||||
<a href="/suppliers" data-perm="suppliers" class="nav-pill nav-pill-suppliers {% if request.url.path.startswith('/suppliers') %}active{% endif %}"><span class="sidebar-nav-icon">🏭</span><span class="sidebar-nav-label" data-i18n="nav.suppliers">Leveranciers</span></a>
|
||||
<a href="/reports" data-perm="reports" class="nav-pill nav-pill-reports {% if request.url.path.startswith('/reports') %}active{% endif %}"><span class="sidebar-nav-icon">📊</span><span class="sidebar-nav-label" data-i18n="nav.reports">Rapporten</span></a>
|
||||
</div>
|
||||
|
||||
<div class="app-sidebar-group" x-show="brand === 'cucina'">
|
||||
<div class="app-sidebar-label">Cucina B2C</div>
|
||||
<a href="/" class="nav-pill nav-pill-herman {% if request.url.path == '/' %}active{% endif %}"><span class="sidebar-nav-icon">🏠</span><span class="sidebar-nav-label" data-i18n="nav.ceo_home">Overzicht</span></a>
|
||||
<a href="/revenue-cockpit" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/revenue-cockpit') %}active{% endif %}"><span class="sidebar-nav-icon">💰</span><span class="sidebar-nav-label" data-i18n="nav.revenue_cockpit">Revenue Cockpit</span></a>
|
||||
<a href="/retail" class="nav-pill nav-pill-retail {% if request.url.path.startswith('/retail') %}active{% endif %}"><span class="sidebar-nav-icon">🛒</span><span class="sidebar-nav-label" data-i18n="nav.retail">Retail NL</span></a>
|
||||
<a href="/marketing" class="nav-pill nav-pill-marketing {% if request.url.path.startswith('/marketing') %}active{% endif %}"><span class="sidebar-nav-icon">📣</span><span class="sidebar-nav-label" data-i18n="nav.marketing">Marketing</span></a>
|
||||
<a href="/packaging" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/packaging') %}active{% endif %}"><span class="sidebar-nav-icon">📦</span><span class="sidebar-nav-label" data-i18n="nav.packaging">Packaging</span></a>
|
||||
<a href="/beurs" class="nav-pill nav-pill-beurs {% if request.url.path.startswith('/beurs') %}active{% endif %}"><span class="sidebar-nav-icon">🎪</span><span class="sidebar-nav-label" data-i18n="nav.beurs">Beurs Live</span></a>
|
||||
<a href="/documents" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/documents') %}active{% endif %}"><span class="sidebar-nav-icon">📄</span><span class="sidebar-nav-label" data-i18n="nav.documents">Documenten</span></a>
|
||||
<a href="/projects" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/projects') %}active{% endif %}"><span class="sidebar-nav-icon">📁</span><span class="sidebar-nav-label" data-i18n="nav.projects">Projecten</span></a>
|
||||
<a href="/hermes" class="nav-pill nav-pill-telegram {% if request.url.path.startswith('/hermes') %}active{% endif %}"><span class="sidebar-nav-icon">✈️</span><span class="sidebar-nav-label" data-i18n="nav.telegram">Telegram</span></a>
|
||||
<a href="/analytics" class="nav-pill nav-pill-analytics {% if request.url.path.startswith('/analytics') %}active{% endif %}"><span class="sidebar-nav-icon">📈</span><span class="sidebar-nav-label" data-i18n="nav.analytics">Analytics</span></a>
|
||||
<a href="/" data-perm="dashboard" class="nav-pill nav-pill-herman {% if request.url.path == '/' %}active{% endif %}"><span class="sidebar-nav-icon">🏠</span><span class="sidebar-nav-label" data-i18n="nav.ceo_home">Overzicht</span></a>
|
||||
<a href="/revenue-cockpit" data-perm="revenue" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/revenue-cockpit') %}active{% endif %}"><span class="sidebar-nav-icon">💰</span><span class="sidebar-nav-label" data-i18n="nav.revenue_cockpit">Revenue Cockpit</span></a>
|
||||
<a href="/retail" data-perm="retail" class="nav-pill nav-pill-retail {% if request.url.path.startswith('/retail') %}active{% endif %}"><span class="sidebar-nav-icon">🛒</span><span class="sidebar-nav-label" data-i18n="nav.retail">Retail NL</span></a>
|
||||
<a href="/marketing" data-perm="marketing" class="nav-pill nav-pill-marketing {% if request.url.path.startswith('/marketing') %}active{% endif %}"><span class="sidebar-nav-icon">📣</span><span class="sidebar-nav-label" data-i18n="nav.marketing">Marketing</span></a>
|
||||
<a href="/packaging" data-perm="packaging" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/packaging') %}active{% endif %}"><span class="sidebar-nav-icon">📦</span><span class="sidebar-nav-label" data-i18n="nav.packaging">Packaging</span></a>
|
||||
<a href="/beurs" data-perm="beurs" class="nav-pill nav-pill-beurs {% if request.url.path.startswith('/beurs') %}active{% endif %}"><span class="sidebar-nav-icon">🎪</span><span class="sidebar-nav-label" data-i18n="nav.beurs">Beurs Live</span></a>
|
||||
<a href="/documents" data-perm="documents" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/documents') %}active{% endif %}"><span class="sidebar-nav-icon">📄</span><span class="sidebar-nav-label" data-i18n="nav.documents">Documenten</span></a>
|
||||
<a href="/projects" data-perm="projects" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/projects') %}active{% endif %}"><span class="sidebar-nav-icon">📁</span><span class="sidebar-nav-label" data-i18n="nav.projects">Projecten</span></a>
|
||||
<a href="/hermes" data-perm="hermes" class="nav-pill nav-pill-telegram {% if request.url.path.startswith('/hermes') %}active{% endif %}"><span class="sidebar-nav-icon">✈️</span><span class="sidebar-nav-label" data-i18n="nav.telegram">Telegram</span></a>
|
||||
<a href="/analytics" data-perm="analytics" class="nav-pill nav-pill-analytics {% if request.url.path.startswith('/analytics') %}active{% endif %}"><span class="sidebar-nav-icon">📈</span><span class="sidebar-nav-label" data-i18n="nav.analytics">Analytics</span></a>
|
||||
</div>
|
||||
|
||||
<div class="app-sidebar-group">
|
||||
<div class="app-sidebar-label" data-i18n="nav.platform">Platform</div>
|
||||
<a href="/ops" class="nav-pill nav-pill-ops {% if request.url.path.startswith('/ops') %}active{% endif %}"><span class="sidebar-nav-icon">⚙️</span><span class="sidebar-nav-label" data-i18n="nav.ops">IT Ops</span></a>
|
||||
<a href="/agents" class="nav-pill nav-pill-agents {% if request.url.path.startswith('/agents') %}active{% endif %}"><span class="sidebar-nav-icon">🤖</span><span class="sidebar-nav-label" data-i18n="nav.agents">Agents</span></a>
|
||||
<a href="/browser" class="nav-pill nav-pill-browser {% if request.url.path.startswith('/browser') or request.url.path.startswith('/monitor') %}active{% endif %}"><span class="sidebar-nav-icon">🌐</span><span class="sidebar-nav-label" data-i18n="nav.browser">Browser</span></a>
|
||||
<a href="/studio" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/studio') %}active{% endif %}"><span class="sidebar-nav-icon">🎨</span><span class="sidebar-nav-label" data-i18n="nav.studio">Studio</span></a>
|
||||
<a href="/voice" class="nav-pill nav-pill-voice {% if request.url.path.startswith('/voice') %}active{% endif %}"><span class="sidebar-nav-icon">🎙️</span><span class="sidebar-nav-label" data-i18n="nav.voice">Voice</span></a>
|
||||
<a href="/settings" class="nav-pill nav-pill-settings {% if request.url.path.startswith('/settings') %}active{% endif %}"><span class="sidebar-nav-icon">🔧</span><span class="sidebar-nav-label" data-i18n="nav.settings">Instellingen</span></a>
|
||||
<a href="/ops" data-perm="ops" class="nav-pill nav-pill-ops {% if request.url.path.startswith('/ops') %}active{% endif %}"><span class="sidebar-nav-icon">⚙️</span><span class="sidebar-nav-label" data-i18n="nav.ops">IT Ops</span></a>
|
||||
<a href="/agents" data-perm="agents" class="nav-pill nav-pill-agents {% if request.url.path.startswith('/agents') %}active{% endif %}"><span class="sidebar-nav-icon">🤖</span><span class="sidebar-nav-label" data-i18n="nav.agents">Agents</span></a>
|
||||
<a href="/browser" data-perm="browser" class="nav-pill nav-pill-browser {% if request.url.path.startswith('/browser') or request.url.path.startswith('/monitor') %}active{% endif %}"><span class="sidebar-nav-icon">🌐</span><span class="sidebar-nav-label" data-i18n="nav.browser">Browser</span></a>
|
||||
<a href="/studio" data-perm="studio" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/studio') %}active{% endif %}"><span class="sidebar-nav-icon">🎨</span><span class="sidebar-nav-label" data-i18n="nav.studio">Studio</span></a>
|
||||
<a href="/voice" data-perm="voice" class="nav-pill nav-pill-voice {% if request.url.path.startswith('/voice') %}active{% endif %}"><span class="sidebar-nav-icon">🎙️</span><span class="sidebar-nav-label" data-i18n="nav.voice">Voice</span></a>
|
||||
<a href="/settings" data-perm="settings" class="nav-pill nav-pill-settings {% if request.url.path.startswith('/settings') %}active{% endif %}"><span class="sidebar-nav-icon">🔧</span><span class="sidebar-nav-label" data-i18n="nav.settings">Instellingen</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -100,15 +108,39 @@
|
||||
<option value="en">EN</option>
|
||||
</select>
|
||||
<div class="app-clock"><strong x-text="clockDate">—</strong> · <span x-text="clockTime">—</span></div>
|
||||
{% set cu = request.state.user if request is defined else none %}
|
||||
<div class="app-user-chip"{% if not cu %} x-show="authUser" x-cloak{% endif %}
|
||||
x-data="userChip()" x-init="bootAvatar()">
|
||||
<button type="button" class="user-avatar-btn" title="Profielfoto wijzigen" @click="pickAvatar()">
|
||||
{% if cu and cu.avatar_url %}
|
||||
<img class="user-avatar-img" src="{{ cu.avatar_url }}"
|
||||
:src="photoUrl || '{{ cu.avatar_url }}'" alt="" width="36" height="36" />
|
||||
{% else %}
|
||||
<img class="user-avatar-img" x-show="photoUrl" :src="photoUrl" alt="" width="36" height="36" />
|
||||
<span class="user-avatar-fallback" x-show="!photoUrl" x-text="initials">?</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
<input type="file" x-ref="avatarFile" accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
class="user-avatar-input" style="display:none!important;position:absolute;width:0;height:0;opacity:0" tabindex="-1" aria-hidden="true" @change="onFile($event)" />
|
||||
<div class="user-avatar-meta">
|
||||
<span class="user-avatar-name" x-text="name">{% if cu %}{{ cu.display_name or cu.username }}{% endif %}</span>
|
||||
<span class="user-avatar-role" x-text="role">{% if cu %}{{ cu.role }}{% endif %}</span>
|
||||
</div>
|
||||
<a class="btn btn-sm btn-secondary user-logout-btn" href="/logout">Uitloggen</a>
|
||||
</div>
|
||||
</header>
|
||||
<main class="main-topnav">{% block content %}{% endblock %}</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pwa-install-bar" x-show="showInstall" x-cloak>
|
||||
<p>📱 Installeer Foodlinkk als app op je telefoon</p>
|
||||
<button type="button" class="btn btn-sm btn-pulse-green" @click="installPwa()">Installeren</button>
|
||||
<button type="button" class="btn btn-sm" @click="showInstall=false">Later</button>
|
||||
<div class="pwa-install-bar" x-show="showInstall" x-cloak
|
||||
:class="{ 'is-ios': isIos, 'is-android': isAndroid }"
|
||||
style="display:none">
|
||||
<p>Installeer Foodlinkk op je beginscherm</p>
|
||||
<p class="pwa-install-hint is-ios-hint">iPhone/iPad (Safari): Deel ↗ → <strong>Zet op beginscherm</strong>. Open via <strong>http://10.4.7.18:8610</strong> — geen certificaatmelding.</p>
|
||||
<p class="pwa-install-hint is-android-hint">Android (Chrome): menu ⋮ → <strong>Toevoegen aan startscherm</strong>. Open via <strong>http://10.4.7.18:8610</strong>.</p>
|
||||
<button type="button" class="btn btn-sm btn-pulse-green" @click="installPwa()">Uitleg</button>
|
||||
<button type="button" class="btn btn-sm" @click="dismissPwa()">Later</button>
|
||||
</div>
|
||||
|
||||
<!-- Herman chat — altijd vast onderin zichtbaar -->
|
||||
@@ -136,6 +168,8 @@
|
||||
<div class="ha-head-actions">
|
||||
<a x-show="!micSecureOk()" class="ha-text-btn" :href="httpsChatUrl()" target="_blank" rel="noopener" title="Audio/microfoon in HTTPS-tab">Audio</a>
|
||||
<button type="button" class="ha-text-btn" @click="clearChat()" title="Nieuwe chat" :disabled="busy || (!turns.length && !manualText)">Wissen</button>
|
||||
<button type="button" class="ha-text-btn" @click="openWarRoomPopup()" title="War Room op tweede scherm">War Room</button>
|
||||
<a href="/agents?tab=warroom">Agents</a>
|
||||
<a href="/herman">Volledig</a>
|
||||
<button type="button" @click="open = false" aria-label="Sluiten">×</button>
|
||||
</div>
|
||||
@@ -224,7 +258,7 @@
|
||||
</div>
|
||||
|
||||
<script src="/static/js/i18n.js?v=5"></script>
|
||||
<script src="/static/js/cockpit.js?v=2"></script>
|
||||
<script src="/static/js/cockpit.js?v=3"></script>
|
||||
<script src="/static/js/viz-engine.js?v=3"></script>
|
||||
<script src="/static/js/global-viz.js?v=1"></script>
|
||||
<script src="/static/js/live-pulse.js?v=3"></script>
|
||||
@@ -250,6 +284,47 @@
|
||||
/* legacy persona helper → brand */
|
||||
window.FoodlinkkPersona = window.FoodlinkkBrand;
|
||||
|
||||
function userChip() {
|
||||
return {
|
||||
photoUrl: {% if cu and cu.avatar_url %}'{{ cu.avatar_url }}'{% else %}null{% endif %},
|
||||
name: {% if cu %}'{{ (cu.display_name or cu.username)|e }}'{% else %}''{% endif %},
|
||||
role: {% if cu %}'{{ cu.role }}'{% else %}''{% endif %},
|
||||
initials: {% if cu %}'{{ ((cu.display_name or cu.username or '?')[0:1])|upper }}'{% else %}'?'{% endif %},
|
||||
bootAvatar() {
|
||||
// sync from parent shell when /api/auth/me returns
|
||||
window.addEventListener('flk-auth-ready', (ev) => {
|
||||
const u = ev.detail;
|
||||
if (!u) return;
|
||||
this.photoUrl = u.avatar_url || null;
|
||||
this.name = u.display_name || u.username || '';
|
||||
this.role = u.role || '';
|
||||
const n = (this.name || '?').trim();
|
||||
const parts = n.split(/\s+/).filter(Boolean);
|
||||
this.initials = parts.length >= 2 ? (parts[0][0] + parts[1][0]).toUpperCase() : n.slice(0, 1).toUpperCase();
|
||||
});
|
||||
},
|
||||
pickAvatar() { this.$refs.avatarFile && this.$refs.avatarFile.click(); },
|
||||
async onFile(ev) {
|
||||
const file = ev.target.files && ev.target.files[0];
|
||||
ev.target.value = '';
|
||||
if (!file) return;
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
try {
|
||||
const res = await fetch('/api/auth/me/avatar', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.detail || 'Upload mislukt');
|
||||
this.photoUrl = (data.avatar_url || (data.user && data.user.avatar_url)) + '?t=' + Date.now();
|
||||
if (window.Cockpit && Cockpit.toast) Cockpit.toast('Profielfoto bijgewerkt', 'success');
|
||||
window.dispatchEvent(new CustomEvent('flk-auth-ready', { detail: data.user }));
|
||||
} catch (e) {
|
||||
if (window.Cockpit && Cockpit.toast) Cockpit.toast(e.message || 'Upload mislukt', 'error');
|
||||
else alert(e.message || 'Upload mislukt');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function appShell() {
|
||||
return {
|
||||
locale: 'nl',
|
||||
@@ -258,21 +333,24 @@
|
||||
navOpen: false,
|
||||
sidebarWidth: 280,
|
||||
showInstall: false,
|
||||
authUser: null,
|
||||
authPages: [],
|
||||
_deferredPrompt: null,
|
||||
clockDate: '—',
|
||||
clockTime: '—',
|
||||
_offset: 0,
|
||||
isIos: false,
|
||||
async initApp() {
|
||||
this.brand = window.FoodlinkkBrand.detect(location.pathname);
|
||||
if (!localStorage.getItem('flk-cache-v4')) {
|
||||
if ('serviceWorker' in navigator) {
|
||||
const regs = await navigator.serviceWorker.getRegistrations();
|
||||
await Promise.all(regs.map((r) => r.unregister()));
|
||||
}
|
||||
this.isIos = /iPhone|iPad|iPod/i.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
// One-time migration off legacy no-op SW
|
||||
if (!localStorage.getItem('flk-pwa-v6')) {
|
||||
if (window.caches) {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((k) => caches.delete(k)));
|
||||
}
|
||||
localStorage.setItem('flk-pwa-v6', '1');
|
||||
localStorage.setItem('flk-cache-v4', '1');
|
||||
}
|
||||
if (window.I18n) await window.I18n.init();
|
||||
@@ -280,6 +358,59 @@
|
||||
this.initSidebarWidth();
|
||||
this.initClock();
|
||||
this.initPwa();
|
||||
await this.initAuth();
|
||||
},
|
||||
async initAuth() {
|
||||
try {
|
||||
const r = await fetch('/api/auth/me', { credentials: 'same-origin' });
|
||||
if (r.status === 401) {
|
||||
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
|
||||
return;
|
||||
}
|
||||
if (!r.ok) return;
|
||||
const data = await r.json();
|
||||
this.authUser = Object.assign({}, data.user || null, { _avTick: Date.now() });
|
||||
this.authPages = (data.user && data.user.permissions && data.user.permissions.pages) || [];
|
||||
this.applyNavPermissions();
|
||||
window.dispatchEvent(new CustomEvent('flk-auth-ready', { detail: this.authUser }));
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
canPage(key) {
|
||||
if (!this.authUser) return true;
|
||||
if (this.authUser.role === 'admin') return true;
|
||||
return this.authPages.indexOf(key) !== -1;
|
||||
},
|
||||
applyNavPermissions() {
|
||||
if (!this.authUser || this.authUser.role === 'admin') return;
|
||||
document.querySelectorAll('.app-sidebar a.nav-pill[data-perm]').forEach((a) => {
|
||||
const key = a.getAttribute('data-perm');
|
||||
a.style.display = this.canPage(key) ? '' : 'none';
|
||||
});
|
||||
},
|
||||
avatarInitials() {
|
||||
const u = this.authUser;
|
||||
if (!u) return '?';
|
||||
const n = (u.display_name || u.username || '?').trim();
|
||||
const parts = n.split(/\s+/).filter(Boolean);
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
return n.slice(0, 1).toUpperCase();
|
||||
},
|
||||
async uploadMyAvatar(ev) {
|
||||
const file = ev.target.files && ev.target.files[0];
|
||||
ev.target.value = '';
|
||||
if (!file) return;
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
try {
|
||||
const res = await fetch('/api/auth/me/avatar', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.detail || 'Upload mislukt');
|
||||
this.authUser = Object.assign({}, data.user, { _avTick: Date.now() });
|
||||
if (window.Cockpit && Cockpit.toast) Cockpit.toast('Profielfoto bijgewerkt', 'success');
|
||||
} catch (e) {
|
||||
if (window.Cockpit && Cockpit.toast) Cockpit.toast(e.message || 'Upload mislukt', 'error');
|
||||
else alert(e.message || 'Upload mislukt');
|
||||
}
|
||||
},
|
||||
initSidebarWidth() {
|
||||
var saved = parseInt(localStorage.getItem('flk-sidebar-width') || '280', 10);
|
||||
@@ -340,32 +471,49 @@
|
||||
tick();
|
||||
setInterval(tick, 1000);
|
||||
},
|
||||
isAndroid: false,
|
||||
initPwa() {
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) return;
|
||||
if (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) {
|
||||
this.showInstall = !localStorage.getItem('pwa-dismiss');
|
||||
const standalone = window.matchMedia('(display-mode: standalone)').matches
|
||||
|| window.navigator.standalone === true;
|
||||
if (standalone) return;
|
||||
|
||||
this.isAndroid = /Android/i.test(navigator.userAgent);
|
||||
|
||||
// SW alleen proberen op secure context (https/localhost). Op http in LAN faalt dit stil — app blijft gewoon werken.
|
||||
if (window.isSecureContext && 'serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(function (err) {
|
||||
console.warn('SW register failed', err);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
this._deferredPrompt = e;
|
||||
this.showInstall = true;
|
||||
if (!localStorage.getItem('pwa-dismiss')) this.showInstall = true;
|
||||
});
|
||||
|
||||
if (!localStorage.getItem('pwa-dismiss') && (this.isIos || this.isAndroid)) {
|
||||
this.showInstall = true;
|
||||
}
|
||||
},
|
||||
dismissPwa() {
|
||||
this.showInstall = false;
|
||||
localStorage.setItem('pwa-dismiss', '1');
|
||||
},
|
||||
async installPwa() {
|
||||
if (this._deferredPrompt) {
|
||||
this._deferredPrompt.prompt();
|
||||
await this._deferredPrompt.userChoice;
|
||||
this._deferredPrompt = null;
|
||||
this.showInstall = false;
|
||||
this.dismissPwa();
|
||||
return;
|
||||
}
|
||||
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
|
||||
alert('Tik op Deel ↗ en kies «Zet op beginscherm» om Foodlinkk als app te installeren.');
|
||||
if (this.isIos) {
|
||||
alert('Open http://10.4.7.18:8610 in Safari\n\n1. Tik op Deel (↗)\n2. Kies «Zet op beginscherm»\n3. Bevestig met «Zet erop»\n\nGeen HTTPS nodig — geen certificaatmelding.');
|
||||
} else {
|
||||
alert('Tik op ⋮ menu → «Toevoegen aan startscherm» of «Installeren».');
|
||||
alert('Open http://10.4.7.18:8610 in Chrome\n\n1. Tik op ⋮ (rechtsboven)\n2. «Toevoegen aan startscherm» of «App installeren»\n3. Bevestig\n\nGeen HTTPS nodig — geen certificaatmelding.');
|
||||
}
|
||||
this.showInstall = false;
|
||||
localStorage.setItem('pwa-dismiss', '1');
|
||||
this.dismissPwa();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user