diff --git a/backend/server.py b/backend/server.py index 2b641f8..aa35201 100644 --- a/backend/server.py +++ b/backend/server.py @@ -29,6 +29,16 @@ def start_server(app): async def index(): return await send_from_directory(server.static_folder, 'index.html') + # These paths must match the routes in the frontend SPA + # file: frontend/src/routes/index.js + @server.route('/chat') + @server.route('/agent') + @server.route('/chat/') + @server.route('/agent/') + async def serve_spa_routes(subpath=None): + # Ignore subpath and render index, Vue router will take care of everything + return await send_from_directory(server.static_folder, 'index.html') + @server.route('/') async def serve_static(path): return await send_from_directory(server.static_folder, path) diff --git a/frontend-vue/src/assets/base.css b/frontend-vue/src/assets/base.css index cdb63cc..0d14076 100644 --- a/frontend-vue/src/assets/base.css +++ b/frontend-vue/src/assets/base.css @@ -23,15 +23,15 @@ /* semantic color variables for this project */ :root { - --color-background: var(--vt-c-white); - --color-background-soft: var(--vt-c-white-soft); - --color-background-mute: var(--vt-c-white-mute); + --color-background: var(--vt-c-black); + --color-background-soft: var(--vt-c-black-soft); + --color-background-mute: var(--vt-c-black-mute); - --color-border: var(--vt-c-divider-light-2); - --color-border-hover: var(--vt-c-divider-light-1); + --color-border: var(--vt-c-divider-dark-2); + --color-border-hover: var(--vt-c-divider-dark-1); - --color-heading: var(--vt-c-text-light-1); - --color-text: var(--vt-c-text-light-1); + --color-heading: var(--vt-c-text-dark-1); + --color-text: var(--vt-c-text-dark-2); --section-gap: 160px; } @@ -40,22 +40,23 @@ :root { --color-green: #00bd7e; --color-green-dark: #003825; + --color-blue: #3d8de1; --border-radius: 6px; } -@media (prefers-color-scheme: dark) { +@media (prefers-color-scheme: light) { :root { - --color-background: var(--vt-c-black); - --color-background-soft: var(--vt-c-black-soft); - --color-background-mute: var(--vt-c-black-mute); + --color-background: var(--vt-c-white); + --color-background-soft: var(--vt-c-white-soft); + --color-background-mute: var(--vt-c-white-mute); - --color-border: var(--vt-c-divider-dark-2); - --color-border-hover: var(--vt-c-divider-dark-1); + --color-border: var(--vt-c-divider-light-2); + --color-border-hover: var(--vt-c-divider-light-1); - --color-heading: var(--vt-c-text-dark-1); - --color-text: var(--vt-c-text-dark-2); + --color-heading: var(--vt-c-text-light-1); + --color-text: var(--vt-c-text-light-1); } } diff --git a/frontend-vue/src/assets/main.css b/frontend-vue/src/assets/main.css index 359975b..e2d5ccf 100644 --- a/frontend-vue/src/assets/main.css +++ b/frontend-vue/src/assets/main.css @@ -2,10 +2,9 @@ *[hidden] { display: none !important; } -a, -.green { +a { text-decoration: none; - color: var(--color-green); + color: var(--color-blue); transition: 0.4s; padding: 3px; } diff --git a/frontend-vue/src/backend.js b/frontend-vue/src/backend.js index 3621a67..f6b8c37 100644 --- a/frontend-vue/src/backend.js +++ b/frontend-vue/src/backend.js @@ -25,8 +25,8 @@ let loginTokenDeferred = createDeferred(); let authDeferred = createDeferred(); // Handlers -const pendingActions = new Map(); // Maps nonce to { resolve, reject } -const chatListeners = new Map(); // Maps chatId to an array of callback functions +const pendingActions = {}; // Maps nonce to { resolve, reject } +const chatListeners = {}; // Maps chatId to an array of callback functions const orphanHandlers = []; // Queue of callbacks for orphan server messages function createDeferred() { @@ -120,9 +120,9 @@ export function sendAction(action, body = {}) { const nonce = nextNonce++; const deferred = createDeferred(); - pendingActions.set(nonce, deferred); + pendingActions[nonce] = deferred; deferred.promise.finally(() => { - pendingActions.delete(nonce); // Clean up + delete pendingActions[nonce]; // Clean up }); socket.send(JSON.stringify({ @@ -156,7 +156,7 @@ function handleIncomingMessage(rawData) { if (packet.nonce != null) { const nonce = packet.nonce; - const actionPromise = pendingActions.get(nonce); + const actionPromise = pendingActions[nonce]; if (actionPromise) { if (packet.error) { actionPromise.reject(new Error(packet.error)); @@ -168,7 +168,7 @@ function handleIncomingMessage(rawData) { } } else if (packet.action === 'chat-message') { const chatId = packet.chatId; - const listeners = chatListeners.get(chatId); + const listeners = chatListeners[chatId]; if (listeners) { listeners.forEach(callback => { try { @@ -213,15 +213,15 @@ async function postRequest(path, body) { // --- Data Management --- // Returns a function to unsubscribe -export function chatMessages(chatId, callback) { +export function onChatMessages(chatId, callback) { if (typeof callback !== 'function') { throw new TypeError("chatMessages: callback must be a function."); } - if (!chatListeners.has(chatId)) { - chatListeners.set(chatId, []); + if (!chatListeners[chatId]) { + chatListeners[chatId] = []; } - const listeners = chatListeners.get(chatId); + const listeners = chatListeners[chatId]; listeners.push(callback); // Return an unsubscribe function @@ -231,7 +231,7 @@ export function chatMessages(chatId, callback) { listeners.splice(index, 1); } if (listeners.length === 0) { - chatListeners.delete(chatId); + delete chatListeners[chatId]; } }; } @@ -239,8 +239,8 @@ export function chatMessages(chatId, callback) { export async function getChats() { if (cache.chats === null) { const response = await sendAction("get-chats", {}); - if (response && response.cache.chats) { - cache.chats = response.cache.chats.map(msg => new ChatInfo({ + if (response && response.chats) { + cache.chats = response.chats.map(msg => new ChatInfo({ id: msg.id, name: msg.name?.length > 0 ? msg.name : `[Chat ${msg.id}]`, })); diff --git a/frontend-vue/src/components/main/Sidebar.vue b/frontend-vue/src/components/main/Sidebar.vue index 0ccb9ed..f799bea 100644 --- a/frontend-vue/src/components/main/Sidebar.vue +++ b/frontend-vue/src/components/main/Sidebar.vue @@ -5,24 +5,40 @@ onMounted, onUnmounted, } from "vue"; - //import * as horde from '../../horde'; + import * as backend from '../../backend'; import * as ui from '../../ui'; + + const collapsed = ref(false); + const mode = ref('chats'); + const chats = ref([]); + const agents = ref([]); + + onMounted(async () => { + chats.value = (await backend.getChats()).map(entry => { + return {id: entry.id, name: entry.name}; + }); + }); @@ -47,20 +63,24 @@ justify-content: space-around; align-items: center; list-style-type: none; - border-radius: var(--border-radius) var(--border-radius) 0 0; + border-radius: var(--border-radius); width: 3em; height: 3em; transition: background-color 0.5s; } .tab-bar li:hover { + background-color: var(--color-background-mute); + } + + .tab-bar li.active { background-color: var(--color-background-soft); } .nav-item { display: block; - color: var(--color-green); + color: inherit; background-color: transparent; border: none; diff --git a/frontend-vue/src/router/index.js b/frontend-vue/src/router/index.js index 3e49915..94fc354 100644 --- a/frontend-vue/src/router/index.js +++ b/frontend-vue/src/router/index.js @@ -1,6 +1,17 @@ import { createRouter, createWebHistory } from 'vue-router' import HomeView from '../views/HomeView.vue' +function objectView (name, mod) { + return { + name, + path: `/${name}/:id`, + props: true, + component: mod + } +} + +// These routers must be handled by the server by rendering the index page +// file: backend/server.py const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ @@ -17,6 +28,7 @@ const router = createRouter({ // which is lazy-loaded when the route is visited. component: () => import('../views/AboutView.vue'), }, + objectView('chat', () => import('../views/Chat.vue')), ], }) diff --git a/frontend-vue/src/views/Chat.vue b/frontend-vue/src/views/Chat.vue new file mode 100644 index 0000000..e385c67 --- /dev/null +++ b/frontend-vue/src/views/Chat.vue @@ -0,0 +1,269 @@ + + + + +