Add public assets route and improve chat initialization

- Add /public static file route for themes and shared assets
- Fix chat app initialization for dynamic script loading
- Add ThemeManager with dropdown selector for 19 theme variants
- Improve module lazy loading with better retry logic
- Add loading overlay for smoother app startup
- Refactor app.css to use unified theme variables
- Format code and organize imports consistently
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2025-11-20 20:39:20 -03:00
parent 37a15ea9e0
commit eb4084fe2d
8 changed files with 1599 additions and 212 deletions

View file

@ -137,6 +137,7 @@ async fn run_axum_server(
// Static file services must come first to match before other routes
.nest_service("/js", ServeDir::new(static_path.join("js")))
.nest_service("/css", ServeDir::new(static_path.join("css")))
.nest_service("/public", ServeDir::new(static_path.join("public")))
.nest_service("/drive", ServeDir::new(static_path.join("drive")))
.nest_service("/chat", ServeDir::new(static_path.join("chat")))
.nest_service("/mail", ServeDir::new(static_path.join("mail")))

View file

@ -1,19 +1,23 @@
use axum::{
Router,
routing::get,
response::{Html, IntoResponse},
http::StatusCode,
response::{Html, IntoResponse},
routing::get,
Router,
};
use tower_http::services::ServeDir;
use log::error;
use std::{fs, path::PathBuf};
use tower_http::services::ServeDir;
pub async fn index() -> impl IntoResponse {
match fs::read_to_string("web/desktop/index.html") {
Ok(html) => (StatusCode::OK, [("content-type", "text/html")], Html(html)),
Err(e) => {
error!("Failed to load index page: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, [("content-type", "text/plain")], Html("Failed to load index page".to_string()))
(
StatusCode::INTERNAL_SERVER_ERROR,
[("content-type", "text/plain")],
Html("Failed to load index page".to_string()),
)
}
}
}
@ -26,14 +30,17 @@ pub fn configure_router() -> Router {
.nest_service("/js", ServeDir::new(static_path.join("js")))
// Serve CSS files
.nest_service("/css", ServeDir::new(static_path.join("css")))
// Serve public assets (themes, etc.)
.nest_service("/public", ServeDir::new(static_path.join("public")))
.nest_service("/drive", ServeDir::new(static_path.join("drive")))
.nest_service("/chat", ServeDir::new(static_path.join("chat")))
.nest_service("/mail", ServeDir::new(static_path.join("mail")))
.nest_service("/tasks", ServeDir::new(static_path.join("tasks")))
// Fallback: serve static files and index.html for SPA routing
.fallback_service(
ServeDir::new(static_path.clone())
.fallback(ServeDir::new(static_path.clone()).append_index_html_on_directories(true))
ServeDir::new(static_path.clone()).fallback(
ServeDir::new(static_path.clone()).append_index_html_on_directories(true),
),
)
.route("/", get(index))
}

View file

@ -177,7 +177,7 @@ function chatApp() {
}
isInitialized = true;
window.addEventListener("load", () => {
const initializeDOM = () => {
// Assign DOM elements after the document is ready
messagesDiv = document.getElementById("messages");
@ -241,7 +241,15 @@ function chatApp() {
// Initialize auth only once
this.initializeAuth();
});
};
// Check if DOM is already loaded (for dynamic script loading)
if (document.readyState === "loading") {
window.addEventListener("load", initializeDOM);
} else {
// DOM is already loaded, initialize immediately
initializeDOM();
}
},
flashScreen() {
@ -971,8 +979,32 @@ function chatApp() {
return returnValue;
}
// Initialize the app
chatApp().init();
// Initialize the app - expose globally for dynamic loading
window.chatAppInstance = chatApp();
// Auto-initialize if we're already on the chat section
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
const hash = window.location.hash.substring(1);
if (hash === "chat" || hash === "" || !hash) {
window.chatAppInstance.init();
}
});
} else {
// If script is loaded dynamically, section-shown event will trigger init
const chatSection = document.getElementById("section-chat");
if (chatSection) {
window.chatAppInstance.init();
}
}
// Listen for section being shown
document.addEventListener("section-shown", function (e) {
if (e.target.id === "section-chat" && window.chatAppInstance) {
console.log("Chat section shown, initializing...");
window.chatAppInstance.init();
}
});
// Listen for section changes to cleanup when leaving chat
document.addEventListener("section-hidden", function (e) {

View file

@ -0,0 +1,986 @@
// Singleton instance to prevent multiple initializations
let chatAppInstance = null;
function chatApp() {
// Return existing instance if already created
if (chatAppInstance) {
console.log("Returning existing chatApp instance");
return chatAppInstance;
}
console.log("Creating new chatApp instance");
// Core state variables (shared via closure)
let ws = null,
pendingContextChange = null,
o,
isConnecting = false,
isInitialized = false,
authPromise = null;
((currentSessionId = null),
(currentUserId = null),
(currentBotId = "default_bot"),
(isStreaming = false),
(voiceRoom = null),
(isVoiceMode = false),
(mediaRecorder = null),
(audioChunks = []),
(streamingMessageId = null),
(isThinking = false),
(currentStreamingContent = ""),
(hasReceivedInitialMessage = false),
(reconnectAttempts = 0),
(reconnectTimeout = null),
(thinkingTimeout = null),
(currentTheme = "auto"),
(themeColor1 = null),
(themeColor2 = null),
(customLogoUrl = null),
(contextUsage = 0),
(isUserScrolling = false),
(autoScrollEnabled = true),
(isContextChange = false));
const maxReconnectAttempts = 5;
// DOM references (cached for performance)
let messagesDiv,
messageInputEl,
sendBtn,
voiceBtn,
connectionStatus,
flashOverlay,
suggestionsContainer,
floatLogo,
sidebar,
themeBtn,
scrollToBottomBtn,
sidebarTitle;
marked.setOptions({ breaks: true, gfm: true });
return {
// ----------------------------------------------------------------------
// UI state (mirrors the structure used in driveApp)
// ----------------------------------------------------------------------
current: "All Chats",
search: "",
selectedChat: null,
navItems: [
{ name: "All Chats", icon: "💬" },
{ name: "Direct", icon: "👤" },
{ name: "Groups", icon: "👥" },
{ name: "Archived", icon: "🗄" },
],
chats: [
{
id: 1,
name: "General Bot Support",
icon: "🤖",
lastMessage: "How can I help you?",
time: "10:15 AM",
status: "Online",
},
{
id: 2,
name: "Project Alpha",
icon: "🚀",
lastMessage: "Launch scheduled for tomorrow.",
time: "Yesterday",
status: "Active",
},
{
id: 3,
name: "Team Standup",
icon: "🗣️",
lastMessage: "Done with the UI updates.",
time: "2 hrs ago",
status: "Active",
},
{
id: 4,
name: "Random Chat",
icon: "🎲",
lastMessage: "Did you see the game last night?",
time: "5 hrs ago",
status: "Idle",
},
{
id: 5,
name: "Support Ticket #1234",
icon: "🛠️",
lastMessage: "Issue resolved, closing ticket.",
time: "3 days ago",
status: "Closed",
},
],
get filteredChats() {
return this.chats.filter((chat) =>
chat.name.toLowerCase().includes(this.search.toLowerCase()),
);
},
// ----------------------------------------------------------------------
// UI helpers (formerly standalone functions)
// ----------------------------------------------------------------------
toggleSidebar() {
sidebar.classList.toggle("open");
},
toggleTheme() {
const themes = ["auto", "dark", "light"];
const savedTheme = localStorage.getItem("gb-theme") || "auto";
const idx = themes.indexOf(savedTheme);
const newTheme = themes[(idx + 1) % themes.length];
localStorage.setItem("gb-theme", newTheme);
currentTheme = newTheme;
this.applyTheme();
this.updateThemeButton();
},
applyTheme() {
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches;
let theme = currentTheme;
if (theme === "auto") {
theme = prefersDark ? "dark" : "light";
}
document.documentElement.setAttribute("data-theme", theme);
if (themeColor1 && themeColor2) {
const root = document.documentElement;
root.style.setProperty(
"--bg",
theme === "dark" ? themeColor2 : themeColor1,
);
root.style.setProperty(
"--fg",
theme === "dark" ? themeColor1 : themeColor2,
);
}
if (customLogoUrl) {
document.documentElement.style.setProperty(
"--logo-url",
`url('${customLogoUrl}')`,
);
}
},
// ----------------------------------------------------------------------
// Lifecycle / event handlers
// ----------------------------------------------------------------------
init() {
// Prevent multiple initializations
if (isInitialized) {
console.log("Already initialized, skipping...");
return;
}
isInitialized = true;
window.addEventListener("load", () => {
// Assign DOM elements after the document is ready
messagesDiv = document.getElementById("messages");
messageInputEl = document.getElementById("messageInput");
sendBtn = document.getElementById("sendBtn");
voiceBtn = document.getElementById("voiceBtn");
connectionStatus = document.getElementById("connectionStatus");
flashOverlay = document.getElementById("flashOverlay");
suggestionsContainer = document.getElementById("suggestions");
floatLogo = document.getElementById("floatLogo");
sidebar = document.getElementById("sidebar");
themeBtn = document.getElementById("themeBtn");
scrollToBottomBtn = document.getElementById("scrollToBottom");
sidebarTitle = document.getElementById("sidebarTitle");
// Theme initialization and focus
const savedTheme = localStorage.getItem("gb-theme") || "auto";
currentTheme = savedTheme;
this.applyTheme();
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", () => {
if (currentTheme === "auto") {
this.applyTheme();
}
});
if (messageInputEl) {
messageInputEl.focus();
}
// UI event listeners
document.addEventListener("click", (e) => {});
// Scroll detection
if (messagesDiv && scrollToBottomBtn) {
messagesDiv.addEventListener("scroll", () => {
const isAtBottom =
messagesDiv.scrollHeight - messagesDiv.scrollTop <=
messagesDiv.clientHeight + 100;
if (!isAtBottom) {
isUserScrolling = true;
scrollToBottomBtn.classList.add("visible");
} else {
isUserScrolling = false;
scrollToBottomBtn.classList.remove("visible");
}
});
scrollToBottomBtn.addEventListener("click", () => {
this.scrollToBottom();
});
}
sendBtn.onclick = () => this.sendMessage();
messageInputEl.addEventListener("keypress", (e) => {
if (e.key === "Enter") this.sendMessage();
});
// Don't auto-reconnect on focus in browser to prevent multiple connections
// Tauri doesn't fire focus events the same way
// Initialize auth only once
this.initializeAuth();
});
},
flashScreen() {
gsap.to(flashOverlay, {
opacity: 0.15,
duration: 0.1,
onComplete: () => {
gsap.to(flashOverlay, { opacity: 0, duration: 0.2 });
},
});
},
updateConnectionStatus(s) {
connectionStatus.className = `connection-status ${s}`;
},
getWebSocketUrl() {
const p = "ws:",
s = currentSessionId || crypto.randomUUID(),
u = currentUserId || crypto.randomUUID();
return `${p}//localhost:8080/ws?session_id=${s}&user_id=${u}`;
},
async initializeAuth() {
// Return existing promise if auth is in progress
if (authPromise) {
console.log("Auth already in progress, waiting...");
return authPromise;
}
// Already authenticated
if (
currentSessionId &&
currentUserId &&
ws &&
ws.readyState === WebSocket.OPEN
) {
console.log("Already authenticated and connected");
return;
}
// Create auth promise to prevent concurrent calls
authPromise = (async () => {
try {
this.updateConnectionStatus("connecting");
const p = window.location.pathname.split("/").filter((s) => s);
const b = p.length > 0 ? p[0] : "default";
const r = await fetch(
`http://localhost:8080/api/auth?bot_name=${encodeURIComponent(b)}`,
);
const a = await r.json();
currentUserId = a.user_id;
currentSessionId = a.session_id;
console.log("Auth successful:", { currentUserId, currentSessionId });
this.connectWebSocket();
} catch (e) {
console.error("Failed to initialize auth:", e);
this.updateConnectionStatus("disconnected");
authPromise = null;
setTimeout(() => this.initializeAuth(), 3000);
} finally {
authPromise = null;
}
})();
return authPromise;
},
async loadSessions() {
try {
const r = await fetch("http://localhost:8080/api/sessions");
const s = await r.json();
const h = document.getElementById("history");
h.innerHTML = "";
s.forEach((session) => {
const item = document.createElement("div");
item.className = "history-item";
item.textContent =
session.title || `Session ${session.session_id.substring(0, 8)}`;
item.onclick = () => this.switchSession(session.session_id);
h.appendChild(item);
});
} catch (e) {
console.error("Failed to load sessions:", e);
}
},
async createNewSession() {
try {
const r = await fetch("http://localhost:8080/api/sessions", {
method: "POST",
});
const s = await r.json();
currentSessionId = s.session_id;
hasReceivedInitialMessage = false;
this.connectWebSocket();
this.loadSessions();
messagesDiv.innerHTML = "";
this.clearSuggestions();
if (isVoiceMode) {
await this.stopVoiceSession();
isVoiceMode = false;
const v = document.getElementById("voiceToggle");
v.textContent = "🎤 Voice Mode";
voiceBtn.classList.remove("recording");
}
} catch (e) {
console.error("Failed to create session:", e);
}
},
switchSession(s) {
currentSessionId = s;
hasReceivedInitialMessage = false;
this.connectWebSocket();
if (isVoiceMode) {
this.startVoiceSession();
}
sidebar.classList.remove("open");
},
connectWebSocket() {
// Prevent multiple simultaneous connection attempts
if (isConnecting) {
console.log("Already connecting to WebSocket, skipping...");
return;
}
if (
ws &&
(ws.readyState === WebSocket.OPEN ||
ws.readyState === WebSocket.CONNECTING)
) {
console.log("WebSocket already connected or connecting");
return;
}
if (ws && ws.readyState !== WebSocket.CLOSED) {
ws.close();
}
clearTimeout(reconnectTimeout);
isConnecting = true;
const u = this.getWebSocketUrl();
console.log("Connecting to WebSocket:", u);
ws = new WebSocket(u);
ws.onmessage = (e) => {
const r = JSON.parse(e.data);
// Filter out welcome/connection messages that aren't BotResponse
if (r.type === "connected" || !r.message_type) {
console.log("Ignoring non-message:", r);
return;
}
if (r.bot_id) {
currentBotId = r.bot_id;
}
// Message type 2 is a bot response (not an event)
// Message type 5 is context change
if (r.message_type === 5) {
isContextChange = true;
return;
}
// Check if this is a special event message (has event field)
if (r.event) {
this.handleEvent(r.event, r.data || {});
return;
}
this.processMessageContent(r);
};
ws.onopen = () => {
console.log("Connected to WebSocket");
isConnecting = false;
this.updateConnectionStatus("connected");
reconnectAttempts = 0;
hasReceivedInitialMessage = false;
};
ws.onclose = (e) => {
console.log("WebSocket disconnected:", e.code, e.reason);
isConnecting = false;
this.updateConnectionStatus("disconnected");
if (isStreaming) {
this.showContinueButton();
}
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
const d = Math.min(1000 * reconnectAttempts, 10000);
reconnectTimeout = setTimeout(() => {
this.updateConnectionStatus("connecting");
this.connectWebSocket();
}, d);
} else {
this.updateConnectionStatus("disconnected");
}
};
ws.onerror = (e) => {
console.error("WebSocket error:", e);
isConnecting = false;
this.updateConnectionStatus("disconnected");
};
},
processMessageContent(r) {
if (isContextChange) {
isContextChange = false;
return;
}
// Ignore messages without content
if (!r.content && r.is_complete !== true) {
return;
}
if (r.suggestions && r.suggestions.length > 0) {
this.handleSuggestions(r.suggestions);
}
if (r.is_complete) {
if (isStreaming) {
this.finalizeStreamingMessage();
isStreaming = false;
streamingMessageId = null;
currentStreamingContent = "";
} else if (r.content) {
// Only add message if there's actual content
this.addMessage("assistant", r.content, false);
}
} else {
if (!isStreaming) {
isStreaming = true;
streamingMessageId = "streaming-" + Date.now();
currentStreamingContent = r.content || "";
this.addMessage(
"assistant",
currentStreamingContent,
true,
streamingMessageId,
);
} else {
currentStreamingContent += r.content || "";
this.updateStreamingMessage(currentStreamingContent);
}
}
},
handleEvent(t, d) {
console.log("Event received:", t, d);
switch (t) {
case "thinking_start":
this.showThinkingIndicator();
break;
case "thinking_end":
this.hideThinkingIndicator();
break;
case "warn":
this.showWarning(d.message);
break;
case "context_usage":
// Context usage removed
break;
case "change_theme":
if (d.color1) themeColor1 = d.color1;
if (d.color2) themeColor2 = d.color2;
if (d.logo_url) customLogoUrl = d.logo_url;
if (d.title) document.title = d.title;
if (d.logo_text) {
sidebarTitle.textContent = d.logo_text;
}
this.applyTheme();
break;
}
},
showThinkingIndicator() {
if (isThinking) return;
const t = document.createElement("div");
t.id = "thinking-indicator";
t.className = "message-container";
t.innerHTML = `<div class="assistant-message"><div class="assistant-avatar"></div><div class="thinking-indicator"><div class="typing-dots"><div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div></div></div></div>`;
messagesDiv.appendChild(t);
gsap.to(t, { opacity: 1, y: 0, duration: 0.3, ease: "power2.out" });
if (!isUserScrolling) {
this.scrollToBottom();
}
thinkingTimeout = setTimeout(() => {
if (isThinking) {
this.hideThinkingIndicator();
this.showWarning(
"O servidor pode estar ocupado. A resposta está demorando demais.",
);
}
}, 60000);
isThinking = true;
},
hideThinkingIndicator() {
if (!isThinking) return;
const t = document.getElementById("thinking-indicator");
if (t) {
gsap.to(t, {
opacity: 0,
duration: 0.2,
onComplete: () => {
if (t.parentNode) {
t.remove();
}
},
});
}
if (thinkingTimeout) {
clearTimeout(thinkingTimeout);
thinkingTimeout = null;
}
isThinking = false;
},
showWarning(m) {
const w = document.createElement("div");
w.className = "warning-message";
w.innerHTML = `⚠️ ${m}`;
messagesDiv.appendChild(w);
gsap.from(w, { opacity: 0, y: 20, duration: 0.4, ease: "power2.out" });
if (!isUserScrolling) {
this.scrollToBottom();
}
setTimeout(() => {
if (w.parentNode) {
gsap.to(w, {
opacity: 0,
duration: 0.3,
onComplete: () => w.remove(),
});
}
}, 5000);
},
showContinueButton() {
const c = document.createElement("div");
c.className = "message-container";
c.innerHTML = `<div class="assistant-message"><div class="assistant-avatar"></div><div class="assistant-message-content"><p>A conexão foi interrompida. Clique em "Continuar" para tentar recuperar a resposta.</p><button class="continue-button" onclick="this.parentElement.parentElement.parentElement.remove();">Continuar</button></div></div>`;
messagesDiv.appendChild(c);
gsap.to(c, { opacity: 1, y: 0, duration: 0.5, ease: "power2.out" });
if (!isUserScrolling) {
this.scrollToBottom();
}
},
continueInterruptedResponse() {
if (!ws || ws.readyState !== WebSocket.OPEN) {
this.connectWebSocket();
}
if (ws && ws.readyState === WebSocket.OPEN) {
const d = {
bot_id: "default_bot",
user_id: currentUserId,
session_id: currentSessionId,
channel: "web",
content: "continue",
message_type: 3,
media_url: null,
timestamp: new Date().toISOString(),
};
ws.send(JSON.stringify(d));
}
document.querySelectorAll(".continue-button").forEach((b) => {
b.parentElement.parentElement.parentElement.remove();
});
},
addMessage(role, content, streaming = false, msgId = null) {
const m = document.createElement("div");
m.className = "message-container";
if (role === "user") {
m.innerHTML = `<div class="user-message"><div class="user-message-content">${this.escapeHtml(content)}</div></div>`;
} else if (role === "assistant") {
m.innerHTML = `<div class="assistant-message"><div class="assistant-avatar"></div><div class="assistant-message-content markdown-content" id="${msgId || ""}">${streaming ? "" : marked.parse(content)}</div></div>`;
} else if (role === "voice") {
m.innerHTML = `<div class="assistant-message"><div class="assistant-avatar">🎤</div><div class="assistant-message-content">${content}</div></div>`;
} else {
m.innerHTML = `<div class="assistant-message"><div class="assistant-avatar"></div><div class="assistant-message-content">${content}</div></div>`;
}
messagesDiv.appendChild(m);
gsap.to(m, { opacity: 1, y: 0, duration: 0.5, ease: "power2.out" });
if (!isUserScrolling) {
this.scrollToBottom();
}
},
updateStreamingMessage(c) {
const m = document.getElementById(streamingMessageId);
if (m) {
m.innerHTML = marked.parse(c);
if (!isUserScrolling) {
this.scrollToBottom();
}
}
},
finalizeStreamingMessage() {
const m = document.getElementById(streamingMessageId);
if (m) {
m.innerHTML = marked.parse(currentStreamingContent);
m.removeAttribute("id");
if (!isUserScrolling) {
this.scrollToBottom();
}
}
},
escapeHtml(t) {
const d = document.createElement("div");
d.textContent = t;
return d.innerHTML;
},
clearSuggestions() {
suggestionsContainer.innerHTML = "";
},
handleSuggestions(s) {
const uniqueSuggestions = s.filter(
(v, i, a) =>
i ===
a.findIndex((t) => t.text === v.text && t.context === v.context),
);
suggestionsContainer.innerHTML = "";
uniqueSuggestions.forEach((v) => {
const b = document.createElement("button");
b.textContent = v.text;
b.className = "suggestion-button";
b.onclick = () => {
this.setContext(v.context);
messageInputEl.value = "";
};
suggestionsContainer.appendChild(b);
});
},
async setContext(c) {
try {
const t = event?.target?.textContent || c;
this.addMessage("user", t);
messageInputEl.value = "";
messageInputEl.value = "";
if (ws && ws.readyState === WebSocket.OPEN) {
pendingContextChange = new Promise((r) => {
const h = (e) => {
const d = JSON.parse(e.data);
if (d.message_type === 5 && d.context_name === c) {
ws.removeEventListener("message", h);
r();
}
};
ws.addEventListener("message", h);
const s = {
bot_id: currentBotId,
user_id: currentUserId,
session_id: currentSessionId,
channel: "web",
content: t,
message_type: 4,
is_suggestion: true,
context_name: c,
timestamp: new Date().toISOString(),
};
ws.send(JSON.stringify(s));
});
await pendingContextChange;
} else {
console.warn("WebSocket não está conectado. Tentando reconectar...");
this.connectWebSocket();
}
} catch (err) {
console.error("Failed to set context:", err);
}
},
async sendMessage() {
if (pendingContextChange) {
await pendingContextChange;
pendingContextChange = null;
}
const m = messageInputEl.value.trim();
if (!m || !ws || ws.readyState !== WebSocket.OPEN) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
this.showWarning("Conexão não disponível. Tentando reconectar...");
this.connectWebSocket();
}
return;
}
if (isThinking) {
this.hideThinkingIndicator();
}
this.addMessage("user", m);
const d = {
bot_id: currentBotId,
user_id: currentUserId,
session_id: currentSessionId,
channel: "web",
content: m,
message_type: 1,
media_url: null,
timestamp: new Date().toISOString(),
};
ws.send(JSON.stringify(d));
messageInputEl.value = "";
messageInputEl.focus();
},
async toggleVoiceMode() {
isVoiceMode = !isVoiceMode;
const v = document.getElementById("voiceToggle");
if (isVoiceMode) {
v.textContent = "🔴 Stop Voice";
v.classList.add("recording");
await this.startVoiceSession();
} else {
v.textContent = "🎤 Voice Mode";
v.classList.remove("recording");
await this.stopVoiceSession();
}
},
async startVoiceSession() {
if (!currentSessionId) return;
try {
const r = await fetch("http://localhost:8080/api/voice/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: currentSessionId,
user_id: currentUserId,
}),
});
const d = await r.json();
if (d.token) {
await this.connectToVoiceRoom(d.token);
this.startVoiceRecording();
}
} catch (e) {
console.error("Failed to start voice session:", e);
this.showWarning("Falha ao iniciar modo de voz");
}
},
async stopVoiceSession() {
if (!currentSessionId) return;
try {
await fetch("http://localhost:8080/api/voice/stop", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: currentSessionId }),
});
if (voiceRoom) {
voiceRoom.disconnect();
voiceRoom = null;
}
if (mediaRecorder && mediaRecorder.state === "recording") {
mediaRecorder.stop();
}
} catch (e) {
console.error("Failed to stop voice session:", e);
}
},
async connectToVoiceRoom(t) {
try {
const r = new LiveKitClient.Room();
const p = "ws:",
u = `${p}//localhost:8080/voice`;
await r.connect(u, t);
voiceRoom = r;
r.on("dataReceived", (d) => {
const dc = new TextDecoder(),
m = dc.decode(d);
try {
const j = JSON.parse(m);
if (j.type === "voice_response") {
this.addMessage("assistant", j.text);
}
} catch (e) {
console.log("Voice data:", m);
}
});
const l = await LiveKitClient.createLocalTracks({
audio: true,
video: false,
});
for (const k of l) {
await r.localParticipant.publishTrack(k);
}
} catch (e) {
console.error("Failed to connect to voice room:", e);
this.showWarning("Falha na conexão de voz");
}
},
startVoiceRecording() {
if (!navigator.mediaDevices) {
console.log("Media devices not supported");
return;
}
navigator.mediaDevices
.getUserMedia({ audio: true })
.then((s) => {
mediaRecorder = new MediaRecorder(s);
audioChunks = [];
mediaRecorder.ondataavailable = (e) => {
audioChunks.push(e.data);
};
mediaRecorder.onstop = () => {
const a = new Blob(audioChunks, { type: "audio/wav" });
this.simulateVoiceTranscription();
};
mediaRecorder.start();
setTimeout(() => {
if (mediaRecorder && mediaRecorder.state === "recording") {
mediaRecorder.stop();
setTimeout(() => {
if (isVoiceMode) {
this.startVoiceRecording();
}
}, 1000);
}
}, 5000);
})
.catch((e) => {
console.error("Error accessing microphone:", e);
this.showWarning("Erro ao acessar microfone");
});
},
simulateVoiceTranscription() {
const p = [
"Olá, como posso ajudá-lo hoje?",
"Entendo o que você está dizendo",
"Esse é um ponto interessante",
"Deixe-me pensar sobre isso",
"Posso ajudá-lo com isso",
"O que você gostaria de saber?",
"Isso parece ótimo",
"Estou ouvindo sua voz",
];
const r = p[Math.floor(Math.random() * p.length)];
if (voiceRoom) {
const m = {
type: "voice_input",
content: r,
timestamp: new Date().toISOString(),
};
voiceRoom.localParticipant.publishData(
new TextEncoder().encode(JSON.stringify(m)),
LiveKitClient.DataPacketKind.RELIABLE,
);
}
this.addMessage("voice", `🎤 ${r}`);
},
scrollToBottom() {
if (messagesDiv) {
messagesDiv.scrollTop = messagesDiv.scrollHeight;
isUserScrolling = false;
if (scrollToBottomBtn) {
scrollToBottomBtn.classList.remove("visible");
}
}
},
};
const returnValue = {
init: init,
current: current,
search: search,
selectedChat: selectedChat,
navItems: navItems,
chats: chats,
get filteredChats() {
return chats.filter((chat) =>
chat.name.toLowerCase().includes(search.toLowerCase()),
);
},
toggleSidebar: toggleSidebar,
toggleTheme: toggleTheme,
applyTheme: applyTheme,
flashScreen: flashScreen,
updateConnectionStatus: updateConnectionStatus,
getWebSocketUrl: getWebSocketUrl,
initializeAuth: initializeAuth,
loadSessions: loadSessions,
createNewSession: createNewSession,
switchSession: switchSession,
connectWebSocket: connectWebSocket,
processMessageContent: processMessageContent,
handleEvent: handleEvent,
showThinkingIndicator: showThinkingIndicator,
hideThinkingIndicator: hideThinkingIndicator,
showWarning: showWarning,
showContinueButton: showContinueButton,
continueInterruptedResponse: continueInterruptedResponse,
addMessage: addMessage,
updateStreamingMessage: updateStreamingMessage,
finalizeStreamingMessage: finalizeStreamingMessage,
escapeHtml: escapeHtml,
clearSuggestions: clearSuggestions,
handleSuggestions: handleSuggestions,
setContext: setContext,
sendMessage: sendMessage,
toggleVoiceMode: toggleVoiceMode,
startVoiceSession: startVoiceSession,
stopVoiceSession: stopVoiceSession,
connectToVoiceRoom: connectToVoiceRoom,
startVoiceRecording: startVoiceRecording,
simulateVoiceTranscription: simulateVoiceTranscription,
scrollToBottom: scrollToBottom,
cleanup: function () {
// Cleanup WebSocket connection
if (ws) {
ws.close();
ws = null;
}
// Clear any pending timeouts/intervals
isConnecting = false;
isInitialized = false;
},
};
// Cache and return the singleton instance
chatAppInstance = returnValue;
return returnValue;
}
// Initialize the app
chatApp().init();
// Listen for section changes to cleanup when leaving chat
document.addEventListener("section-hidden", function (e) {
if (
e.target.id === "section-chat" &&
chatAppInstance &&
chatAppInstance.cleanup
) {
chatAppInstance.cleanup();
}
});

