diff --git a/Makefile b/Makefile index a303e66..6761ed8 100644 --- a/Makefile +++ b/Makefile @@ -9,10 +9,13 @@ $(VENV_DIR): install: venv @$(VENV_DIR)/bin/pip install -r backend/requirements.txt + git submodule init + git submodule update -web: frontend/build/web/index.html +web: frontend-vue/dist/index.html -frontend/build/web/index.html: - cd frontend && flutter build web --release --source-maps --no-web-resources-cdn +#frontend/build/web/index.html: $(shell find frontend/lib -name '*.dart') +# cd frontend && flutter build web --release --source-maps --no-web-resources-cdn -frontend/build/web/index.html: $(shell find frontend/lib -name '*.dart') \ No newline at end of file +frontend-vue/dist/index.html: $(shell find frontend-vue/src -name '*.vue' -or -name '*.js*' -or -name '*.css*') frontend-vue/index.html + cd frontend-vue && npm run build \ No newline at end of file diff --git a/backend/server.py b/backend/server.py index 6e427da..2b641f8 100644 --- a/backend/server.py +++ b/backend/server.py @@ -7,8 +7,14 @@ import json import asyncio import nest_asyncio +# Flutter frontend +#frontend_path = '../frontend-flutter/build/web' + +# Vue frontend +frontend_path = '../frontend-vue/dist' + def start_server(app): - server = Quart(__name__, static_folder='../frontend/build/web') + server = Quart(__name__, static_folder=frontend_path) server.secret_key = 'horde-secret' auth_manager = QuartAuth(server, mode="brearer") @@ -32,12 +38,11 @@ def start_server(app): data = await request.get_json() if "username" not in data or "password" not in data: - await websocket.send(json.dumps({'error': 'invalid arguments'})) - return + return "invalid login arguments", 400 user_id = app.verify_user_credentials(data['username'], data['password']) if user_id == None: - return jsonify({'error': "bad credentials"}), 401 + return "bad login credentials", 401 token = auth_manager.dump_token(user_id) return {"token": token} @@ -54,12 +59,12 @@ def start_server(app): user_id = app.create_new_user(username, email, password) if user_id is None: - return {"error": "existing credentials"} + return "existing or incorrect credentials", 400 app.generate_default_user_data(user_id) - # token = auth_manager.dump_token(user_id) - return {"userId": user_id} + token = auth_manager.dump_token(user_id) + return {"token": token} @server.websocket('/ws') async def ws(): diff --git a/frontend-vue/.gitignore b/frontend-vue/.gitignore new file mode 100644 index 0000000..000789d --- /dev/null +++ b/frontend-vue/.gitignore @@ -0,0 +1,31 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +package-lock.json +.DS_Store +dist +dist-ssr +coverage +*.local + +/cypress/videos/ +/cypress/screenshots/ + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +*.tsbuildinfo diff --git a/frontend-vue/.vscode/extensions.json b/frontend-vue/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/frontend-vue/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/frontend-vue/README.md b/frontend-vue/README.md new file mode 100644 index 0000000..114cf21 --- /dev/null +++ b/frontend-vue/README.md @@ -0,0 +1,29 @@ +# frontend + +This template should help get you started developing with Vue 3 in Vite. + +## Recommended IDE Setup + +[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur). + +## Customize configuration + +See [Vite Configuration Reference](https://vite.dev/config/). + +## Project Setup + +```sh +npm install +``` + +### Compile and Hot-Reload for Development + +```sh +npm run dev +``` + +### Compile and Minify for Production + +```sh +npm run build +``` diff --git a/frontend-vue/index.html b/frontend-vue/index.html new file mode 100644 index 0000000..7fe5fa9 --- /dev/null +++ b/frontend-vue/index.html @@ -0,0 +1,15 @@ + + + + + + + + + + Horde Chat + + + + + diff --git a/frontend-vue/jsconfig.json b/frontend-vue/jsconfig.json new file mode 100644 index 0000000..5a1f2d2 --- /dev/null +++ b/frontend-vue/jsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "paths": { + "@/*": ["./src/*"] + } + }, + "exclude": ["node_modules", "dist"] +} diff --git a/frontend-vue/package.json b/frontend-vue/package.json new file mode 100644 index 0000000..7c8208f --- /dev/null +++ b/frontend-vue/package.json @@ -0,0 +1,20 @@ +{ + "name": "frontend", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.17", + "vue-router": "^4.5.1" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.0", + "vite": "^7.0.0", + "vite-plugin-vue-devtools": "^7.7.7" + } +} diff --git a/frontend-vue/public/favicon.ico b/frontend-vue/public/favicon.ico new file mode 100644 index 0000000..df36fcf Binary files /dev/null and b/frontend-vue/public/favicon.ico differ diff --git a/frontend-vue/public/horde.png b/frontend-vue/public/horde.png new file mode 100644 index 0000000..71fed33 Binary files /dev/null and b/frontend-vue/public/horde.png differ diff --git a/frontend-vue/src/App.vue b/frontend-vue/src/App.vue new file mode 100644 index 0000000..753729d --- /dev/null +++ b/frontend-vue/src/App.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/frontend-vue/src/assets/base.css b/frontend-vue/src/assets/base.css new file mode 100644 index 0000000..cdb63cc --- /dev/null +++ b/frontend-vue/src/assets/base.css @@ -0,0 +1,95 @@ +/* color palette from */ +:root { + --vt-c-white: #ffffff; + --vt-c-white-soft: #f8f8f8; + --vt-c-white-mute: #f2f2f2; + + --vt-c-black: #181818; + --vt-c-black-soft: #222222; + --vt-c-black-mute: #282828; + + --vt-c-indigo: #2c3e50; + + --vt-c-divider-light-1: rgba(60, 60, 60, 0.29); + --vt-c-divider-light-2: rgba(60, 60, 60, 0.12); + --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); + --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48); + + --vt-c-text-light-1: var(--vt-c-indigo); + --vt-c-text-light-2: rgba(60, 60, 60, 0.66); + --vt-c-text-dark-1: var(--vt-c-white); + --vt-c-text-dark-2: rgba(235, 235, 235, 0.64); +} + +/* 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-border: var(--vt-c-divider-light-2); + --color-border-hover: var(--vt-c-divider-light-1); + + --color-heading: var(--vt-c-text-light-1); + --color-text: var(--vt-c-text-light-1); + + --section-gap: 160px; +} + +/* Horde Colors */ +:root { + --color-green: #00bd7e; + --color-green-dark: #003825; + + --border-radius: 6px; +} + + +@media (prefers-color-scheme: dark) { + :root { + --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-dark-2); + --color-border-hover: var(--vt-c-divider-dark-1); + + --color-heading: var(--vt-c-text-dark-1); + --color-text: var(--vt-c-text-dark-2); + } +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + font-weight: normal; +} + +body { + min-height: 100vh; + color: var(--color-text); + background: var(--color-background); + transition: + color 0.5s, + background-color 0.5s; + line-height: 1.6; + font-family: + Inter, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + Oxygen, + Ubuntu, + Cantarell, + 'Fira Sans', + 'Droid Sans', + 'Helvetica Neue', + sans-serif; + font-size: 15px; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/frontend-vue/src/assets/logo.svg b/frontend-vue/src/assets/logo.svg new file mode 100644 index 0000000..7565660 --- /dev/null +++ b/frontend-vue/src/assets/logo.svg @@ -0,0 +1 @@ + diff --git a/frontend-vue/src/assets/main.css b/frontend-vue/src/assets/main.css new file mode 100644 index 0000000..359975b --- /dev/null +++ b/frontend-vue/src/assets/main.css @@ -0,0 +1,22 @@ +@import './base.css'; + +*[hidden] { display: none !important; } + +a, +.green { + text-decoration: none; + color: var(--color-green); + transition: 0.4s; + padding: 3px; +} + +body { + display: flex; + direction: row; +} + +main { + height: 100vh; + flex-grow: 1; + overflow: auto; +} \ No newline at end of file diff --git a/frontend-vue/src/backend.js b/frontend-vue/src/backend.js new file mode 100644 index 0000000..3621a67 --- /dev/null +++ b/frontend-vue/src/backend.js @@ -0,0 +1,339 @@ + +import * as ui from './ui'; + +export class ChatInfo { + constructor({ id, name }) { + this.id = id; + this.name = name; + } +} + +// Basic objects and configuration +let socket = null; +let backendUrl = null; + +// State +let nextNonce = 0; +let reconnectTimeout = null; // For managing the reconnect timer +let cache = { + chat: null, +}; + +// Auth +let loginToken = null; +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 orphanHandlers = []; // Queue of callbacks for orphan server messages + +function createDeferred() { + let resolve, reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return {promise, resolve, reject}; +} + +function initState () { + backendUrl = window.location.origin; + const tk = localStorage.getItem('loginToken'); + if (tk) { + resolveToken(tk); + + // Clear token if it doesn't work + authDeferred.promise.then(success => { + if (!success) { + localStorage.removeItem('loginToken'); + } + }); + } else { + authDeferred.resolve(false); + } + connect(); +} + +initState(); + + +// --- Authentication --- + +// Passes the token to everyone who needs it, and returns it +function resolveToken (tk) { + loginToken = tk; + loginTokenDeferred.resolve(tk); + return tk; +} + +// Waits for the result of the last authentication attempt +export async function authResult() { + // No token has been set, login or signup have to be called + if (!loginToken) return false; + return await authDeferred.promise; +} + +// Creates an account and returns a login token +export async function signup(username, email, password) { + try { + const response = await postRequest('/user/signup', { + username, + email, + password, + }); + // Reset authentication status + authDeferred = createDeferred(); + resolveToken(response.token); + return await authResult(); + } catch (error) { + ui.alert('Signup error: ' + (error.message || String(error))); + return false; + } +} + +// Returns a login token for the given credentials +export async function login(username, password) { + try { + const response = await postRequest('/user/login', { + username, + password, + }); + // Reset authentication status + authDeferred = createDeferred(); + resolveToken(response.token); + return await authResult(); + } catch (error) { + ui.alert('Login error: ' + (error.message || String(error))); + return false; + } +} + + +// --- Communication --- + +export function sendAction(action, body = {}) { + if (socket?.readyState !== WebSocket.OPEN) { + return Promise.reject(new Error("WebSocket is not open. Cannot send action.")); + } + const nonce = nextNonce++; + const deferred = createDeferred(); + + pendingActions.set(nonce, deferred); + deferred.promise.finally(() => { + pendingActions.delete(nonce); // Clean up + }); + + socket.send(JSON.stringify({ + action, + nonce, + body, + })); + return deferred.promise; +} + +export function ping() { + if (socket && socket.readyState === WebSocket.OPEN) { + const data = JSON.stringify({ path: 'ping' }); + socket.send(data); + console.log('Ping sent:', data); + } else { + console.warn('Cannot send ping: WebSocket is not open.'); + } +} + +function handleIncomingMessage(rawData) { + let packet; + try { + packet = JSON.parse(rawData); + } catch (e) { + console.error("Failed to parse incoming message as JSON:", rawData, e); + return; + } + + console.log("Incoming Packet:", packet); + + if (packet.nonce != null) { + const nonce = packet.nonce; + const actionPromise = pendingActions.get(nonce); + if (actionPromise) { + if (packet.error) { + actionPromise.reject(new Error(packet.error)); + } else { + actionPromise.resolve(packet); + } + } else { + console.warn(`Orphan response for action nonce ${nonce}. No pending action found.`); + } + } else if (packet.action === 'chat-message') { + const chatId = packet.chatId; + const listeners = chatListeners.get(chatId); + if (listeners) { + listeners.forEach(callback => { + try { + callback(packet); + } catch (e) { + console.error(`Error in chat message listener for chat ${chatId}:`, e); + } + }); + } else { + console.log(`No active listeners for chatId ${chatId}`); + } + } else { + if (orphanHandlers.length) { + const f = orphanHandlers.shift(); + f(packet); + } else if (packet.error) { + console.error(`Backend Error: ${packet.error}`); + } + } +} + +async function postRequest(path, body) { + const response = await fetch(`${backendUrl}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Response Error: ${response.status}\n${errorText}`); + } + + const responseText = await response.text(); + const responseData = JSON.parse(responseText); + return responseData; +} + + +// --- Data Management --- + +// Returns a function to unsubscribe +export function chatMessages(chatId, callback) { + if (typeof callback !== 'function') { + throw new TypeError("chatMessages: callback must be a function."); + } + + if (!chatListeners.has(chatId)) { + chatListeners.set(chatId, []); + } + const listeners = chatListeners.get(chatId); + listeners.push(callback); + + // Return an unsubscribe function + return () => { + const index = listeners.indexOf(callback); + if (index > -1) { + listeners.splice(index, 1); + } + if (listeners.length === 0) { + chatListeners.delete(chatId); + } + }; +} + +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({ + id: msg.id, + name: msg.name?.length > 0 ? msg.name : `[Chat ${msg.id}]`, + })); + } else { + throw new Error("Invalid response for get-chats: " + JSON.stringify(response)); + } + } + return cache.chats; +} + +export async function getChat(chatId) { + const allChats = await getChats(); + return allChats.find(chat => chat.id === chatId) || null; +} + +export function invalidateCache({ chat = false } = {}) { + if (chat) { + cache.chats = null; + } +} + + + +// --- Connection --- + +function reconnect() { + if (reconnectTimeout) { + clearTimeout(reconnectTimeout); + } + reconnectTimeout = setTimeout(() => { + connect(); + }, 1000); // Wait 1 second before attempting to reconnect +} + +function connect() { + if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) { + console.log('WebSocket is already connected or connecting.'); + return; + } + + socket = new WebSocket(`${backendUrl}/ws`); + + socket.onopen = async (event) => { + console.log('Connected to the WebSocket server'); + invalidateCache({ chat: true }); + + console.log('Attempting to log in...'); + try { + const tk = loginToken || await loginTokenDeferred.promise; + socket.send(JSON.stringify({ token: tk })); + + // Wait for the login response as an orphan server message + const response = await new Promise(resolve => { + orphanHandlers.push(resolve); + }); + + if (response && !response.error) { + // Successful connection, store token long term + authDeferred.resolve(true); + localStorage.setItem('loginToken', loginToken); + } else { + authDeferred.resolve(false); + console.error("Socket authentication failed:", response ? response.error : "Unknown error"); + socket.close(); // Close to trigger reconnect and retry login + } + } catch (error) { + console.error("Error during WebSocket login:", error); + authDeferred.resolve(false); + socket.close(); // Close to trigger reconnect + } + }; + + socket.onerror = (event) => { + console.error('WebSocket Error:', event); + reconnect(); + }; + + socket.onclose = (event) => { + console.log('Disconnected. Attempting to reconnect...', event.reason); + reconnect(); + }; + + socket.onmessage = (event) => { + handleIncomingMessage(event.data); + }; +} + +function disconnect() { + if (socket) { + socket.close(1000, "Client initiated disconnect"); + socket = null; + if (reconnectTimeout) { + clearTimeout(reconnectTimeout); // Stop any pending reconnects + } + } +} diff --git a/frontend-vue/src/components/HelloWorld.vue b/frontend-vue/src/components/HelloWorld.vue new file mode 100644 index 0000000..eff59f1 --- /dev/null +++ b/frontend-vue/src/components/HelloWorld.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/frontend-vue/src/components/TheWelcome.vue b/frontend-vue/src/components/TheWelcome.vue new file mode 100644 index 0000000..fe48afc --- /dev/null +++ b/frontend-vue/src/components/TheWelcome.vue @@ -0,0 +1,94 @@ + + + diff --git a/frontend-vue/src/components/WelcomeItem.vue b/frontend-vue/src/components/WelcomeItem.vue new file mode 100644 index 0000000..ac366d0 --- /dev/null +++ b/frontend-vue/src/components/WelcomeItem.vue @@ -0,0 +1,86 @@ + + + diff --git a/frontend-vue/src/components/icons/IconCommunity.vue b/frontend-vue/src/components/icons/IconCommunity.vue new file mode 100644 index 0000000..2dc8b05 --- /dev/null +++ b/frontend-vue/src/components/icons/IconCommunity.vue @@ -0,0 +1,7 @@ + diff --git a/frontend-vue/src/components/icons/IconDocumentation.vue b/frontend-vue/src/components/icons/IconDocumentation.vue new file mode 100644 index 0000000..6d4791c --- /dev/null +++ b/frontend-vue/src/components/icons/IconDocumentation.vue @@ -0,0 +1,7 @@ + diff --git a/frontend-vue/src/components/icons/IconEcosystem.vue b/frontend-vue/src/components/icons/IconEcosystem.vue new file mode 100644 index 0000000..c3a4f07 --- /dev/null +++ b/frontend-vue/src/components/icons/IconEcosystem.vue @@ -0,0 +1,7 @@ + diff --git a/frontend-vue/src/components/icons/IconSupport.vue b/frontend-vue/src/components/icons/IconSupport.vue new file mode 100644 index 0000000..7452834 --- /dev/null +++ b/frontend-vue/src/components/icons/IconSupport.vue @@ -0,0 +1,7 @@ + diff --git a/frontend-vue/src/components/icons/IconTooling.vue b/frontend-vue/src/components/icons/IconTooling.vue new file mode 100644 index 0000000..660598d --- /dev/null +++ b/frontend-vue/src/components/icons/IconTooling.vue @@ -0,0 +1,19 @@ + + diff --git a/frontend-vue/src/components/main/Alerts.vue b/frontend-vue/src/components/main/Alerts.vue new file mode 100644 index 0000000..23d0309 --- /dev/null +++ b/frontend-vue/src/components/main/Alerts.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/frontend-vue/src/components/main/Sidebar.vue b/frontend-vue/src/components/main/Sidebar.vue new file mode 100644 index 0000000..0ccb9ed --- /dev/null +++ b/frontend-vue/src/components/main/Sidebar.vue @@ -0,0 +1,95 @@ + + + + + \ No newline at end of file diff --git a/frontend-vue/src/main.js b/frontend-vue/src/main.js new file mode 100644 index 0000000..9fd0a5c --- /dev/null +++ b/frontend-vue/src/main.js @@ -0,0 +1,11 @@ +import './assets/main.css' + +import { createApp } from 'vue' +import App from './App.vue' +import router from './router' + +const app = createApp(App) + +app.use(router) + +app.mount(document.body) diff --git a/frontend-vue/src/router/index.js b/frontend-vue/src/router/index.js new file mode 100644 index 0000000..3e49915 --- /dev/null +++ b/frontend-vue/src/router/index.js @@ -0,0 +1,23 @@ +import { createRouter, createWebHistory } from 'vue-router' +import HomeView from '../views/HomeView.vue' + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + { + path: '/', + name: 'home', + component: HomeView, + }, + { + path: '/about', + name: 'about', + // route level code-splitting + // this generates a separate chunk (About.[hash].js) for this route + // which is lazy-loaded when the route is visited. + component: () => import('../views/AboutView.vue'), + }, + ], +}) + +export default router diff --git a/frontend-vue/src/ui.js b/frontend-vue/src/ui.js new file mode 100644 index 0000000..65c0bee --- /dev/null +++ b/frontend-vue/src/ui.js @@ -0,0 +1,64 @@ +// Global utility functions related with UI functionality + +export function goto (path) { + window.location.hash = "#" + path; +} + +export async function confirm (prompt) { + return window.confirm(prompt); +} + +export async function input (prompt) { + return window.prompt(prompt); +} + +export async function inputFile (options) { + console.log("app.inputFile", options); + throw new Error("Cannot open files"); +} + +export function alert (message) { + emit('alert', {message}); +} + +export function editValue (mode, value, callback) { + emit('edit-value', {mode, value, callback}); +} + +export function contextMenu (entries) { + return new Promise(resolve => { + emit('context-menu', {entries, callback: resolve}); + }); +} + + +// === Events === // + +const listeners = {}; + +export function emit (name, ...args) { + const fs = listeners[name]; + if (fs == null) return; + + for (let f of fs) { + f(...args); + } +} + +export function listen (name, fn) { + let fs = listeners[name]; + if (fs == null) { + listeners[name] = fs = []; + } + + fs.push(fn); +} + +export function invoke (name, ...args) { + throw new Error("invoke"); +} + +window.addEventListener("unhandledrejection", function(promiseRejectionEvent) { + console.error(promiseRejectionEvent.reason); + alert("Unhandled Error: " + String(promiseRejectionEvent.reason)); +}); \ No newline at end of file diff --git a/frontend-vue/src/views/AboutView.vue b/frontend-vue/src/views/AboutView.vue new file mode 100644 index 0000000..e7c9e9c --- /dev/null +++ b/frontend-vue/src/views/AboutView.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend-vue/src/views/HomeView.vue b/frontend-vue/src/views/HomeView.vue new file mode 100644 index 0000000..6bb706f --- /dev/null +++ b/frontend-vue/src/views/HomeView.vue @@ -0,0 +1,9 @@ + + + diff --git a/frontend-vue/vite.config.js b/frontend-vue/vite.config.js new file mode 100644 index 0000000..ee675f4 --- /dev/null +++ b/frontend-vue/vite.config.js @@ -0,0 +1,21 @@ +import { fileURLToPath, URL } from 'node:url' + +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import vueDevTools from 'vite-plugin-vue-devtools' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + vue(), + vueDevTools(), + ], + build: { + sourcemap: true, + }, + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + }, + }, +})