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:
parent
37a15ea9e0
commit
eb4084fe2d
8 changed files with 1599 additions and 212 deletions
|
|
@ -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")))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
986
web/desktop/chat/chat.js.backup
Normal file
986
web/desktop/chat/chat.js.backup
Normal 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 Stand‑up",
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
|
@ -1,324 +1,526 @@
|
|||
* { 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;
|
||||
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;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
|
||||
sans-serif;
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary-fg);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
background var(--transition-smooth),
|
||||
color var(--transition-smooth);
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
#main-content {
|
||||
height: calc(100vh - 60px);
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
display: none;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
display: none;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.content-section.active {
|
||||
display: block;
|
||||
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;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 250px 1fr 300px;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-lg);
|
||||
height: 100%;
|
||||
background: var(--primary-bg);
|
||||
}
|
||||
|
||||
.drive-sidebar, .drive-details {
|
||||
overflow-y: auto;
|
||||
.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;
|
||||
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;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
cursor: pointer;
|
||||
border-radius: 0.375rem;
|
||||
margin: 0.25rem 0.5rem;
|
||||
transition: background 0.2s;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
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;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.file-item {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
cursor: pointer;
|
||||
border-radius: 0.375rem;
|
||||
border-bottom: 1px solid #334155;
|
||||
transition: background 0.2s;
|
||||
padding: var(--space-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
cursor: pointer;
|
||||
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;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-xl);
|
||||
padding-top: 80px;
|
||||
}
|
||||
|
||||
.task-input {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
display: flex;
|
||||
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;
|
||||
flex: 1;
|
||||
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;
|
||||
outline: none;
|
||||
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;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: background 0.2s;
|
||||
padding: 12px 24px;
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
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 {
|
||||
list-style: none;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: var(--space-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.5;
|
||||
text-decoration: line-through;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.task-item input[type="checkbox"] {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
cursor: pointer;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.task-item span {
|
||||
flex: 1;
|
||||
flex: 1;
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.task-item button {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
background: var(--error-color);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
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;
|
||||
display: flex;
|
||||
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;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
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 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;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 250px 350px 1fr;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-lg);
|
||||
height: 100%;
|
||||
background: var(--primary-bg);
|
||||
}
|
||||
|
||||
.mail-sidebar, .mail-list, .mail-content {
|
||||
overflow-y: auto;
|
||||
.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;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #334155;
|
||||
transition: background 0.2s;
|
||||
padding: var(--space-md);
|
||||
cursor: pointer;
|
||||
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 {
|
||||
font-weight: 600;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.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 {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
@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%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility */
|
||||
h1, h2, h3 {
|
||||
margin-bottom: 1rem;
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.875rem;
|
||||
/* 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);
|
||||
}
|
||||
|
||||
.text-xs {
|
||||
font-size: 0.75rem;
|
||||
.module-header h1 {
|
||||
color: var(--text-primary);
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-gray {
|
||||
color: #94a3b8;
|
||||
.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);
|
||||
}
|
||||
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
.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;
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.empty-state-description {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
/* Animation classes */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.slide-in {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
117
web/desktop/js/theme-manager.js
Normal file
117
web/desktop/js/theme-manager.js
Normal 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;
|
||||
Loading…
Add table
Reference in a new issue