From 5b59d3df75f75e523af8288aee89fd6be825daa2 Mon Sep 17 00:00:00 2001 From: Shivering Ocelot Date: Wed, 9 Jul 2025 17:08:39 -0500 Subject: [PATCH] Initial vue frontend, with only authentication --- Makefile | 11 +- backend/server.py | 19 +- frontend-vue/.gitignore | 31 ++ frontend-vue/.vscode/extensions.json | 3 + frontend-vue/README.md | 29 ++ frontend-vue/index.html | 15 + frontend-vue/jsconfig.json | 8 + frontend-vue/package.json | 20 ++ frontend-vue/public/favicon.ico | Bin 0 -> 4286 bytes frontend-vue/public/horde.png | Bin 0 -> 8971 bytes frontend-vue/src/App.vue | 118 ++++++ frontend-vue/src/assets/base.css | 95 +++++ frontend-vue/src/assets/logo.svg | 1 + frontend-vue/src/assets/main.css | 22 ++ frontend-vue/src/backend.js | 339 ++++++++++++++++++ frontend-vue/src/components/HelloWorld.vue | 44 +++ frontend-vue/src/components/TheWelcome.vue | 94 +++++ frontend-vue/src/components/WelcomeItem.vue | 86 +++++ .../src/components/icons/IconCommunity.vue | 7 + .../components/icons/IconDocumentation.vue | 7 + .../src/components/icons/IconEcosystem.vue | 7 + .../src/components/icons/IconSupport.vue | 7 + .../src/components/icons/IconTooling.vue | 19 + frontend-vue/src/components/main/Alerts.vue | 41 +++ frontend-vue/src/components/main/Sidebar.vue | 95 +++++ frontend-vue/src/main.js | 11 + frontend-vue/src/router/index.js | 23 ++ frontend-vue/src/ui.js | 64 ++++ frontend-vue/src/views/AboutView.vue | 15 + frontend-vue/src/views/HomeView.vue | 9 + frontend-vue/vite.config.js | 21 ++ 31 files changed, 1250 insertions(+), 11 deletions(-) create mode 100644 frontend-vue/.gitignore create mode 100644 frontend-vue/.vscode/extensions.json create mode 100644 frontend-vue/README.md create mode 100644 frontend-vue/index.html create mode 100644 frontend-vue/jsconfig.json create mode 100644 frontend-vue/package.json create mode 100644 frontend-vue/public/favicon.ico create mode 100644 frontend-vue/public/horde.png create mode 100644 frontend-vue/src/App.vue create mode 100644 frontend-vue/src/assets/base.css create mode 100644 frontend-vue/src/assets/logo.svg create mode 100644 frontend-vue/src/assets/main.css create mode 100644 frontend-vue/src/backend.js create mode 100644 frontend-vue/src/components/HelloWorld.vue create mode 100644 frontend-vue/src/components/TheWelcome.vue create mode 100644 frontend-vue/src/components/WelcomeItem.vue create mode 100644 frontend-vue/src/components/icons/IconCommunity.vue create mode 100644 frontend-vue/src/components/icons/IconDocumentation.vue create mode 100644 frontend-vue/src/components/icons/IconEcosystem.vue create mode 100644 frontend-vue/src/components/icons/IconSupport.vue create mode 100644 frontend-vue/src/components/icons/IconTooling.vue create mode 100644 frontend-vue/src/components/main/Alerts.vue create mode 100644 frontend-vue/src/components/main/Sidebar.vue create mode 100644 frontend-vue/src/main.js create mode 100644 frontend-vue/src/router/index.js create mode 100644 frontend-vue/src/ui.js create mode 100644 frontend-vue/src/views/AboutView.vue create mode 100644 frontend-vue/src/views/HomeView.vue create mode 100644 frontend-vue/vite.config.js 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 0000000000000000000000000000000000000000..df36fcfb72584e00488330b560ebcf34a41c64c2 GIT binary patch literal 4286 zcmds*O-Phc6o&64GDVCEQHxsW(p4>LW*W<827=Unuo8sGpRux(DN@jWP-e29Wl%wj zY84_aq9}^Am9-cWTD5GGEo#+5Fi2wX_P*bo+xO!)p*7B;iKlbFd(U~_d(U?#hLj56 zPhFkj-|A6~Qk#@g^#D^U0XT1cu=c-vu1+SElX9NR;kzAUV(q0|dl0|%h|dI$%VICy zJnu2^L*Te9JrJMGh%-P79CL0}dq92RGU6gI{v2~|)p}sG5x0U*z<8U;Ij*hB9z?ei z@g6Xq-pDoPl=MANPiR7%172VA%r)kevtV-_5H*QJKFmd;8yA$98zCxBZYXTNZ#QFk2(TX0;Y2dt&WitL#$96|gJY=3xX zpCoi|YNzgO3R`f@IiEeSmKrPSf#h#Qd<$%Ej^RIeeYfsxhPMOG`S`Pz8q``=511zm zAm)MX5AV^5xIWPyEu7u>qYs?pn$I4nL9J!=K=SGlKLXpE<5x+2cDTXq?brj?n6sp= zphe9;_JHf40^9~}9i08r{XM$7HB!`{Ys~TK0kx<}ZQng`UPvH*11|q7&l9?@FQz;8 zx!=3<4seY*%=OlbCbcae?5^V_}*K>Uo6ZWV8mTyE^B=DKy7-sdLYkR5Z?paTgK-zyIkKjIcpyO z{+uIt&YSa_$QnN_@t~L014dyK(fOOo+W*MIxbA6Ndgr=Y!f#Tokqv}n<7-9qfHkc3 z=>a|HWqcX8fzQCT=dqVbogRq!-S>H%yA{1w#2Pn;=e>JiEj7Hl;zdt-2f+j2%DeVD zsW0Ab)ZK@0cIW%W7z}H{&~yGhn~D;aiP4=;m-HCo`BEI+Kd6 z={Xwx{TKxD#iCLfl2vQGDitKtN>z|-AdCN|$jTFDg0m3O`WLD4_s#$S literal 0 HcmV?d00001 diff --git a/frontend-vue/public/horde.png b/frontend-vue/public/horde.png new file mode 100644 index 0000000000000000000000000000000000000000..71fed33643b4ac172bf1dde8e2e69a8563b868bc GIT binary patch literal 8971 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 +import { ref } from 'vue'; +import { RouterLink, RouterView } from 'vue-router' +import HelloWorld from './components/HelloWorld.vue' +import Alerts from './components/main/Alerts.vue' +import Sidebar from './components/main/Sidebar.vue' + +import * as ui from './ui'; +import * as backend from './backend'; + +// possible states: loading, login, signup, logged +const loginState = ref('loading'); + +function triggerAlert () { + ui.alert("This is an alert"); +} + +function handleAuthResult (success) { + loginState.value = success ? 'logged' : 'login'; +} + +function loginSubmit (formElem) { + let username = formElem.username.value; + let password = formElem.password.value; + + loginState.value = 'loading'; + backend.login(username, password).then(handleAuthResult); +} + +function signupSubmit (formElem) { + let email = formElem.email.value; + let username = formElem.username.value; + let password = formElem.password.value; + let passwordRepeat = formElem.passwordRepeat.value; + + if (password != passwordRepeat) { + ui.alert("Passwords don't match"); + return; + } + + loginState.value = 'loading'; + backend.signup(username, email, password).then(handleAuthResult); +} + +backend.authResult().then(handleAuthResult); + + + + + 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)) + }, + }, +})