Add War Room voice meeting, fix chat overlay, and ship 5 cyan UI themes.

Voice Room lets humans talk with agents via Whisper/TTS; chat typing no longer blocked by a full-screen overlay, attachments/avatars are larger, and Settings exposes five vivid mid-dark cyan/blue themes for the whole cockpit.
This commit is contained in:
Aissa
2026-08-16 15:49:16 +00:00
parent 1dd607b060
commit 910ba465d7
16 changed files with 2162 additions and 139 deletions
+138 -11
View File
@@ -5,7 +5,7 @@
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
<link rel="stylesheet" href="/static/css/agents-mesh.css?v=8" />
<link rel="stylesheet" href="/static/css/agent-characters.css?v=4" />
<link rel="stylesheet" href="/static/css/agents-warroom.css?v=3" />
<link rel="stylesheet" href="/static/css/agents-warroom.css?v=18" />
<script src="/static/js/agent-avatars.js?v=4"></script>
{% endblock %}
{% block content %}
@@ -272,7 +272,8 @@
</div>
</section>
<section x-show="tab==='warroom'" x-cloak x-data="warRoomHub()" x-init="init()">
<section x-show="tab==='warroom'" x-cloak>
<div x-data="warRoomHub()" x-init="init()" class="wr-hub-root">
<div class="panel" style="padding:0.75rem">
<div class="wr-layout">
<aside class="wr-roster">
@@ -284,8 +285,9 @@
<template x-for="s in souls" :key="'wr-'+s.agent_key">
<button type="button" class="wr-agent-btn"
:class="{active: room && room.kind==='dm' && room.agent_key===s.agent_key}"
@click="openDm(s.agent_key)"
@dblclick="insertMention(s.agent_key)">
@click="selectAgent(s.agent_key)"
@dblclick.prevent="openDm(s.agent_key)"
title="Klik = @tag in War Room · dubbelklik = DM">
<div class="agent-char-wrap"
:data-agent-avatar="s.agent_key"
:data-agent-color="s.avatar_color || '#1e3a5f'"
@@ -304,12 +306,14 @@
<small class="muted" x-show="status" x-text="status"></small>
</div>
<div class="wr-head-actions">
<label class="btn btn-sm" style="display:inline-flex;align-items:center;gap:0.3rem;cursor:pointer;margin:0" title="Agents praten onderling door"><input type="checkbox" x-model="autoDebate" @change="autoDebate && scheduleContinue(1500)" style="margin:0" /> A2A</label>
<button type="button" class="btn btn-sm btn-pulse" @click="openVoiceRoom()" :disabled="!room" title="Spraakmeeting met agents">🎙️ Voice Room</button><button type="button" class="btn btn-sm" :class="{'is-recording': voiceRecording}" @mousedown.prevent="startVoiceTalk($event)" @mouseup.prevent="stopVoiceTalk($event)" @touchstart.prevent="startVoiceTalk($event)" @touchend.prevent="stopVoiceTalk($event)" :disabled="voiceBusy || busy || !room" title="Snel praten">🎤</button>
<button type="button" class="btn btn-sm" @click="popOut()" title="Open op tweede scherm">Pop-out ↗</button>
<button type="button" class="btn btn-sm btn-pulse" @click="room && openRoom(room.id)" :disabled="busy">↻</button>
<button type="button" class="btn btn-sm" @click="room && openRoom(room.id)" :disabled="busy">↻</button>
</div>
</div>
<div class="wr-log" x-ref="wrLog">
<p class="wr-empty" x-show="!messages.length">Nog geen berichten — tag agents met @marketing of open een DM.</p>
<p class="wr-empty" x-show="!messages.length && !typing">Nog geen berichten — tag agents met @marketing of deel een bestand.</p>
<template x-for="m in messages" :key="m.id">
<div class="wr-bubble"
:class="{'is-user': m.sender_type==='user', 'is-agent': m.sender_type==='agent', 'is-system': m.sender_type==='system'}">
@@ -331,21 +335,144 @@
<div class="wr-bubble-meta">
<span x-text="labelForMessage(m)"></span>
<span x-show="m.agent_key" x-text="' · @'+m.agent_key"></span>
<span x-show="m.metadata && m.metadata.in_reply_to" x-text="' · reageert op @'+m.metadata.in_reply_to"></span>
<button type="button" class="wr-msg-del" title="Verwijderen"
@click.stop="deleteMessage(m)">🗑</button>
</div>
<div class="wr-bubble-text" x-text="m.content"></div>
<div class="wr-attach-list" x-show="attachmentsOf(m).length">
<template x-for="(a, ai) in attachmentsOf(m)" :key="m.id+'-a-'+ai">
<a class="wr-attach-card" :href="a.url" target="_blank" rel="noopener">
<img x-show="isImage(a)" :src="a.url" :alt="a.name" />
<span x-text="a.name"></span>
</a>
</template>
</div>
</div>
</div>
</template>
<div class="wr-bubble is-agent is-typing" x-show="typing" x-cloak>
<div class="wr-avatar">
<div class="agent-char-wrap"
:data-agent-avatar="typing && typing.agent_key"
x-effect="if (typing && window.AgentAvatars) AgentAvatars.paint($el, typing.agent_key)"></div>
</div>
<div class="wr-bubble-body">
<div class="wr-bubble-meta">
<span x-text="(typing && typing.display_name) || 'Agent'"></span>
<span> · typt</span>
</div>
<div class="wr-bubble-text wr-typing-cursor" x-text="(typing && typing.text) || '…'"></div>
</div>
</div>
</div>
<div class="wr-composer">
<textarea class="form-input" rows="2" x-model="draft" @keydown="onKey($event)"
placeholder="Bericht… gebruik @agent om iemand te taggen" :disabled="busy || !room"></textarea>
<button type="button" class="btn btn-pulse-green" @click="send()" :disabled="busy || !room || !draft.trim()">Stuur</button>
<div class="wr-composer-tools">
<button type="button" class="btn btn-sm" :class="{'is-recording': voiceRecording}" @mousedown.prevent="startVoiceTalk($event)" @mouseup.prevent="stopVoiceTalk($event)" @touchstart.prevent="startVoiceTalk($event)" @touchend.prevent="stopVoiceTalk($event)" :disabled="voiceBusy || busy" title="Houd in om te praten">🎤</button>
<button type="button" class="btn btn-sm" @click="emojiOpen=!emojiOpen" :disabled="busy">😊</button>
<label class="btn btn-sm" style="cursor:pointer;margin:0">
📎
<input type="file" hidden multiple @change="onPickFiles($event)"
accept=".png,.jpg,.jpeg,.gif,.webp,.pdf,.txt,.csv,.xlsx,.xls,.doc,.docx,.zip,.json,.md,image/*" />
</label>
<div class="wr-emoji-pop" x-show="emojiOpen" @click.outside="emojiOpen=false" x-cloak>
<template x-for="em in emojis" :key="em">
<button type="button" @click="insertEmoji(em)" x-text="em"></button>
</template>
</div>
</div>
<div class="wr-pending" x-show="pendingFiles.length">
<template x-for="(f, fi) in pendingFiles" :key="f.url">
<span class="wr-pending-chip">
<span x-text="f.name"></span>
<button type="button" @click="removePending(fi)" title="Verwijderen">×</button>
</span>
</template>
</div>
<div class="wr-composer-wrap">
<div class="wr-mention-pop" x-show="mentionOpen && mentionSuggestions().length" x-cloak>
<template x-for="(s, si) in mentionSuggestions()" :key="'m-'+s.agent_key">
<button type="button" class="wr-mention-item"
:class="{active: si===mentionIndex}"
@mousedown.prevent="pickMention(s)">
<strong x-text="s.display_name"></strong>
<small x-text="'@'+s.agent_key"></small>
</button>
</template>
</div>
<textarea class="form-input" rows="2" x-ref="wrInput" x-model="draft"
@input="onDraftInput()" @keydown="onKey($event)"
placeholder="Typ @bizdev of @finance… agent antwoordt hier in de War Room"
:disabled="!room"></textarea>
</div>
<button type="button" class="btn btn-pulse-green" @click="send()" :disabled="!canSend()">Stuur</button>
</div>
<div class="wr-voice-overlay" :class="{ 'is-open': voiceRoomOpen }" x-show="voiceRoomOpen" x-cloak @keydown.escape.window="closeVoiceRoom()">
<div class="wr-voice-panel" @click.stop>
<div class="wr-voice-head">
<div>
<h3>🎙️ Foodlinkk Voice Room</h3>
<small class="muted">Jij + agents in één gesprek (spraak)</small>
</div>
<div class="wr-voice-head-actions">
<label class="btn btn-sm" style="display:inline-flex;align-items:center;gap:0.25rem;margin:0;cursor:pointer">
<input type="checkbox" x-model="speakReplies" style="margin:0" /> 🔊 Voorlezen
</label>
<button type="button" class="btn btn-sm btn-pulse" @click="closeVoiceRoom()">Sluiten</button>
</div>
</div>
<div class="wr-voice-status" x-text="status || (voiceRecording ? 'Luisteren…' : (voiceSpeakingKey ? (voiceSpeakingKey + ' spreekt…') : 'Houd de microfoon in om te praten'))"></div>
<div class="wr-voice-grid">
<div class="wr-voice-card is-human" :class="{'is-live': voiceRecording}">
<div class="wr-voice-avatar wr-voice-avatar-human" x-text="(me.username||'Jij').slice(0,2).toUpperCase()"></div>
<strong x-text="me.username || 'Jij'"></strong>
<small x-show="voiceRecording">🎤 aan het woord</small>
<small x-show="!voiceRecording" class="muted">mens</small>
</div>
<template x-for="s in voiceAgentSouls()" :key="'vr-'+s.agent_key">
<div class="wr-voice-card is-agent"
:class="{'is-live': voiceSpeakingKey===s.agent_key, 'is-off': !isVoiceAgent(s.agent_key)}"
@click="toggleVoiceAgent(s.agent_key)"
:title="isVoiceAgent(s.agent_key) ? 'Klik = uit meeting' : 'Klik = in meeting'">
<div class="wr-voice-avatar agent-char-wrap"
:data-agent-avatar="s.agent_key"
x-init="$nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, s.agent_key))"></div>
<strong x-text="s.display_name || s.agent_key"></strong>
<small x-show="voiceSpeakingKey===s.agent_key">🔊 spreekt</small>
<small x-show="voiceSpeakingKey!==s.agent_key" class="muted" x-text="isVoiceAgent(s.agent_key) ? ('@'+s.agent_key) : 'niet in meeting'"></small>
</div>
</template>
</div>
<p class="wr-voice-hint" x-show="voiceLastHeard">Laatst gehoord: <span x-text="voiceLastHeard"></span></p>
<div class="wr-voice-ptt">
<button type="button" class="wr-ptt-btn"
:class="{'is-recording': voiceRecording, 'is-busy': voiceBusy}"
@mousedown.prevent="startVoiceTalk($event)" @mouseup.prevent="stopVoiceTalk($event)"
@touchstart.prevent="startVoiceTalk($event)" @touchend.prevent="stopVoiceTalk($event)"
@mouseleave="voiceRecording && stopVoiceTalk($event)"
:disabled="voiceBusy || busy">
<span x-show="!voiceRecording && !voiceBusy">🎤 Ingedrukt houden</span>
<span x-show="voiceRecording">● Luisteren… laat los</span>
<span x-show="voiceBusy && !voiceRecording">⏳ Bezig…</span>
</button>
</div>
<div class="wr-voice-foot">
<span class="muted">Klik agents om ze in/uit de meeting te zetten. Standaard praat @herman; 🔊 leest antwoorden voor.</span>
<a class="btn btn-sm" href="https://meet.ffmuc.net/FoodlinkkWR1" :href="callHref()" target="_blank" rel="noopener">Alleen-mensen video ↗</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
@@ -389,7 +516,7 @@
<script src="/static/js/page-viz.js?v=1"></script>
<script src="/static/js/agents-mesh.js?v=11"></script>
<script src="/static/js/agents-terminals.js?v=6"></script>
<script src="/static/js/agents-warroom.js?v=4"></script>
<script src="/static/js/agents-warroom.js?v=18"></script>
<script>
function agentsSoulHub() {
return {
+138 -9
View File
@@ -1,13 +1,16 @@
<!DOCTYPE html>
<html lang="nl">
<script>(function(){try{var t=localStorage.getItem('flk-theme');if(t)document.documentElement.setAttribute('data-theme',t);}catch(e){}})();</script>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>War Room · Foodlinkk</title>
<link rel="stylesheet" href="/static/css/palantir-theme.css?v=2" />
<link rel="stylesheet" href="/static/css/flk-themes.css?v=1" />
<link rel="stylesheet" href="/static/css/pulse-theme.css?v=2" />
<link rel="stylesheet" href="/static/css/agent-characters.css?v=4" />
<link rel="stylesheet" href="/static/css/agents-warroom.css?v=3" />
<link rel="stylesheet" href="/static/css/agents-warroom.css?v=18" />
<script defer src="/static/js/agents-warroom.js?v=18"></script>
<script defer src="/static/js/alpine.min.js?v=2"></script>
</head>
<body class="wr-popup-body">
@@ -22,7 +25,9 @@
<template x-for="s in souls" :key="'p-'+s.agent_key">
<button type="button" class="wr-agent-btn"
:class="{active: room && room.kind==='dm' && room.agent_key===s.agent_key}"
@click="openDm(s.agent_key)">
@click="selectAgent(s.agent_key)"
@dblclick.prevent="openDm(s.agent_key)"
title="Klik = @tag · dubbelklik = DM">
<div class="agent-char-wrap"
:data-agent-avatar="s.agent_key"
:data-agent-color="s.avatar_color || '#1e3a5f'"
@@ -41,12 +46,14 @@
<small class="muted" x-show="status" x-text="status"></small>
</div>
<div class="wr-head-actions">
<label class="btn btn-sm" style="display:inline-flex;align-items:center;gap:0.3rem;cursor:pointer;margin:0" title="Agents praten onderling door"><input type="checkbox" x-model="autoDebate" @change="autoDebate && scheduleContinue(1500)" style="margin:0" /> A2A</label>
<button type="button" class="btn btn-sm btn-pulse" @click="openVoiceRoom()" :disabled="!room" title="Spraakmeeting met agents">🎙️ Voice Room</button><button type="button" class="btn btn-sm" :class="{'is-recording': voiceRecording}" @mousedown.prevent="startVoiceTalk($event)" @mouseup.prevent="stopVoiceTalk($event)" @touchstart.prevent="startVoiceTalk($event)" @touchend.prevent="stopVoiceTalk($event)" :disabled="voiceBusy || busy || !room" title="Snel praten">🎤</button>
<a class="btn btn-sm" href="/agents?tab=warroom" target="_blank">In cockpit</a>
<button type="button" class="btn btn-sm btn-pulse" @click="room && openRoom(room.id)" :disabled="busy">↻</button>
<button type="button" class="btn btn-sm" @click="room && openRoom(room.id)" :disabled="busy">↻</button>
</div>
</div>
<div class="wr-log" x-ref="wrLog">
<p class="wr-empty" x-show="!messages.length">Nog geen berichten.</p>
<p class="wr-empty" x-show="!messages.length && !typing">Nog geen berichten.</p>
<template x-for="m in messages" :key="m.id">
<div class="wr-bubble"
:class="{'is-user': m.sender_type==='user', 'is-agent': m.sender_type==='agent', 'is-system': m.sender_type==='system'}">
@@ -64,22 +71,144 @@
</template>
</div>
<div class="wr-bubble-body">
<div class="wr-bubble-meta"><span x-text="labelForMessage(m)"></span></div>
<div class="wr-bubble-meta">
<span x-text="labelForMessage(m)"></span>
<button type="button" class="wr-msg-del" title="Verwijderen" @click.stop="deleteMessage(m)">🗑</button>
</div>
<div class="wr-bubble-text" x-text="m.content"></div>
<div class="wr-attach-list" x-show="attachmentsOf(m).length">
<template x-for="(a, ai) in attachmentsOf(m)" :key="m.id+'-pa-'+ai">
<a class="wr-attach-card" :href="a.url" target="_blank" rel="noopener">
<img x-show="isImage(a)" :src="a.url" :alt="a.name" />
<span x-text="a.name"></span>
</a>
</template>
</div>
</div>
</div>
</template>
<div class="wr-bubble is-agent is-typing" x-show="typing" x-cloak>
<div class="wr-avatar">
<div class="agent-char-wrap"
:data-agent-avatar="typing && typing.agent_key"
x-effect="if (typing && window.AgentAvatars) AgentAvatars.paint($el, typing.agent_key)"></div>
</div>
<div class="wr-bubble-body">
<div class="wr-bubble-meta"><span x-text="(typing && typing.display_name) || 'Agent'"></span><span> · typt</span></div>
<div class="wr-bubble-text wr-typing-cursor" x-text="(typing && typing.text) || '…'"></div>
</div>
</div>
</div>
<div class="wr-composer">
<textarea class="form-input" rows="2" x-model="draft" @keydown="onKey($event)"
placeholder="Bericht… @agent" :disabled="busy || !room"></textarea>
<button type="button" class="btn btn-pulse-green" @click="send()" :disabled="busy || !room || !draft.trim()">Stuur</button>
<div class="wr-composer-tools">
<button type="button" class="btn btn-sm" :class="{'is-recording': voiceRecording}" @mousedown.prevent="startVoiceTalk($event)" @mouseup.prevent="stopVoiceTalk($event)" @touchstart.prevent="startVoiceTalk($event)" @touchend.prevent="stopVoiceTalk($event)" :disabled="voiceBusy || busy" title="Houd in om te praten">🎤</button>
<button type="button" class="btn btn-sm" @click="emojiOpen=!emojiOpen" :disabled="busy">😊</button>
<label class="btn btn-sm" style="cursor:pointer;margin:0">
📎
<input type="file" hidden multiple @change="onPickFiles($event)"
accept=".png,.jpg,.jpeg,.gif,.webp,.pdf,.txt,.csv,.xlsx,.xls,.doc,.docx,.zip,.json,.md,image/*" />
</label>
<div class="wr-emoji-pop" x-show="emojiOpen" @click.outside="emojiOpen=false" x-cloak>
<template x-for="em in emojis" :key="em">
<button type="button" @click="insertEmoji(em)" x-text="em"></button>
</template>
</div>
</div>
<div class="wr-pending" x-show="pendingFiles.length">
<template x-for="(f, fi) in pendingFiles" :key="f.url">
<span class="wr-pending-chip">
<span x-text="f.name"></span>
<button type="button" @click="removePending(fi)">×</button>
</span>
</template>
</div>
<div class="wr-composer-wrap">
<div class="wr-mention-pop" x-show="mentionOpen && mentionSuggestions().length" x-cloak>
<template x-for="(s, si) in mentionSuggestions()" :key="'pm-'+s.agent_key">
<button type="button" class="wr-mention-item"
:class="{active: si===mentionIndex}"
@mousedown.prevent="pickMention(s)">
<strong x-text="s.display_name"></strong>
<small x-text="'@'+s.agent_key"></small>
</button>
</template>
</div>
<textarea class="form-input" rows="2" x-ref="wrInput" x-model="draft"
@input="onDraftInput()" @keydown="onKey($event)"
placeholder="Typ @bizdev… antwoord komt in deze War Room"
:disabled="!room"></textarea>
</div>
<button type="button" class="btn btn-pulse-green" @click="send()" :disabled="!canSend()">Stuur</button>
</div>
<div class="wr-voice-overlay" :class="{ 'is-open': voiceRoomOpen }" x-show="voiceRoomOpen" x-cloak @keydown.escape.window="closeVoiceRoom()">
<div class="wr-voice-panel" @click.stop>
<div class="wr-voice-head">
<div>
<h3>🎙️ Foodlinkk Voice Room</h3>
<small class="muted">Jij + agents in één gesprek (spraak)</small>
</div>
<div class="wr-voice-head-actions">
<label class="btn btn-sm" style="display:inline-flex;align-items:center;gap:0.25rem;margin:0;cursor:pointer">
<input type="checkbox" x-model="speakReplies" style="margin:0" /> 🔊 Voorlezen
</label>
<button type="button" class="btn btn-sm btn-pulse" @click="closeVoiceRoom()">Sluiten</button>
</div>
</div>
<div class="wr-voice-status" x-text="status || (voiceRecording ? 'Luisteren…' : (voiceSpeakingKey ? (voiceSpeakingKey + ' spreekt…') : 'Houd de microfoon in om te praten'))"></div>
<div class="wr-voice-grid">
<div class="wr-voice-card is-human" :class="{'is-live': voiceRecording}">
<div class="wr-voice-avatar wr-voice-avatar-human" x-text="(me.username||'Jij').slice(0,2).toUpperCase()"></div>
<strong x-text="me.username || 'Jij'"></strong>
<small x-show="voiceRecording">🎤 aan het woord</small>
<small x-show="!voiceRecording" class="muted">mens</small>
</div>
<template x-for="s in voiceAgentSouls()" :key="'vr-'+s.agent_key">
<div class="wr-voice-card is-agent"
:class="{'is-live': voiceSpeakingKey===s.agent_key, 'is-off': !isVoiceAgent(s.agent_key)}"
@click="toggleVoiceAgent(s.agent_key)"
:title="isVoiceAgent(s.agent_key) ? 'Klik = uit meeting' : 'Klik = in meeting'">
<div class="wr-voice-avatar agent-char-wrap"
:data-agent-avatar="s.agent_key"
x-init="$nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, s.agent_key))"></div>
<strong x-text="s.display_name || s.agent_key"></strong>
<small x-show="voiceSpeakingKey===s.agent_key">🔊 spreekt</small>
<small x-show="voiceSpeakingKey!==s.agent_key" class="muted" x-text="isVoiceAgent(s.agent_key) ? ('@'+s.agent_key) : 'niet in meeting'"></small>
</div>
</template>
</div>
<p class="wr-voice-hint" x-show="voiceLastHeard">Laatst gehoord: <span x-text="voiceLastHeard"></span></p>
<div class="wr-voice-ptt">
<button type="button" class="wr-ptt-btn"
:class="{'is-recording': voiceRecording, 'is-busy': voiceBusy}"
@mousedown.prevent="startVoiceTalk($event)" @mouseup.prevent="stopVoiceTalk($event)"
@touchstart.prevent="startVoiceTalk($event)" @touchend.prevent="stopVoiceTalk($event)"
@mouseleave="voiceRecording && stopVoiceTalk($event)"
:disabled="voiceBusy || busy">
<span x-show="!voiceRecording && !voiceBusy">🎤 Ingedrukt houden</span>
<span x-show="voiceRecording">● Luisteren… laat los</span>
<span x-show="voiceBusy && !voiceRecording">⏳ Bezig…</span>
</button>
</div>
<div class="wr-voice-foot">
<span class="muted">Klik agents om ze in/uit de meeting te zetten. Standaard praat @herman; 🔊 leest antwoorden voor.</span>
<a class="btn btn-sm" href="https://meet.ffmuc.net/FoodlinkkWR1" :href="callHref()" target="_blank" rel="noopener">Alleen-mensen video ↗</a>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/static/js/cockpit.js?v=3"></script>
<script src="/static/js/agent-avatars.js?v=4"></script>
<script src="/static/js/agents-warroom.js?v=4"></script>
<!-- agents-warroom loaded in head (before alpine) -->
</body>
</html>
+28 -3
View File
@@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="nl">
<script>(function(){try{var t=localStorage.getItem('flk-theme');if(t)document.documentElement.setAttribute('data-theme',t);var m=document.querySelector('meta[name=theme-color]');}catch(e){}})();</script>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=5" />
@@ -21,13 +22,13 @@
<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/flk-themes.css?v=1" />
<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/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/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=12" />
@@ -528,6 +529,30 @@
}, 2500);
});
</script>
{% block scripts %}{% endblock %}
<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' },
{ id: 'cobalt', name: 'Cobalt Pulse', desc: 'Kobaltblauw met aqua en amber' }
],
current: function () {
try { return localStorage.getItem('flk-theme') || 'ocean'; } catch (e) { return 'ocean'; }
},
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;
}
};
if (!document.documentElement.getAttribute('data-theme')) {
window.FlkThemes.apply(window.FlkThemes.current());
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+48
View File
@@ -15,6 +15,7 @@
{% if can_manage_users %}
<a href="/settings?tab=users" class="settings-tab {% if active_tab == 'users' %}active{% endif %}">Gebruikers</a>
{% endif %}
<a href="/settings?tab=themes" class="settings-tab {% if active_tab == 'themes' %}active{% endif %}">Themes</a>
<a href="/settings?tab=general" class="settings-tab {% if active_tab == 'general' %}active{% endif %}">General</a>
</nav>
@@ -282,6 +283,26 @@
</template>
</div>
</section>
{% elif active_tab == 'themes' %}
<section class="panel settings-panel" x-data="flkThemesPage()" x-init="init()">
<div class="settings-panel-head">
<div>
<h2>UI Themes</h2>
<p class="page-tip">Kies een kleurrijk cyaan/blauw thema voor de hele cockpit. Direct toegepast op alle pagina's.</p>
</div>
</div>
<div class="flk-theme-grid">
<template x-for="t in themes" :key="t.id">
<button type="button" class="flk-theme-card" :class="{ 'is-active': current === t.id }" @click="select(t.id)">
<div class="flk-theme-swatch" :style="swatch(t.id)"></div>
<strong x-text="t.name"></strong>
<small x-text="t.desc"></small>
</button>
</template>
</div>
</section>
{% elif active_tab == 'general' %}
<section class="panel settings-panel">
<h2>General</h2>
@@ -293,6 +314,33 @@
{% endblock %}
{% block scripts %}
<script>
function flkThemesPage() {
return {
themes: (window.FlkThemes && window.FlkThemes.list) || [],
current: 'ocean',
init() {
this.current = (window.FlkThemes && window.FlkThemes.current()) || 'ocean';
if (window.FlkThemes) window.FlkThemes.apply(this.current);
},
select(id) {
this.current = window.FlkThemes.apply(id);
if (window.Cockpit) Cockpit.toast('Theme: ' + id, 'ok');
},
swatch(id) {
var map = {
ocean: 'linear-gradient(135deg,#07131f,#123049 40%,#22d3ee)',
aurora: 'linear-gradient(135deg,#0a1020,#1a2550 45%,#a78bfa 75%,#22d3ee)',
neon: 'linear-gradient(135deg,#050b14,#10243f 40%,#06b6d4 70%,#e879f9)',
glacier: 'linear-gradient(135deg,#0b1522,#1a2f4a 45%,#38bdf8 80%,#7dd3fc)',
cobalt: 'linear-gradient(135deg,#08101c,#172642 40%,#3b82f6 70%,#22d3ee)'
};
return 'background:' + (map[id] || map.ocean);
}
};
}
</script>
<script>
function socialSettingsPage() {
const META = {