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 @@
+
+
+
+
+
{{ msg }}
+
+ You’ve successfully created a project with
+ Vite +
+ Vue 3 .
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+ Documentation
+
+ Vue’s
+ official documentation
+ provides you with all information you need to get started.
+
+
+
+
+
+
+ Tooling
+
+ This project is served and bundled with
+ Vite . The
+ recommended IDE setup is
+ VSCode
+ +
+ Vue - Official . If
+ you need to test your components and web pages, check out
+ Vitest
+ and
+ Cypress
+ /
+ Playwright .
+
+
+
+ More instructions are available in
+ README.md .
+
+
+
+
+
+
+ Ecosystem
+
+ Get official tools and libraries for your project:
+ Pinia ,
+ Vue Router ,
+ Vue Test Utils , and
+ Vue Dev Tools . If
+ you need more resources, we suggest paying
+ Awesome Vue
+ a visit.
+
+
+
+
+
+
+ Community
+
+ Got stuck? Ask your question on
+ Vue Land
+ (our official Discord server), or
+ StackOverflow . You should also follow the official
+ @vuejs.org
+ Bluesky account or the
+ @vuejs
+ X account for latest news in the Vue world.
+
+
+
+
+
+
+ Support Vue
+
+ As an independent project, Vue relies on community backing for its sustainability. You can help
+ us by
+ becoming a sponsor .
+
+
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 @@
+
+
+ This is an about page
+
+
+
+
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))
+ },
+ },
+})