View file

@ -1,56 +1,32 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
/* App-specific styles for General Bots Desktop */
/* All modules (chat, drive, tasks, mail) use these unified theme variables */
@import url("global.css");
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: #0f172a;
color: #e2e8f0;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
sans-serif;
background: var(--primary-bg);
color: var(--primary-fg);
height: 100vh;
overflow: hidden;
}
/* Navbar */
nav {
background: #1e293b;
border-bottom: 2px solid #334155;
padding: 0 1rem;
display: flex;
align-items: center;
height: 60px;
gap: 0.5rem;
}
nav .logo {
font-size: 1.5rem;
font-weight: bold;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-right: auto;
}
nav a {
color: #94a3b8;
text-decoration: none;
padding: 0.75rem 1.25rem;
border-radius: 0.5rem;
transition: all 0.2s;
font-weight: 500;
}
nav a:hover {
background: #334155;
color: #e2e8f0;
}
nav a.active {
background: #3b82f6;
color: white;
transition:
background var(--transition-smooth),
color var(--transition-smooth);
}
/* Main Content */
#main-content {
height: calc(100vh - 60px);
height: 100vh;
overflow: hidden;
position: relative;
}
.content-section {
@ -63,121 +39,158 @@ nav a.active {
display: block;
}
/* Panel Styles */
/* Panel Styles - used across all modules */
.panel {
background: #1e293b;
border: 1px solid #334155;
border-radius: 0.5rem;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: var(--space-lg);
box-shadow: var(--shadow-sm);
backdrop-filter: blur(20px);
}
/* Drive Styles */
.panel:hover {
box-shadow: var(--shadow-md);
}
/* Drive Layout */
.drive-layout {
display: grid;
grid-template-columns: 250px 1fr 300px;
gap: 1rem;
padding: 1rem;
gap: var(--space-md);
padding: var(--space-lg);
height: 100%;
background: var(--primary-bg);
}
.drive-sidebar, .drive-details {
.drive-sidebar,
.drive-details {
overflow-y: auto;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: var(--space-md);
backdrop-filter: blur(20px);
}
.drive-main {
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
backdrop-filter: blur(20px);
}
.nav-item {
padding: 0.75rem 1rem;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 0.75rem;
gap: 12px;
cursor: pointer;
border-radius: 0.375rem;
margin: 0.25rem 0.5rem;
transition: background 0.2s;
border-radius: var(--radius-md);
margin: 4px 0;
transition: all var(--transition-fast);
color: var(--text-primary);
font-weight: 500;
}
.nav-item:hover {
background: #334155;
background: var(--bg-hover);
transform: translateX(2px);
}
.nav-item.active {
background: #3b82f6;
background: var(--accent-color);
color: white;
}
.file-list {
flex: 1;
overflow-y: auto;
padding: 1rem;
padding: var(--space-md);
}
.file-item {
padding: 1rem;
padding: var(--space-md);
display: flex;
align-items: center;
gap: 1rem;
gap: var(--space-md);
cursor: pointer;
border-radius: 0.375rem;
border-bottom: 1px solid #334155;
transition: background 0.2s;
border-radius: var(--radius-md);
border-bottom: 1px solid var(--border-light);
transition: all var(--transition-fast);
color: var(--text-primary);
}
.file-item:hover {
background: #334155;
background: var(--bg-hover);
transform: translateX(2px);
}
.file-item.selected {
background: #1e40af;
background: var(--accent-light);
border-color: var(--accent-color);
}
.file-icon {
font-size: 2rem;
font-size: 32px;
}
/* Tasks Styles */
/* Tasks Layout */
.tasks-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
padding: var(--space-xl);
padding-top: 80px;
}
.task-input {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
gap: var(--space-sm);
margin-bottom: var(--space-xl);
}
.task-input input {
flex: 1;
padding: 0.75rem;
background: #1e293b;
border: 1px solid #334155;
border-radius: 0.5rem;
color: #e2e8f0;
font-size: 1rem;
padding: 12px 20px;
background: var(--glass-bg);
border: 2px solid var(--border-color);
border-radius: var(--radius-lg);
color: var(--text-primary);
font-size: 15px;
transition: all var(--transition-fast);
backdrop-filter: blur(10px);
}
.task-input input:focus {
outline: none;
border-color: #3b82f6;
border-color: var(--accent-color);
box-shadow: 0 0 0 4px var(--accent-light);
}
.task-input input::placeholder {
color: var(--text-tertiary);
}
.task-input button {
padding: 0.75rem 1.5rem;
background: #3b82f6;
padding: 12px 24px;
background: var(--accent-color);
color: white;
border: none;
border-radius: 0.5rem;
border-radius: var(--radius-lg);
cursor: pointer;
font-weight: 600;
transition: background 0.2s;
font-size: 15px;
transition: all var(--transition-fast);
box-shadow: var(--shadow-sm);
}
.task-input button:hover {
background: #2563eb;
background: var(--accent-hover);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.task-list {
@ -185,14 +198,21 @@ nav a.active {
}
.task-item {
padding: 1rem;
padding: var(--space-md);
display: flex;
align-items: center;
gap: 1rem;
background: #1e293b;
border: 1px solid #334155;
border-radius: 0.5rem;
margin-bottom: 0.5rem;
gap: var(--space-md);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
margin-bottom: var(--space-sm);
transition: all var(--transition-fast);
backdrop-filter: blur(20px);
}
.task-item:hover {
transform: translateX(4px);
box-shadow: var(--shadow-sm);
}
.task-item.completed span {
@ -201,73 +221,98 @@ nav a.active {
}
.task-item input[type="checkbox"] {
width: 1.25rem;
height: 1.25rem;
width: 20px;
height: 20px;
cursor: pointer;
accent-color: var(--accent-color);
}
.task-item span {
flex: 1;
color: var(--text-primary);
font-size: 15px;
}
.task-item button {
background: #ef4444;
background: var(--error-color);
color: white;
border: none;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
padding: 8px 16px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.2s;
transition: all var(--transition-fast);
font-weight: 500;
}
.task-item button:hover {
background: #dc2626;
opacity: 0.9;
transform: scale(1.05);
}
.task-filters {
display: flex;
gap: 0.5rem;
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid #334155;
gap: var(--space-sm);
margin-top: var(--space-xl);
padding-top: var(--space-xl);
border-top: 1px solid var(--border-color);
}
.task-filters button {
padding: 0.5rem 1rem;
background: #334155;
color: #e2e8f0;
border: none;
border-radius: 0.375rem;
padding: 10px 20px;
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s;
transition: all var(--transition-fast);
font-weight: 500;
}
.task-filters button:hover {
background: var(--bg-hover);
transform: translateY(-2px);
}
.task-filters button.active {
background: #3b82f6;
background: var(--accent-color);
color: white;
border-color: var(--accent-color);
}
/* Mail Styles */
/* Mail Layout */
.mail-layout {
display: grid;
grid-template-columns: 250px 350px 1fr;
gap: 1rem;
padding: 1rem;
gap: var(--space-md);
padding: var(--space-lg);
height: 100%;
background: var(--primary-bg);
}
.mail-sidebar, .mail-list, .mail-content {
.mail-sidebar,
.mail-list,
.mail-content {
overflow-y: auto;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: var(--space-md);
backdrop-filter: blur(20px);
}
.mail-item {
padding: 1rem;
padding: var(--space-md);
cursor: pointer;
border-bottom: 1px solid #334155;
transition: background 0.2s;
border-bottom: 1px solid var(--border-light);
transition: all var(--transition-fast);
border-radius: var(--radius-sm);
margin-bottom: var(--space-xs);
color: var(--text-primary);
}
.mail-item:hover {
background: #334155;
background: var(--bg-hover);
transform: translateX(2px);
}
.mail-item.unread {
@ -275,50 +320,207 @@ nav a.active {
}
.mail-item.selected {
background: #1e40af;
background: var(--accent-light);
border-color: var(--accent-color);
}
.mail-content-view {
padding: 2rem;
padding: var(--space-lg);
}
.mail-header {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid #334155;
margin-bottom: var(--space-lg);
padding-bottom: var(--space-md);
border-bottom: 1px solid var(--border-color);
}
.mail-header h2 {
color: var(--text-primary);
margin-bottom: var(--space-sm);
}
.mail-header .text-sm {
color: var(--text-secondary);
}
.mail-body {
line-height: 1.6;
line-height: 1.7;
color: var(--text-primary);
}
/* Buttons */
button {
font-family: inherit;
/* Responsive adjustments for all modules */
@media (max-width: 1024px) {
.drive-layout {
grid-template-columns: 200px 1fr;
}
.drive-details {
display: none;
}
.mail-layout {
grid-template-columns: 200px 300px 1fr;
}
}
button:disabled {
@media (max-width: 768px) {
.drive-layout {
grid-template-columns: 1fr;
padding: var(--space-md);
}
.drive-sidebar,
.drive-details {
display: none;
}
.mail-layout {
grid-template-columns: 1fr;
padding: var(--space-md);
}
.mail-sidebar,
.mail-list {
display: none;
}
.tasks-container {
padding: var(--space-lg);
padding-top: 80px;
}
.task-input {
flex-direction: column;
}
.task-input button {
width: 100%;
}
}
@media (max-width: 480px) {
.tasks-container {
padding: var(--space-md);
padding-top: 70px;
}
.task-item {
padding: var(--space-sm);
font-size: 14px;
}
.task-filters {
flex-wrap: wrap;
}
}
/* Common utility classes for all modules */
.module-header {
padding: var(--space-lg);
border-bottom: 1px solid var(--border-color);
background: var(--glass-bg);
backdrop-filter: blur(20px);
}
.module-header h1 {
color: var(--text-primary);
font-size: 24px;
font-weight: 600;
}
.module-toolbar {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-md);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.module-toolbar button {
padding: 8px 16px;
background: var(--bg);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
font-weight: 500;
}
.module-toolbar button:hover {
background: var(--bg-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.module-toolbar button.primary {
background: var(--accent-color);
color: white;
border-color: var(--accent-color);
}
.module-toolbar button.primary:hover {
background: var(--accent-hover);
}
/* Empty state for all modules */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-xl);
text-align: center;
color: var(--text-secondary);
}
.empty-state-icon {
font-size: 64px;
margin-bottom: var(--space-md);
opacity: 0.5;
cursor: not-allowed;
}
/* Utility */
h1, h2, h3 {
margin-bottom: 1rem;
.empty-state-title {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-sm);
}
.text-sm {
font-size: 0.875rem;
.empty-state-description {
font-size: 14px;
color: var(--text-secondary);
max-width: 400px;
}
.text-xs {
font-size: 0.75rem;
/* Animation classes */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.text-gray {
color: #94a3b8;
.fade-in {
animation: fadeIn 0.3s ease-out;
}
[x-cloak] {
display: none !important;
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.slide-in {
animation: slideIn 0.3s ease-out;
}

View file

@ -295,18 +295,35 @@
</head>
<body>
<!-- Loading overlay -->
<div class="loading-overlay" id="loadingOverlay">
<div class="loading-spinner"></div>
</div>
<!-- Minimal floating header -->
<div class="float-header">
<!-- Left: General Bots logo -->
<div class="header-left">
<div class="logo-wrapper" onclick="window.location.reload()">
<div
class="logo-wrapper"
onclick="window.location.reload()"
title="General Bots"
>
<div class="logo-icon"></div>
<span class="logo-text">General Bots</span>
</div>
</div>
<!-- Right: Apps menu and user avatar -->
<!-- Right: Theme selector, Theme toggle, Apps menu and user avatar -->
<div class="header-right">
<button class="apps-menu-btn" id="appsMenuBtn" title="Apps">
<!-- Theme dropdown selector -->
<div id="themeSelectorContainer"></div>
<button
class="icon-button apps-button"
id="appsButton"
title="Apps"
>
<svg
width="24"
height="24"
@ -324,7 +341,9 @@
<circle cx="19" cy="19" r="2"></circle>
</svg>
</button>
<!-- Apps dropdown -->
<div class="apps-dropdown" id="appsDropdown">
<div class="apps-dropdown-title">Applications</div>
<div class="app-grid">
<a
class="app-item active"
@ -339,7 +358,7 @@
<span>Drive</span>
</a>
<a class="app-item" href="#tasks" data-section="tasks">
<div class="app-icon"></div>
<div class="app-icon"></div>
<span>Tasks</span>
</a>
<a class="app-item" href="#mail" data-section="mail">
@ -348,6 +367,8 @@
</a>
</div>
</div>
<!-- User avatar -->
<div class="user-avatar" id="userAvatar" title="User Account">
<span>U</span>
</div>
@ -359,14 +380,25 @@
</div>
<!-- Load scripts -->
<script src="js/theme-manager.js"></script>
<script src="js/layout.js"></script>
<script>
// Apps menu toggle
// Initialize ThemeManager and Apps menu
document.addEventListener("DOMContentLoaded", () => {
const appsBtn = document.getElementById("appsMenuBtn");
// Initialize ThemeManager
if (window.ThemeManager) {
ThemeManager.init();
}
const appsBtn = document.getElementById("appsButton");
const appsDropdown = document.getElementById("appsDropdown");
const appItems = document.querySelectorAll(".app-item");
if (!appsBtn || !appsDropdown) {
console.error("Apps button or dropdown not found");
return;
}
// Toggle apps menu
appsBtn.addEventListener("click", (e) => {
e.stopPropagation();
@ -403,12 +435,14 @@
});
});
// Load default section (chat) after a short delay to ensure layout.js is loaded
// Hide loading overlay after layout.js initializes
setTimeout(() => {
if (window.switchSection) {
window.switchSection("chat");
const loadingOverlay =
document.getElementById("loadingOverlay");
if (loadingOverlay) {
loadingOverlay.classList.add("hidden");
}
}, 100);
}, 500);
});
</script>
</body>

View file

@ -136,8 +136,8 @@ async function switchSection(section) {
// Wait for the component function to be registered
const appFunctionName = section + "App";
let retries = 0;
while (typeof window[appFunctionName] !== "function" && retries < 50) {
await new Promise((resolve) => setTimeout(resolve, 50));
while (typeof window[appFunctionName] !== "function" && retries < 100) {
await new Promise((resolve) => setTimeout(resolve, 100));
retries++;
}
@ -189,10 +189,18 @@ async function switchSection(section) {
// Remove x-ignore to allow Alpine to process
wrapper.removeAttribute("x-ignore");
// Verify component function is available
const appFunctionName = section + "App";
if (typeof window[appFunctionName] !== "function") {
console.error(`${appFunctionName} not available during Alpine init!`);
throw new Error(`Component ${appFunctionName} missing`);
}
// Small delay to ensure DOM is ready
await new Promise((resolve) => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 100));
try {
console.log(`Calling Alpine.initTree for ${section}`);
window.Alpine.initTree(wrapper);
console.log(`✓ Alpine initialized for ${section}`);
} catch (err) {

View file

@ -0,0 +1,117 @@
// Unified Theme Manager - Dropdown only, no light/dark toggle
const ThemeManager = (() => {
let currentThemeId = "default";
let subscribers = [];
const themes = [
{ id: "default", name: "🎨 Default", file: null },
{ id: "orange", name: "🍊 Orange", file: "orange.css" },
{ id: "cyberpunk", name: "🌃 Cyberpunk", file: "cyberpunk.css" },
{ id: "retrowave", name: "🌴 Retrowave", file: "retrowave.css" },
{ id: "vapordream", name: "💭 Vapor Dream", file: "vapordream.css" },
{ id: "y2kglow", name: "✨ Y2K", file: "y2kglow.css" },
{ id: "3dbevel", name: "🔲 3D Bevel", file: "3dbevel.css" },
{ id: "arcadeflash", name: "🕹️ Arcade", file: "arcadeflash.css" },
{ id: "discofever", name: "🪩 Disco", file: "discofever.css" },
{ id: "grungeera", name: "🎸 Grunge", file: "grungeera.css" },
{ id: "jazzage", name: "🎺 Jazz", file: "jazzage.css" },
{ id: "mellowgold", name: "🌻 Mellow", file: "mellowgold.css" },
{ id: "midcenturymod", name: "🏠 Mid Century", file: "midcenturymod.css" },
{ id: "polaroidmemories", name: "📷 Polaroid", file: "polaroidmemories.css" },
{ id: "saturdaycartoons", name: "📺 Cartoons", file: "saturdaycartoons.css" },
{ id: "seasidepostcard", name: "🏖️ Seaside", file: "seasidepostcard.css" },
{ id: "typewriter", name: "⌨️ Typewriter", file: "typewriter.css" },
{ id: "xeroxui", name: "📠 Xerox", file: "xeroxui.css" },
{ id: "xtreegold", name: "📁 XTree", file: "xtreegold.css" }
];
function loadTheme(id) {
const theme = themes.find(t => t.id === id);
if (!theme) {
console.warn("Theme not found:", id);
return;
}
const old = document.getElementById("theme-css");
if (old) old.remove();
if (!theme.file) {
currentThemeId = "default";
localStorage.setItem("gb-theme", "default");
updateDropdown();
return;
}
const link = document.createElement("link");
link.id = "theme-css";
link.rel = "stylesheet";
link.href = `public/themes/${theme.file}`;
link.onload = () => {
console.log("✓ Theme loaded:", theme.name);
currentThemeId = id;
localStorage.setItem("gb-theme", id);
updateDropdown();
subscribers.forEach(cb => cb({ themeId: id, themeName: theme.name }));
};
link.onerror = () => console.error("✗ Failed:", theme.name);
document.head.appendChild(link);
}
function updateDropdown() {
const dd = document.getElementById("themeDropdown");
if (dd) dd.value = currentThemeId;
}
function createDropdown() {
const select = document.createElement("select");
select.id = "themeDropdown";
select.className = "theme-dropdown";
themes.forEach(t => {
const opt = document.createElement("option");
opt.value = t.id;
opt.textContent = t.name;
select.appendChild(opt);
});
select.value = currentThemeId;
select.onchange = (e) => loadTheme(e.target.value);
return select;
}
function init() {
let saved = localStorage.getItem("gb-theme") || "default";
if (!themes.find(t => t.id === saved)) saved = "default";
currentThemeId = saved;
loadTheme(saved);
const container = document.getElementById("themeSelectorContainer");
if (container) container.appendChild(createDropdown());
console.log("✓ Theme Manager initialized");
}
function setThemeFromServer(data) {
if (data.logo_url) {
document.querySelectorAll(".logo-icon, .assistant-avatar").forEach(el => {
el.style.backgroundImage = `url("${data.logo_url}")`;
});
}
if (data.title) document.title = data.title;
if (data.logo_text) {
document.querySelectorAll(".logo-text").forEach(el => {
el.textContent = data.logo_text;
});
}
}
function applyCustomizations() {
// Called by modules if needed
}
function subscribe(cb) {
subscribers.push(cb);
}
return { init, loadTheme, setThemeFromServer, applyCustomizations, subscribe, getAvailableThemes: () => themes };
})();
window.ThemeManager = ThemeManager;