2026-06-09 00:41:27 +00:00
<!DOCTYPE html>
< html lang = "nl" >
2026-08-16 22:40:32 +00:00
< script >( function (){ try { var t = localStorage . getItem ( 'flk-theme' ); if ( t ) document . documentElement . setAttribute ( 'data-theme' , t ); var s = localStorage . getItem ( 'flk-sidebar-theme' ); if ( s ) document . documentElement . setAttribute ( 'data-sidebar' , s ); var b = localStorage . getItem ( 'flk-page-bg' ); if ( b ) document . documentElement . setAttribute ( 'data-bg' , b ); var ss = localStorage . getItem ( 'flk-sidebar-style' ); if ( ss === 'plain' ) document . documentElement . setAttribute ( 'data-sidebar-style' , 'plain' ); else document . documentElement . removeAttribute ( 'data-sidebar-style' );} catch ( e ){}})();</ script >
2026-06-09 00:41:27 +00:00
< head >
< meta charset = "UTF-8" />
< meta name = "viewport" content = "width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=5" />
< meta name = "theme-color" content = "#0a0e14" />
< meta name = "mobile-web-app-capable" content = "yes" />
< meta name = "apple-mobile-web-app-capable" content = "yes" />
< meta name = "apple-mobile-web-app-status-bar-style" content = "black-translucent" />
< meta name = "apple-mobile-web-app-title" content = "Foodlinkk" />
2026-06-23 10:04:23 +00:00
< title > {% block title %}{{ page_title }} · Cucina{% endblock %}</ title >
2026-07-19 18:49:52 +00:00
< 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" />
2026-08-16 11:01:58 +00:00
< 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" />
2026-06-09 00:41:27 +00:00
< link rel = "icon" href = "/static/icons/app-icon.svg" type = "image/svg+xml" />
2026-08-16 11:01:58 +00:00
< 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" />
2026-07-19 18:49:52 +00:00
< link rel = "stylesheet" href = "/static/css/palantir-theme.css?v=4" />
2026-08-16 22:40:32 +00:00
< link rel = "stylesheet" href = "/static/css/flk-themes.css?v=8" />
2026-06-23 10:04:23 +00:00
< link rel = "stylesheet" href = "/static/css/pulse-theme.css?v=2" />
< link rel = "stylesheet" href = "/static/css/topnav-neo.css?v=6" />
2026-08-16 22:40:32 +00:00
< link rel = "stylesheet" href = "/static/css/vertical-tabs.css?v=10" />
2026-08-16 11:01:58 +00:00
< link rel = "stylesheet" href = "/static/css/herman-dashboard.css?v=34" />
< script defer src = "/static/js/herman-assistant.js?v=17" ></ script >
2026-08-16 15:49:16 +00:00
< script defer src = "/static/js/alpine.min.js?v=2" ></ script >
2026-06-23 10:04:23 +00:00
< script defer src = "/static/js/htmx.min.js?v=2" ></ script >
2026-06-09 00:41:27 +00:00
{% block extra_head %}{% endblock %}
2026-08-16 22:40:32 +00:00
< link rel = "stylesheet" href = "/static/css/app-sidebar-neo.css?v=13" />
2026-07-19 18:49:52 +00:00
< link rel = "stylesheet" href = "/static/css/cockpit-typography.css?v=1" />
2026-08-01 17:50:45 +00:00
< link rel = "stylesheet" href = "/static/css/herman-assistant.css?v=16" />
2026-08-16 11:01:58 +00:00
<!-- mobile last: wins over sidebar/dashboard cascade -->
2026-08-16 22:40:32 +00:00
< link rel = "stylesheet" href = "/static/css/mobile.css?v=12" />
2026-08-16 11:01:58 +00:00
< link rel = "stylesheet" href = "/static/css/user-avatar.css?v=3" />
2026-06-09 00:41:27 +00:00
</ head >
2026-06-23 10:04:23 +00:00
< body x-data = "appShell()" x-init = "initApp()" :class = "{ 'nav-open': navOpen, 'drawer-open': drawerOpen, 'brand-cucina': brand === 'cucina', 'brand-foodlinkk': brand === 'foodlinkk' }" >
2026-06-09 00:41:27 +00:00
< div class = "app-shell" >
2026-07-30 11:53:43 +00:00
< aside class = "app-sidebar" :style = "'--sidebar-width:' + sidebarWidth + 'px'" @ click = "if($event.target.closest('a')) navOpen=false" >
< div class = "app-sidebar-resizer" title = "Sleep om breder te maken"
@ mousedown . prevent = "startSidebarResize($event)" ></ div >
2026-06-09 00:41:27 +00:00
< div class = "app-sidebar-brand" >
2026-06-23 10:04:23 +00:00
< p class = "brand-title" > Cucina · Foodlinkk</ p >
< p class = "brand-name" x-text = "brand === 'foodlinkk' ? 'Foodlinkk B2B' : 'Cucina B2C'" > Cucina B2C</ p >
2026-06-09 00:41:27 +00:00
</ div >
2026-06-23 10:04:23 +00:00
< div class = "persona-switch brand-switch" role = "tablist" aria-label = "Brand" >
< button type = "button" role = "tab" class = "persona-btn brand-btn-cucina"
:class = "{ active: brand === 'cucina' }" @ click = "setBrand('cucina')"
:aria-selected = "brand === 'cucina'" > Cucina · B2C</ button >
< button type = "button" role = "tab" class = "persona-btn brand-btn-foodlinkk"
:class = "{ active: brand === 'foodlinkk' }" @ click = "setBrand('foodlinkk')"
:aria-selected = "brand === 'foodlinkk'" > Foodlinkk · B2B</ button >
2026-06-09 00:41:27 +00:00
</ div >
2026-06-23 10:04:23 +00:00
< div class = "persona-nav" >
< div class = "app-sidebar-group" x-show = "brand === 'foodlinkk'" >
< div class = "app-sidebar-label" > Foodlinkk B2B</ div >
2026-08-16 22:40:32 +00:00
< 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" aria-hidden = "true" >< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< circle cx = "12" cy = "12" r = "10" />< path d = "M2 12h20" />< path d = "M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></ svg ></ 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" aria-hidden = "true" >< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />< circle cx = "9" cy = "7" r = "4" />< path d = "M22 21v-2a4 4 0 0 0-3-3.87" />< path d = "M16 3.13a4 4 0 0 1 0 7.75" /></ svg ></ 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" aria-hidden = "true" >< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "m11 17 2 2a1 1 0 1 0 3-3" />< path d = "m14 14 2.5 2.5a1 1 0 1 0 3-3l-3.88-3.88a3 3 0 0 0-4.24 0l-.88.88a1 1 0 1 1-3-3l2.81-2.81a5.79 5.79 0 0 1 7.06-.87l.47.28a2 2 0 0 0 1.42.25L21 6" />< path d = "m3 13 1.68-1.68a2.41 2.41 0 0 1 3.41 0L12 16" /></ svg ></ 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" aria-hidden = "true" >< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" />< path d = "M12 22V12" />< path d = "m3.3 7 7.7 4.7L18.7 7" />< path d = "m7.5 4.3 9 5.2" /></ svg ></ 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" aria-hidden = "true" >< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M2 20a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8l-7 5V8l-7 5V4a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z" />< path d = "M17 18h1" />< path d = "M12 18h1" />< path d = "M7 18h1" /></ svg ></ 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" aria-hidden = "true" >< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M3 3v18h18" />< path d = "M18 17V9" />< path d = "M13 17V5" />< path d = "M8 17v-3" /></ svg ></ span >< span class = "sidebar-nav-label" data-i18n = "nav.reports" > Rapporten</ span ></ a >
2026-06-23 10:04:23 +00:00
</ div >
2026-06-09 00:41:27 +00:00
2026-06-23 10:04:23 +00:00
< div class = "app-sidebar-group" x-show = "brand === 'cucina'" >
< div class = "app-sidebar-label" > Cucina B2C</ div >
2026-08-16 22:40:32 +00:00
< a href = "/" data-perm = "dashboard" class = "nav-pill nav-pill-herman {% if request.url.path == '/' %}active{% endif %}" >< span class = "sidebar-nav-icon" aria-hidden = "true" >< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />< polyline points = "9 22 9 12 15 12 15 22" /></ svg ></ 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" aria-hidden = "true" >< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< circle cx = "8" cy = "8" r = "6" />< path d = "M18.09 10.37A6 6 0 1 1 10.34 18" />< path d = "M7 6h1v4" />< path d = "m16.71 13.88.7.71-2.82 2.82" /></ svg ></ 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" aria-hidden = "true" >< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< circle cx = "8" cy = "21" r = "1" />< circle cx = "19" cy = "21" r = "1" />< path d = "M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12" /></ svg ></ 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" aria-hidden = "true" >< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "m3 11 18-5v12L3 13v-2z" />< path d = "M11.6 16.8a3 3 0 1 1-5.8-1.6" /></ svg ></ 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" aria-hidden = "true" >< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z" />< path d = "m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65" />< path d = "m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65" /></ svg ></ 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" aria-hidden = "true" >< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M3.5 21 14 3" />< path d = "M20.5 21 10 3" />< path d = "M15.5 21 12 15l-3.5 6" />< path d = "M2 21h20" /></ svg ></ 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" aria-hidden = "true" >< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />< path d = "M14 2v4a2 2 0 0 0 2 2h4" />< path d = "M10 9H8" />< path d = "M16 13H8" />< path d = "M16 17H8" /></ svg ></ 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" aria-hidden = "true" >< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" /></ svg ></ 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" aria-hidden = "true" >< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "m22 2-7 20-4-9-9-4Z" />< path d = "M22 2 11 13" /></ svg ></ 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" aria-hidden = "true" >< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< polyline points = "22 7 13.5 15.5 8.5 10.5 2 17" />< polyline points = "16 7 22 7 22 13" /></ svg ></ span >< span class = "sidebar-nav-label" data-i18n = "nav.analytics" > Analytics</ span ></ a >
2026-06-23 10:04:23 +00:00
</ div >
2026-06-09 00:41:27 +00:00
2026-06-23 10:04:23 +00:00
< div class = "app-sidebar-group" >
< div class = "app-sidebar-label" data-i18n = "nav.platform" > Platform</ div >
2026-08-16 22:40:32 +00:00
< 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" aria-hidden = "true" >< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< rect width = "16" height = "16" x = "4" y = "4" rx = "2" />< rect width = "6" height = "6" x = "9" y = "9" rx = "1" />< path d = "M15 2v2" />< path d = "M15 20v2" />< path d = "M2 15h2" />< path d = "M2 9h2" />< path d = "M20 15h2" />< path d = "M20 9h2" />< path d = "M9 2v2" />< path d = "M9 20v2" /></ svg ></ span >< span class = "sidebar-nav-label" data-i18n = "nav.ops" > IT Ops</ span ></ a >
< a href = "/agents?tab=warroom" data-perm = "agents" class = "nav-pill nav-pill-agents {% if request.url.path.startswith('/agents') %}active{% endif %}" >< span class = "sidebar-nav-icon" aria-hidden = "true" >< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M12 8V4H8" />< rect width = "16" height = "12" x = "4" y = "8" rx = "2" />< path d = "M2 14h2" />< path d = "M20 14h2" />< path d = "M15 13v2" />< path d = "M9 13v2" /></ svg ></ 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" aria-hidden = "true" >< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< circle cx = "12" cy = "12" r = "10" />< polygon points = "16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76" /></ svg ></ 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" aria-hidden = "true" >< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< circle cx = "13.5" cy = "6.5" r = ".5" fill = "currentColor" />< circle cx = "17.5" cy = "10.5" r = ".5" fill = "currentColor" />< circle cx = "8.5" cy = "7.5" r = ".5" fill = "currentColor" />< circle cx = "6.5" cy = "12.5" r = ".5" fill = "currentColor" />< path d = "M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z" /></ svg ></ 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" aria-hidden = "true" >< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />< path d = "M19 10v2a7 7 0 0 1-14 0v-2" />< line x1 = "12" x2 = "12" y1 = "19" y2 = "22" /></ svg ></ 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" aria-hidden = "true" >< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.75" stroke-linecap = "round" stroke-linejoin = "round" >< line x1 = "4" x2 = "4" y1 = "21" y2 = "14" />< line x1 = "4" x2 = "4" y1 = "10" y2 = "3" />< line x1 = "12" x2 = "12" y1 = "21" y2 = "12" />< line x1 = "12" x2 = "12" y1 = "8" y2 = "3" />< line x1 = "20" x2 = "20" y1 = "21" y2 = "16" />< line x1 = "20" x2 = "20" y1 = "12" y2 = "3" />< line x1 = "2" x2 = "6" y1 = "14" y2 = "14" />< line x1 = "10" x2 = "14" y1 = "8" y2 = "8" />< line x1 = "18" x2 = "22" y1 = "16" y2 = "16" /></ svg ></ span >< span class = "sidebar-nav-label" data-i18n = "nav.settings" > Instellingen</ span ></ a >
2026-06-23 10:04:23 +00:00
</ div >
2026-06-09 00:41:27 +00:00
</ div >
</ aside >
< div class = "mobile-nav-overlay" x-show = "navOpen" @ click = "navOpen=false" x-cloak ></ div >
< div class = "app-main" >
< header class = "app-topbar" >
< button type = "button" class = "mobile-menu-btn" @ click = "navOpen = !navOpen" aria-label = "Menu openen" > ☰</ button >
2026-06-23 10:04:23 +00:00
< span class = "page-tip" >
< span class = "persona-badge" :class = "brand === 'foodlinkk' ? 'brand-badge-foodlinkk' : 'brand-badge-cucina'" x-text = "brand === 'foodlinkk' ? 'B2B' : 'B2C'" ></ span >
Cucina · Foodlinkk
</ span >
2026-06-09 10:41:13 +00:00
< select class = "form-input" style = "width:auto;min-width:110px;padding:0.2rem 0.5rem;font-size:0.72rem;margin:0 0.5rem"
x-model = "locale" @ change = "setLocale(locale)" :title = "$t ? $t('common.language') : 'Taal'" >
< option value = "nl" > NL</ option >
< option value = "en" > EN</ option >
</ select >
2026-06-09 00:41:27 +00:00
< div class = "app-clock" >< strong x-text = "clockDate" > —</ strong > · < span x-text = "clockTime" > —</ span ></ div >
2026-08-16 11:01:58 +00:00
{% 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 >
2026-06-09 00:41:27 +00:00
</ header >
< main class = "main-topnav" > {% block content %}{% endblock %}</ main >
</ div >
</ div >
2026-08-16 11:01:58 +00:00
< 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 >
2026-06-09 00:41:27 +00:00
</ div >
2026-08-01 17:50:45 +00:00
<!-- Herman chat — altijd vast onderin zichtbaar -->
< div id = "ha-dock" class = "ha-dock" x-data = "hermanBrowserWidget()" x-init = "initWidget()" :class = "{ 'ha-dock-hidden': hidden }" >
< button type = "button" class = "ha-fab"
:class = "{ 'is-open': open, 'is-busy': busy, 'is-dragging': fabDragging, 'has-custom-pos': !!fabPos }"
:style = "fabStyle()"
@ pointerdown . prevent = "onFabPointerDown($event)"
@ pointermove = "onFabPointerMove($event)"
@ pointerup = "onFabPointerUp($event)"
@ pointercancel = "onFabPointerUp($event)"
@ click = "onFabClick($event)"
@ dblclick = "onFabDblClick($event)"
title = "Slepen om te verplaatsen · klik om te openen · dubbelklik = reset"
aria-label = "Open Herman chat" >
< span class = "ha-fab-icon" aria-hidden = "true" > 💬</ span >
< span class = "ha-fab-label" x-text = "open ? 'Sluiten' : 'Herman'" ></ span >
</ button >
< div class = "ha-panel" :class = "{ 'has-custom-pos': !!fabPos }" :style = "panelStyle()" x-show = "open" x-cloak x-transition . opacity . duration . 150ms @ keydown . escape . window = "open = false" >
2026-06-23 10:04:23 +00:00
< header class = "ha-head" >
2026-08-01 17:50:45 +00:00
< div class = "ha-head-title" >
< h2 > Herman</ h2 >
< p class = "ha-conn" :class = "{ 'is-ok': connOk, 'is-err': connOk === false }" x-text = "connLabel" ></ p >
</ div >
2026-06-23 10:04:23 +00:00
< div class = "ha-head-actions" >
2026-08-01 17:50:45 +00:00
< 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 >
2026-08-16 11:01:58 +00:00
< 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 >
2026-08-01 17:50:45 +00:00
< a href = "/herman" > Volledig</ a >
2026-06-23 10:04:23 +00:00
< button type = "button" @ click = "open = false" aria-label = "Sluiten" > × </ button >
</ div >
</ header >
< div class = "ha-confirm" x-show = "pendingAction" >
< span x-text = "pendingAction ? (pendingAction.type === 'webbuilder_build' ? ('Agy website: ' + (pendingAction.project || '')) : ('Zoeken: ' + (pendingAction.entity_label || '') + ' in ' + (pendingAction.location_label || ''))) : ''" ></ span >
< div class = "ha-confirm-actions" >
< button type = "button" class = "btn btn-sm btn-pulse" @ click = "confirmPending()" :disabled = "busy" > Ja</ button >
< button type = "button" class = "btn btn-sm" @ click = "cancelPending()" :disabled = "busy" > Nee</ button >
</ div >
</ div >
< div class = "ha-log" x-ref = "chatLog" >
< template x-for = "(t, i) in turns" :key = "i" >
< div class = "ha-bubble" :class = "t.role === 'user' ? 'ha-bubble-user' : 'ha-bubble-agent'" >
2026-08-01 17:50:45 +00:00
< div class = "ha-bubble-meta" x-text = "t.at + (t.role === 'user' ? ' · jij' : (' · ' + ((t.meta && t.meta.agent_label) || 'Herman')))" ></ div >
2026-06-23 10:04:23 +00:00
< p x-text = "t.text" ></ p >
</ div >
</ template >
2026-08-01 17:50:45 +00:00
< p x-show = "!turns.length" class = "page-tip" style = "font-size:0.75rem;margin:0" > Chat met Herman op de achtergrond — bv. “zoek distributeurs in Dubai”</ p >
2026-06-23 10:04:23 +00:00
</ div >
< div class = "ha-preview" :class = "{ 'is-interim': liveInterim }" x-show = "livePreview || recording" x-text = "livePreview || 'Luisteren…'" ></ div >
2026-08-01 17:50:45 +00:00
< div class = "ha-toggles" >
< label title = "Alleen voorlezen als jij dit aanzet" >
< input type = "checkbox" x-model = "speakReply" @ change = "persistPrefs()" />
< span > Voorlezen (Maarten)</ span >
</ label >
< label title = "Na antwoord meteen weer luisteren" >
< input type = "checkbox" x-model = "handsFreeMode" @ change = "onHandsFreeChange()" />
< span > Hands-free praten</ span >
</ label >
</ div >
< p class = "ha-status" :class = "{ 'is-error': status === 'error' }" x-show = "statusDetail || busy" x-text = "statusDetail || (busy ? 'Herman denkt…' : '')" ></ p >
< div class = "ha-https-prompt" x-show = "httpsAudioPrompt" x-cloak >
< span > Microfoon werkt via HTTPS</ span >
< button type = "button" class = "btn btn-sm btn-pulse" @ click = "goHttpsAudio()" > Doorgaan naar HTTPS →</ button >
< button type = "button" class = "ha-text-btn" @ click = "httpsAudioPrompt = false" title = "Sluiten" > × </ button >
</ div >
< form class = "ha-input-row" @ submit . prevent = "sendText()" >
2026-06-23 10:04:23 +00:00
< button type = "button" class = "ha-mic" :class = "micClass()"
@ mousedown . prevent = "onMicDown()" @ mouseup . prevent = "onMicUp()"
@ touchstart . prevent = "onMicDown()" @ touchend . prevent = "onMicUp()"
@ click . prevent = "onMicClick()" title = "Microfoon" > 🎙️</ button >
2026-08-01 17:50:45 +00:00
< input class = "form-input" type = "text" name = "herman_message" autocomplete = "off"
placeholder = "Vraag aan Herman…" x-model = "manualText"
2026-06-23 10:04:23 +00:00
@ keydown . enter . prevent = "sendText()" :disabled = "busy" />
2026-08-01 17:50:45 +00:00
< button type = "submit" class = "btn btn-sm btn-pulse" :disabled = "busy || !manualText.trim()" > →</ button >
</ form >
2026-06-23 10:04:23 +00:00
</ div >
< div class = "ei-modal-root ha-results-modal" x-show = "resultsOpen" @ click . self = "closeResultsModal()" >
< div class = "ei-modal-card ha-results-card" @ click . stop >
< h3 x-text = "resultsTitle" ></ h3 >
< p >< strong x-text = "resultsTotal" ></ strong > resultaten</ p >
< div class = "voice-results-table-wrap" style = "max-height:40vh;overflow:auto" >
< table class = "voice-results-table" style = "width:100%;font-size:0.78rem" >
< thead >< tr >< th > Naam</ th >< th > Type</ th >< th > Stad</ th ></ tr ></ thead >
< tbody >
< template x-for = "(e, i) in resultsEntities" :key = "i" >
< tr >
< td >< a :href = "'/export-intel?open=' + e.id" x-text = "e.name" ></ a ></ td >
< td x-text = "entityTypeLabel(e.entity_type)" ></ td >
< td x-text = "e.city || '—'" ></ td >
</ tr >
</ template >
</ tbody >
</ table >
</ div >
< div class = "ei-modal-actions" >
< a class = "btn btn-sm" :href = "resultsOpenUrl" > Wereldexport</ a >
< button type = "button" class = "btn btn-sm" @ click = "closeResultsModal()" > Sluiten</ button >
</ div >
</ div >
</ div >
< div class = "ei-modal-root ha-results-modal" x-show = "webbuilderOpen" @ click . self = "closeWebbuilderModal()" >
< div class = "ei-modal-card ha-results-card" @ click . stop >
< h3 x-text = "webbuilderTitle" ></ h3 >
< p >< strong x-text = "webbuilderProject" ></ strong > · Agy / Antigravity op Hermes</ p >
< p class = "page-tip" style = "font-size:0.78rem" > Preview na afloop: < a :href = "webbuilderPreview" target = "_blank" rel = "noopener" x-text = "webbuilderPreview" ></ a ></ p >
< p class = "page-tip" x-show = "webbuilderNas" style = "font-size:0.72rem" x-text = "'NAS: ' + webbuilderNas" ></ p >
< div class = "ei-modal-actions" >
< a class = "btn btn-sm btn-pulse" :href = "webbuilderAgentsUrl" > Agents terminal</ a >
< a class = "btn btn-sm" :href = "webbuilderPreview" target = "_blank" rel = "noopener" > Preview</ a >
< button type = "button" class = "btn btn-sm" @ click = "closeWebbuilderModal()" > Sluiten</ button >
</ div >
</ div >
</ div >
</ div >
< script src = "/static/js/i18n.js?v=5" ></ script >
2026-08-16 11:01:58 +00:00
< script src = "/static/js/cockpit.js?v=3" ></ script >
2026-06-09 10:41:13 +00:00
< script src = "/static/js/viz-engine.js?v=3" ></ script >
< script src = "/static/js/global-viz.js?v=1" ></ script >
2026-07-19 18:25:01 +00:00
< script src = "/static/js/live-pulse.js?v=3" ></ script >
2026-06-09 00:41:27 +00:00
< script >
2026-06-23 10:04:23 +00:00
window . FoodlinkkBrand = ( function () {
var FOODLINKK_PREFIXES = [ '/export-intel' , '/foodlinkk' , '/clients' , '/deals' , '/products' , '/suppliers' ];
var CUCINA_PREFIXES = [ '/retail' , '/marketing' , '/packaging' , '/revenue-cockpit' , '/beurs' ];
function isFoodlinkkPath ( path ) {
return FOODLINKK_PREFIXES . some ( function ( p ) { return path === p || path . indexOf ( p + '/' ) === 0 ; });
}
function isCucinaPath ( path ) {
return path === '/' || CUCINA_PREFIXES . some ( function ( p ) { return path === p || path . indexOf ( p + '/' ) === 0 ; });
}
function detect ( path ) {
var stored = localStorage . getItem ( 'foodlinkk-brand' );
if ( stored === 'cucina' || stored === 'foodlinkk' ) return stored ;
if ( isFoodlinkkPath ( path )) return 'foodlinkk' ;
return 'cucina' ;
}
return { isFoodlinkkPath : isFoodlinkkPath , isCucinaPath : isCucinaPath , detect : detect };
})();
/* legacy persona helper → brand */
window . FoodlinkkPersona = window . FoodlinkkBrand ;
2026-08-16 11:01:58 +00:00
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' );
}
}
};
}
2026-06-09 00:41:27 +00:00
function appShell () {
return {
2026-06-09 10:41:13 +00:00
locale : 'nl' ,
2026-06-23 10:04:23 +00:00
brand : 'cucina' ,
2026-06-09 00:41:27 +00:00
drawerOpen : false ,
navOpen : false ,
2026-07-30 11:53:43 +00:00
sidebarWidth : 280 ,
2026-06-09 00:41:27 +00:00
showInstall : false ,
2026-08-16 11:01:58 +00:00
authUser : null ,
authPages : [],
2026-06-09 00:41:27 +00:00
_deferredPrompt : null ,
clockDate : '—' ,
clockTime : '—' ,
_offset : 0 ,
2026-08-16 11:01:58 +00:00
isIos : false ,
2026-06-09 10:41:13 +00:00
async initApp () {
2026-06-23 10:04:23 +00:00
this . brand = window . FoodlinkkBrand . detect ( location . pathname );
2026-08-16 11:01:58 +00:00
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' )) {
2026-06-23 10:04:23 +00:00
if ( window . caches ) {
const keys = await caches . keys ();
await Promise . all ( keys . map (( k ) => caches . delete ( k )));
}
2026-08-16 11:01:58 +00:00
localStorage . setItem ( 'flk-pwa-v6' , '1' );
2026-08-01 17:50:45 +00:00
localStorage . setItem ( 'flk-cache-v4' , '1' );
2026-06-23 10:04:23 +00:00
}
2026-06-09 10:41:13 +00:00
if ( window . I18n ) await window . I18n . init ();
this . locale = window . I18n ? window . I18n . locale () : 'nl' ;
2026-07-30 11:53:43 +00:00
this . initSidebarWidth ();
2026-06-09 00:41:27 +00:00
this . initClock ();
this . initPwa ();
2026-08-16 11:01:58 +00:00
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' );
}
2026-06-09 00:41:27 +00:00
},
2026-07-30 11:53:43 +00:00
initSidebarWidth () {
var saved = parseInt ( localStorage . getItem ( 'flk-sidebar-width' ) || '280' , 10 );
if ( ! isFinite ( saved )) saved = 280 ;
this . sidebarWidth = Math . min ( 420 , Math . max ( 220 , saved ));
document . documentElement . style . setProperty ( '--sidebar-width' , this . sidebarWidth + 'px' );
},
startSidebarResize ( e ) {
var self = this ;
var startX = e . clientX ;
var startW = this . sidebarWidth ;
var handle = e . currentTarget ;
handle . classList . add ( 'is-dragging' );
document . body . classList . add ( 'sidebar-resizing' );
function onMove ( ev ) {
var next = Math . min ( 420 , Math . max ( 220 , startW + ( ev . clientX - startX )));
self . sidebarWidth = next ;
document . documentElement . style . setProperty ( '--sidebar-width' , next + 'px' );
}
function onUp () {
handle . classList . remove ( 'is-dragging' );
document . body . classList . remove ( 'sidebar-resizing' );
localStorage . setItem ( 'flk-sidebar-width' , String ( self . sidebarWidth ));
window . removeEventListener ( 'mousemove' , onMove );
window . removeEventListener ( 'mouseup' , onUp );
}
window . addEventListener ( 'mousemove' , onMove );
window . addEventListener ( 'mouseup' , onUp );
},
2026-06-23 10:04:23 +00:00
setBrand ( b ) {
if ( b === this . brand ) return ;
this . brand = b ;
localStorage . setItem ( 'foodlinkk-brand' , b );
var path = location . pathname ;
if ( b === 'foodlinkk' && window . FoodlinkkBrand . isCucinaPath ( path ) && ! window . FoodlinkkBrand . isFoodlinkkPath ( path )) {
location . href = '/export-intel' ;
return ;
}
if ( b === 'cucina' && window . FoodlinkkBrand . isFoodlinkkPath ( path )) {
location . href = '/' ;
return ;
}
},
2026-06-09 10:41:13 +00:00
async setLocale ( loc ) {
if ( window . I18n ) await window . I18n . setLocale ( loc );
this . locale = loc ;
},
2026-06-09 00:41:27 +00:00
async initClock () {
try {
const r = await fetch ( '/api/server-time' ). then ( x => x . json ());
this . _offset = new Date ( r . utc ). getTime () - Date . now ();
} catch ( e ) { this . _offset = 0 ; }
const tick = () => {
const d = new Date ( Date . now () + this . _offset );
this . clockDate = d . toLocaleDateString ( 'nl-NL' , { timeZone : 'Europe/Amsterdam' , weekday : 'short' , day : 'numeric' , month : 'short' });
this . clockTime = d . toLocaleTimeString ( 'nl-NL' , { timeZone : 'Europe/Amsterdam' , hour : '2-digit' , minute : '2-digit' , second : '2-digit' });
};
tick ();
setInterval ( tick , 1000 );
},
2026-08-16 11:01:58 +00:00
isAndroid : false ,
2026-06-09 00:41:27 +00:00
initPwa () {
2026-08-16 11:01:58 +00:00
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 );
});
2026-06-09 00:41:27 +00:00
}
2026-08-16 11:01:58 +00:00
2026-06-09 00:41:27 +00:00
window . addEventListener ( 'beforeinstallprompt' , ( e ) => {
e . preventDefault ();
this . _deferredPrompt = e ;
2026-08-16 11:01:58 +00:00
if ( ! localStorage . getItem ( 'pwa-dismiss' )) this . showInstall = true ;
2026-06-09 00:41:27 +00:00
});
2026-08-16 11:01:58 +00:00
if ( ! localStorage . getItem ( 'pwa-dismiss' ) && ( this . isIos || this . isAndroid )) {
this . showInstall = true ;
}
},
dismissPwa () {
this . showInstall = false ;
localStorage . setItem ( 'pwa-dismiss' , '1' );
2026-06-09 00:41:27 +00:00
},
async installPwa () {
if ( this . _deferredPrompt ) {
this . _deferredPrompt . prompt ();
await this . _deferredPrompt . userChoice ;
this . _deferredPrompt = null ;
2026-08-16 11:01:58 +00:00
this . dismissPwa ();
2026-06-09 00:41:27 +00:00
return ;
}
2026-08-16 11:01:58 +00:00
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.' );
2026-06-09 00:41:27 +00:00
} else {
2026-08-16 11:01:58 +00:00
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.' );
2026-06-09 00:41:27 +00:00
}
2026-08-16 11:01:58 +00:00
this . dismissPwa ();
2026-06-09 00:41:27 +00:00
}
};
}
</ script >
2026-06-23 10:04:23 +00:00
< script >
window . addEventListener ( 'load' , function () {
setTimeout ( function () {
if ( window . Alpine ) return ;
document . querySelectorAll ( '[x-cloak]' ). forEach ( function ( el ) { el . removeAttribute ( 'x-cloak' ); });
document . body . classList . add ( 'alpine-missing' );
console . error ( 'Alpine.js niet geladen — interactieve UI beperkt. Hard refresh (Ctrl+Shift+R).' );
}, 2500 );
});
</ script >
2026-08-16 15:49:16 +00:00
< script >
window . FlkThemes = {
list : [
{ id : 'ocean' , name : 'Ocean Cyan' , desc : 'Diep navy met fel cyaan' },
{ id : 'aurora' , name : 'Aurora' , desc : 'Blauw-violet met cyaan highlights' },
{ id : 'neon' , name : 'Neon Tide' , desc : 'Donker neon cyaan + magenta' },
{ id : 'glacier' , name : 'Glacier' , desc : 'IJsblauw en helder sky-cyan' },
2026-08-16 22:40:32 +00:00
{ id : 'cobalt' , name : 'Cobalt Pulse' , desc : 'Kobaltblauw met aqua en amber' },
{ id : 'reef' , name : 'Reef' , desc : 'Teal-diep met helder mint' },
{ id : 'plasma' , name : 'Plasma' , desc : 'Paars-nacht met cyaan vonken' },
{ id : 'lagoon' , name : 'Lagoon' , desc : 'Tropisch blauwgroen water' },
{ id : 'indigo' , name : 'Indigo Night' , desc : 'Indigo met lavendel accents' },
{ id : 'skyline' , name : 'Skyline' , desc : 'Stad-nacht blauw + sky' },
{ id : 'mintvolt' , name : 'Mint Volt' , desc : 'Donker groen met mint-energie' },
{ id : 'sapphire' , name : 'Sapphire' , desc : 'Klassiek saffierblauw' },
{ id : 'arctic' , name : 'Arctic Glow' , desc : 'Koud grijsblauw met ice-cyan' },
{ id : 'hyperion' , name : 'Hyperion' , desc : 'Cyaan met lime-flits' },
{ id : 'meridian' , name : 'Meridian' , desc : 'Violet-meridiaan met aqua' }
],
sidebars : [
{ id : 'aqua' , name : 'Deep Aqua' , desc : 'Diep aqua, strak en koel' },
{ id : 'azure' , name : 'Royal Azure' , desc : 'Diep koningsblauw' },
{ id : 'turquoise' , name : 'Abyss Teal' , desc : 'Diep teal, premium' },
{ id : 'cerulean' , name : 'Ocean Depth' , desc : 'Diep oceaanblauw' },
{ id : 'electric' , name : 'Electric Cyan' , desc : 'Donker elektrisch cyaan' },
{ id : 'violet' , name : 'Indigo Night' , desc : 'Diep indigo-violet (geen roze)' },
{ id : 'emerald' , name : 'Jade Depth' , desc : 'Diep jade smaragd' },
{ id : 'midnight' , name : 'Midnight Navy' , desc : 'Diep marinezwart-blauw' },
{ id : 'steel' , name : 'Steel Blue' , desc : 'Strak staalblauw' },
{ id : 'nordic' , name : 'Nordic Frost' , desc : 'Koel nordic blauw' }
],
backgrounds : [
{ id : 'navy' , name : 'Deep Navy' , desc : 'Diep marineblauw' },
{ id : 'royal' , name : 'Royal Blue' , desc : 'Koningsblauw' },
{ id : 'azure' , name : 'Azure Sea' , desc : 'Helder azuur' },
{ id : 'sky' , name : 'Sky Blue' , desc : 'Heldere luchtblauw' },
{ id : 'cerulean' , name : 'Cerulean' , desc : 'Ceruleum' },
{ id : 'teal' , name : 'Deep Teal' , desc : 'Diep teal' },
{ id : 'lagoon' , name : 'Lagoon Blue' , desc : 'Lagune blauwgroen' },
{ id : 'cyan' , name : 'Cyan Depth' , desc : 'Cyaan diepte' },
{ id : 'indigo' , name : 'Indigo Field' , desc : 'Indigo veld' },
{ id : 'violet' , name : 'Violet Dusk' , desc : 'Violet schemer' },
{ id : 'sapphire' , name : 'Sapphire' , desc : 'Saffierblauw' },
{ id : 'midnight' , name : 'Midnight' , desc : 'Nachtblauw (niet zwart)' },
{ id : 'emerald' , name : 'Emerald Sea' , desc : 'Smaragd zee' },
{ id : 'slate' , name : 'Slate Blue' , desc : 'Leisteenblauw' },
{ id : 'graphite' , name : 'Graphite Blue' , desc : 'Grafietblauw' }
2026-08-16 15:49:16 +00:00
],
current : function () {
try { return localStorage . getItem ( 'flk-theme' ) || 'ocean' ; } catch ( e ) { return 'ocean' ; }
},
2026-08-16 22:40:32 +00:00
currentSidebar : function () {
try { return localStorage . getItem ( 'flk-sidebar-theme' ) || '' ; } catch ( e ) { return '' ; }
},
2026-08-16 15:49:16 +00:00
apply : function ( id ) {
var ok = this . list . some ( function ( t ) { return t . id === id ; });
if ( ! ok ) id = 'ocean' ;
document . documentElement . setAttribute ( 'data-theme' , id );
try { localStorage . setItem ( 'flk-theme' , id ); } catch ( e ) {}
return id ;
2026-08-16 22:40:32 +00:00
},
applySidebar : function ( id ) {
if ( ! id ) {
document . documentElement . removeAttribute ( 'data-sidebar' );
try { localStorage . removeItem ( 'flk-sidebar-theme' ); } catch ( e ) {}
return '' ;
}
var migrate = { coral : 'midnight' , fuchsia : 'nordic' , lime : 'steel' };
if ( migrate [ id ]) id = migrate [ id ];
var ok = this . sidebars . some ( function ( t ) { return t . id === id ; });
if ( ! ok ) {
document . documentElement . removeAttribute ( 'data-sidebar' );
try { localStorage . removeItem ( 'flk-sidebar-theme' ); } catch ( e ) {}
return '' ;
}
document . documentElement . setAttribute ( 'data-sidebar' , id );
try { localStorage . setItem ( 'flk-sidebar-theme' , id ); } catch ( e ) {}
return id ;
},
currentSidebarStyle : function () {
try { return localStorage . getItem ( 'flk-sidebar-style' ) || 'gradient' ; } catch ( e ) { return 'gradient' ; }
},
applySidebarStyle : function ( mode ) {
if ( mode !== 'plain' ) mode = 'gradient' ;
if ( mode === 'plain' ) {
document . documentElement . setAttribute ( 'data-sidebar-style' , 'plain' );
} else {
document . documentElement . removeAttribute ( 'data-sidebar-style' );
}
try { localStorage . setItem ( 'flk-sidebar-style' , mode ); } catch ( e ) {}
return mode ;
},
currentBg : function () {
try { return localStorage . getItem ( 'flk-page-bg' ) || '' ; } catch ( e ) { return '' ; }
},
applyBg : function ( id ) {
if ( ! id ) {
document . documentElement . removeAttribute ( 'data-bg' );
try { localStorage . removeItem ( 'flk-page-bg' ); } catch ( e ) {}
return '' ;
}
var ok = ( this . backgrounds || []). some ( function ( t ) { return t . id === id ; });
if ( ! ok ) {
document . documentElement . removeAttribute ( 'data-bg' );
try { localStorage . removeItem ( 'flk-page-bg' ); } catch ( e ) {}
return '' ;
}
document . documentElement . setAttribute ( 'data-bg' , id );
try { localStorage . setItem ( 'flk-page-bg' , id ); } catch ( e ) {}
return id ;
2026-08-16 15:49:16 +00:00
}
};
if ( ! document . documentElement . getAttribute ( 'data-theme' )) {
window . FlkThemes . apply ( window . FlkThemes . current ());
}
2026-08-16 22:40:32 +00:00
( function () {
var s = window . FlkThemes . currentSidebar ();
if ( s ) window . FlkThemes . applySidebar ( s );
if ( window . FlkThemes . applySidebarStyle ) window . FlkThemes . applySidebarStyle ( window . FlkThemes . currentSidebarStyle ());
var b = window . FlkThemes . currentBg ();
if ( b ) window . FlkThemes . applyBg ( b );
})();
2026-08-16 15:49:16 +00:00
</ script >
2026-08-16 22:40:32 +00:00
2026-08-16 15:49:16 +00:00
{% block scripts %}{% endblock %}
2026-06-09 00:41:27 +00:00
</ body >
</ html >