SysOps: approved_backup — 2026-08-16 11:01 UTC

This commit is contained in:
sysops
2026-08-16 11:01:58 +00:00
parent 22a17ed048
commit 1dd607b060
46 changed files with 3734 additions and 147 deletions
+196 -48
View File
@@ -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();
}
};
}