feat: implement user profile management with avatars, login, and personal data

- Add avatar upload functionality with public URL access.
- Implement user login with email and password authentication.
- Create endpoints for fetching and updating the authenticated user's profile.
- Set up database migrations for user profiles, including email and role management.
- Introduce personal data management linked to user profiles.
- Add email verification process with welcome email templates.
This commit is contained in:
fabio
2026-07-02 10:39:53 +02:00
parent 2608ce6e60
commit 167be9b0b3
94 changed files with 11696 additions and 1 deletions

7
frontend/.editorconfig Normal file
View File

@@ -0,0 +1,7 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
charset = utf-8
indent_size = 2
indent_style = space
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

26
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
.DS_Store
.thumbs.db
node_modules
# .env files
.env*
# Quasar core related directories
.quasar
/dist
/quasar.config.*.temporary.compiled*
# Cordova related directories and files
/src-cordova/node_modules
/src-cordova/platforms
/src-cordova/plugins
/src-cordova/www
# Capacitor related directories and files
/src-capacitor/www
/src-capacitor/node_modules
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*

13
frontend/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,13 @@
{
"recommendations": [
"editorconfig.editorconfig",
"vue.volar",
"wayou.vscode-todo-highlight"
],
"unwantedRecommendations": [
"octref.vetur",
"hookyqr.beautify",
"dbaeumer.jshint",
"ms-vscode.vscode-typescript-tslint-plugin"
]
}

10
frontend/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
"editor.bracketPairColorization.enabled": true,
"editor.guides.bracketPairs": true,
"js/ts.tsdk.path": "node_modules/typescript/lib",
"search.exclude": {
"dist/": true,
".quasar/": true,
"/quasar.config.js.temporary.*": true
}
}

23
frontend/README.md Normal file
View File

@@ -0,0 +1,23 @@
# Quasar App (frontend)
## Install the dependencies
```bash
pnpm install
# or: yarn/npm/bun install
```
### Start the app in development mode (HMR, error reporting, etc.)
```bash
quasar dev
```
### Build the app for production
```bash
quasar build
```
### Customize the configuration
See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js).

15
frontend/env.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
/**
* Add types (that are not auto-magically added by Quasar CLI already)
* for your custom variables to avoid TypeScript errors, like dynamic
* process.env variables or definitions in dotenv files configured ONLY
* for the /quasar.config file itself.
*
* https://quasar.dev/quasar-cli-vite/handling-import-meta-env#type-inference
*
* @example
* interface ImportMetaEnv {
* readonly MY_VAR: string;
* readonly MY_OTHER_VAR: string;
* }
*/
interface ImportMetaEnv {}

25
frontend/index.html Normal file
View File

@@ -0,0 +1,25 @@
<!doctype html>
<html>
<head>
<title><%= productName %></title>
<meta charset="utf-8">
<meta name="description" content="<%= productDescription %>">
<meta name="format-detection" content="telephone=no">
<meta name="msapplication-tap-highlight" content="no">
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>">
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.encr.app<% if (ctx.dev) { %> http://localhost:* http://127.0.0.1:*<% } %>; connect-src 'self' blob: https://*.encr.app<% if (ctx.dev) { %> http://localhost:4000 ws://localhost:*<% } %>;<% if (ctx.dev) { %> worker-src 'self' blob:;<% } %>"
/>
<link rel="icon" type="image/png" sizes="128x128" href="icons/favicon-128x128.png">
<link rel="icon" type="image/png" sizes="96x96" href="icons/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png">
<link rel="icon" type="image/ico" href="favicon.ico">
</head>
<body>
<!-- quasar:entry-point -->
</body>
</html>

45
frontend/package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "frontend",
"version": "0.0.1",
"description": "A Quasar Project",
"productName": "Quasar App",
"author": "fabio <prada.fabio@gmail.com>",
"type": "module",
"private": true,
"scripts": {
"dev": "quasar dev",
"build": "quasar build",
"typecheck": "vue-tsc --noEmit",
"zod:sync": "node tools/zod-sync.mjs",
"postinstall": "quasar prepare --silent"
},
"dependencies": {
"@quasar/extras": "^2.0.0",
"pinia": "^3.0.4",
"quasar": "^2.20.0",
"vue": "^3.5.22",
"vue-advanced-cropper": "^2.8.9",
"vue-i18n": "^11.4.6",
"vue-router": "^5.0.6",
"zod": "^4.4.3"
},
"devDependencies": {
"@quasar/app-vite": "^3.0.0-rc.2",
"@types/node": "^22.19.11",
"autoprefixer": "^10.4.27",
"typescript": "^6.0.0",
"vue-tsc": "^3.3.3"
},
"keywords": [
"quasar",
"quasar-app",
"quasar-cli",
"quasar-app-vite",
"vite",
"vue",
"vuejs"
],
"engines": {
"node": ">= 26 || ^24 || ^22.12"
}
}

3035
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
# https://pnpm.io/settings
allowBuilds:
'@parcel/watcher': true
core-js: true
electron-winstaller: true
esbuild: true
lightningcss: true
rolldown: true
unrs-resolver: true

View File

@@ -0,0 +1,29 @@
// https://github.com/michael-ciniawsky/postcss-load-config
import autoprefixer from 'autoprefixer'
// import rtlcss from 'postcss-rtlcss'
export default {
plugins: [
// https://github.com/postcss/autoprefixer
autoprefixer({
overrideBrowserslist: [
'last 4 Chrome versions',
'last 4 Firefox versions',
'last 4 Edge versions',
'last 4 Safari versions',
'last 4 Android versions',
'last 4 ChromeAndroid versions',
'last 4 FirefoxAndroid versions',
'last 4 iOS versions'
]
}),
// https://github.com/elchininet/postcss-rtlcss
// If you want to support RTL css, then
// 1. yarn/pnpm/bun/npm install postcss-rtlcss
// 2. optionally set quasar.config.js > framework > lang to an RTL language
// 3. uncomment the following line (and its import statement above):
// rtlcss()
]
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

218
frontend/quasar.config.ts Normal file
View File

@@ -0,0 +1,218 @@
// Configuration for your app
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file
import { defineConfig } from '#q-app';
export default defineConfig((/* ctx */) => {
return {
// https://v2.quasar.dev/quasar-cli-vite/prefetch-feature
// preFetch: true,
// app boot file (/src/boot)
// --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli-vite/boot-files
boot: [
'i18n',
],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css
css: [
'app.css'
],
// https://github.com/quasarframework/quasar/tree/dev/extras
extras: [
// 'ionicons-v4',
// 'mdi-v7',
// 'fontawesome-v7',
// 'eva-icons',
// 'themify',
// 'line-awesome',
// 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both!
'roboto-font', // optional, you are not bound to it
'material-icons', // optional, you are not bound to it
],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#build
build: {
target: {
// browser: 'baseline-widely-available',
// node: 'node22'
},
typescript: {
strict: true,
vueShim: true
// extendTsConfig (tsConfig) {}
},
// https://v2.quasar.dev/quasar-cli-vite/page-routing-with-vue-router#filename-based-routing
// filenameBasedRouting: true,
vueRouterMode: 'hash', // available values: 'hash', 'history'
// vueRouterBase,
// vueDevtools,
// publicPath: '/',
// define: {},
// defineEnv: {}
// ignorePublicFolder: true,
// minify: false,
// distDir
extendViteConf (viteConf) {
viteConf.optimizeDeps = {
...viteConf.optimizeDeps,
include: [
...(viteConf.optimizeDeps?.include ?? []),
'zod',
'vue-i18n',
],
};
},
// viteVuePluginOptions: {},
// vitePlugins: [
// [ 'package-name', { ..pluginOptions.. }, { server: true, client: true } ]
// ]
},
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#devserver
devServer: {
// https: true,
open: true // opens browser window automatically
},
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework
framework: {
config: {},
// iconSet: 'material-icons', // Quasar icon set
// lang: 'en-US', // Quasar language pack
// For special cases outside of where the auto-import strategy can have an impact
// (like functional components as one of the examples),
// you can manually specify Quasar components/directives to be available everywhere:
//
// components: [],
// directives: [],
// Quasar plugins
plugins: []
},
// animations: 'all', // --- includes all animations
// https://v2.quasar.dev/options/animations
animations: [],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#sourcefiles
sourceFiles: {
// rootComponent: 'src/App.vue',
// router: 'src/router/index',
store: 'src/stores/index',
// pwaRegisterServiceWorker: 'src-pwa/register-sw',
// pwaServiceWorker: 'src-pwa/sw/custom-sw',
// pwaManifestFile: 'src-pwa/manifest.json',
// electronMain: 'src-electron/electron-main',
// electronPreload: 'src-electron/electron-preload'
// bexManifestFile: 'src-bex/manifest.json'
},
// https://v2.quasar.dev/quasar-cli-vite/developing-ssr/configuring-ssr
ssr: {
prodPort: 3000, // The default port that the production server should use
// (gets superseded if process.env.PORT is specified at runtime)
middlewares: [
'render' // keep this as last one
],
// extendSSRPackageJson (pkgJson) {},
// extendSSRWebserverConf (rolldownConf) {},
// manualStoreSerialization: true,
// manualStoreSsrContextInjection: true,
// manualStoreHydration: true,
// manualPostHydrationTrigger: true,
pwa: false
// pwaOfflineHtmlFilename: 'offline.html', // do NOT use index.html as name!
// extendSSRGenerateSWOptions (cfg) {},
// extendSSRInjectManifestOptions (cfg) {}
},
// https://v2.quasar.dev/quasar-cli-vite/developing-pwa/configuring-pwa
pwa: {
workboxMode: 'GenerateSW' // 'GenerateSW' or 'InjectManifest'
// swFilename: 'sw.js',
// manifestFilename: 'manifest.json',
// extendPWAManifestJson (json) {},
// useCredentialsForManifestTag: true,
// injectPWAMetaTags: false,
// extendPWACustomSWConf (rolldownConf) {},
// extendPWAGenerateSWOptions (cfg) {},
// extendPWAInjectManifestOptions (cfg) {},
// extendPWASwTsConfig (tsConfig) {}
},
// https://v2.quasar.dev/quasar-cli-vite/developing-cordova-apps/configuring-cordova
cordova: {},
// https://v2.quasar.dev/quasar-cli-vite/developing-capacitor-apps/configuring-capacitor
capacitor: {
hideSplashscreen: true
},
// https://v2.quasar.dev/quasar-cli-vite/developing-electron-apps/configuring-electron
electron: {
// extendElectronMainConf (rolldownConf) {},
// extendElectronPreloadConf (rolldownConf) {},
// extendElectronPackageJson (pkgJson) {},
// Electron preload scripts (if any) from /src-electron, WITHOUT file extension
preloadScripts: [ 'electron-preload' ],
// specify the debugging port to use for the Electron app when running in development mode
inspectPort: 5858,
bundler: 'packager', // 'packager' or 'builder'
packager: {
// https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options
// OS X / Mac App Store
// appBundleId: '',
// appCategoryType: '',
// osxSign: '',
// protocol: 'myapp://path',
// Windows only
// win32metadata: { ... }
},
builder: {
// https://www.electron.build/configuration
appId: 'frontend'
}
},
// https://v2.quasar.dev/quasar-cli-vite/developing-browser-extensions/configuring-bex
bex: {
// extendBexScriptsConf (rolldownConf) {},
// extendBexManifestJson (json) {},
/**
* The list of extra scripts (js/ts) not in your bex manifest that you want to
* compile and use in your browser extension. Maybe dynamic use them?
*
* Each entry in the list should be a relative filename to /src-bex/
*
* @example [ 'my-script.ts', 'sub-folder/my-other-script.js' ]
*/
extraScripts: []
}
}
});

3
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 356 360">
<path
d="M43.4 303.4c0 3.8-2.3 6.3-7.1 6.3h-15v-22h14.4c4.3 0 6.2 2.2 6.2 5.2 0 2.6-1.5 4.4-3.4 5 2.8.4 4.9 2.5 4.9 5.5zm-8-13H24.1v6.9H35c2.1 0 4-1.3 4-3.8 0-2.2-1.3-3.1-3.7-3.1zm5.1 12.6c0-2.3-1.8-3.7-4-3.7H24.2v7.7h11.7c3.4 0 4.6-1.8 4.6-4zm36.3 4v2.7H56v-22h20.6v2.7H58.9v6.8h14.6v2.3H58.9v7.5h17.9zm23-5.8v8.5H97v-8.5l-11-13.4h3.4l8.9 11 8.8-11h3.4l-10.8 13.4zm19.1-1.8V298c0-7.9 5.2-10.7 12.7-10.7 7.5 0 13 2.8 13 10.7v1.4c0 7.9-5.5 10.8-13 10.8s-12.7-3-12.7-10.8zm22.7 0V298c0-5.7-3.9-8-10-8-6 0-9.8 2.3-9.8 8v1.4c0 5.8 3.8 8.1 9.8 8.1 6 0 10-2.3 10-8.1zm37.2-11.6v21.9h-2.9l-15.8-17.9v17.9h-2.8v-22h3l15.6 18v-18h2.9zm37.9 10.2v1.3c0 7.8-5.2 10.4-12.4 10.4H193v-22h11.2c7.2 0 12.4 2.8 12.4 10.3zm-3 0c0-5.3-3.3-7.6-9.4-7.6h-8.4V307h8.4c6 0 9.5-2 9.5-7.7V298zm50.8-7.6h-9.7v19.3h-3v-19.3h-9.7v-2.6h22.4v2.6zm34.4-2.6v21.9h-3v-10.1h-16.8v10h-2.8v-21.8h2.8v9.2H296v-9.2h2.9zm34.9 19.2v2.7h-20.7v-22h20.6v2.7H316v6.8h14.5v2.3H316v7.5h17.8zM24 340.2v7.3h13.9v2.4h-14v9.6H21v-22h20v2.7H24zm41.5 11.4h-9.8v7.9H53v-22h13.3c5.1 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6H66c3.1 0 5.3-1.5 5.3-4.7 0-3.3-2.2-4.1-5.3-4.1H55.7v8.8zm47.9 6.2H89l-2 4.3h-3.2l10.7-22.2H98l10.7 22.2h-3.2l-2-4.3zm-1-2.3l-6.3-13-6 13h12.2zm46.3-15.3v21.9H146v-17.2L135.7 358h-2.1l-10.2-15.6v17h-2.8v-21.8h3l11 16.9 11.3-17h3zm35 19.3v2.6h-20.7v-22h20.6v2.7H166v6.8h14.5v2.3H166v7.6h17.8zm47-19.3l-8.3 22h-3l-7.1-18.6-7 18.6h-3l-8.2-22h3.3L204 356l6.8-18.5h3.4L221 356l6.6-18.5h3.3zm10 11.6v-1.4c0-7.8 5.2-10.7 12.7-10.7 7.6 0 13 2.9 13 10.7v1.4c0 7.9-5.4 10.8-13 10.8-7.5 0-12.7-3-12.7-10.8zm22.8 0v-1.4c0-5.7-4-8-10-8s-9.9 2.3-9.9 8v1.4c0 5.8 3.8 8.2 9.8 8.2 6.1 0 10-2.4 10-8.2zm28.3 2.4h-9.8v7.9h-2.8v-22h13.2c5.2 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6h10.2c3 0 5.2-1.5 5.2-4.7 0-3.3-2.1-4.1-5.2-4.1h-10.2v8.8zm40.3-1.5l-6.8 5.6v6.4h-2.9v-22h2.9v12.3l15.2-12.2h3.7l-9.9 8.1 10.3 13.8h-3.6l-8.9-12z" />
<path fill="#050A14"
d="M188.4 71.7a10.4 10.4 0 01-20.8 0 10.4 10.4 0 1120.8 0zM224.2 45c-2.2-3.9-5-7.5-8.2-10.7l-12 7c-3.7-3.2-8-5.7-12.6-7.3a49.4 49.4 0 00-9.7 13.9 59 59 0 0140.1 14l7.6-4.4a57 57 0 00-5.2-12.5zM178 125.1c4.5 0 9-.6 13.4-1.7v-14a40 40 0 0012.5-7.2 47.7 47.7 0 00-7.1-15.3 59 59 0 01-32.2 27.7v8.7c4.4 1.2 8.9 1.8 13.4 1.8zM131.8 45c-2.3 4-4 8.1-5.2 12.5l12 7a40 40 0 000 14.4c5.7 1.5 11.3 2 16.9 1.5a59 59 0 01-8-41.7l-7.5-4.3c-3.2 3.2-6 6.7-8.2 10.6z" />
<path fill="#00B4FF"
d="M224.2 98.4c2.3-3.9 4-8 5.2-12.4l-12-7a40 40 0 000-14.5c-5.7-1.5-11.3-2-16.9-1.5a59 59 0 018 41.7l7.5 4.4c3.2-3.2 6-6.8 8.2-10.7zm-92.4 0c2.2 4 5 7.5 8.2 10.7l12-7a40 40 0 0012.6 7.3c4-4.1 7.3-8.8 9.7-13.8a59 59 0 01-40-14l-7.7 4.4c1.2 4.3 3 8.5 5.2 12.4zm46.2-80c-4.5 0-9 .5-13.4 1.7V34a40 40 0 00-12.5 7.2c1.5 5.7 4 10.8 7.1 15.4a59 59 0 0132.2-27.7V20a53.3 53.3 0 00-13.4-1.8z" />
<path fill="#00B4FF"
d="M178 9.2a62.6 62.6 0 11-.1 125.2A62.6 62.6 0 01178 9.2m0-9.2a71.7 71.7 0 100 143.5A71.7 71.7 0 00178 0z" />
<path fill="#050A14"
d="M96.6 212v4.3c-9.2-.8-15.4-5.8-15.4-17.8V180h4.6v18.4c0 8.6 4 12.6 10.8 13.5zm16-31.9v18.4c0 8.9-4.3 12.8-10.9 13.5v4.4c9.2-.7 15.5-5.6 15.5-18v-18.3h-4.7zM62.2 199v-2.2c0-12.7-8.8-17.4-21-17.4-12.1 0-20.7 4.7-20.7 17.4v2.2c0 12.8 8.6 17.6 20.7 17.6 1.5 0 3-.1 4.4-.3l11.8 6.2 2-3.3-8.2-4-6.4-3.1a32 32 0 01-3.6.2c-9.8 0-16-3.9-16-13.3v-2.2c0-9.3 6.2-13.1 16-13.1 9.9 0 16.3 3.8 16.3 13.1v2.2c0 5.3-2.1 8.7-5.6 10.8l4.8 2.4c3.4-2.8 5.5-7 5.5-13.2zM168 215.6h5.1L156 179.7h-4.8l17 36zM143 205l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.8-3.7H143zm133.7 10.7h5.2l-17.3-35.9h-4.8l17 36zm-25-10.7l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.7-3.7h-14.8zm73.8-2.5c6-1.2 9-5.4 9-11.4 0-8-4.5-10.9-12.9-10.9h-21.4v35.5h4.6v-31.3h16.5c5 0 8.5 1.4 8.5 6.7 0 5.2-3.5 7.7-8.5 7.7h-11.4v4.1h10.7l9.3 12.8h5.5l-9.9-13.2zm-117.4 9.9c-9.7 0-14.7-2.5-18.6-6.3l-2.2 3.8c5.1 5 11 6.7 21 6.7 1.6 0 3.1-.1 4.6-.3l-1.9-4h-3zm18.4-7c0-6.4-4.7-8.6-13.8-9.4l-10.1-1c-6.7-.7-9.3-2.2-9.3-5.6 0-2.5 1.4-4 4.6-5l-1.8-3.8c-4.7 1.4-7.5 4.2-7.5 8.9 0 5.2 3.4 8.7 13 9.6l11.3 1.2c6.4.6 8.9 2 8.9 5.4 0 2.7-2.1 4.7-6 5.8l1.8 3.9c5.3-1.6 8.9-4.7 8.9-10zm-20.3-21.9c7.9 0 13.3 1.8 18.1 5.7l1.8-3.9a30 30 0 00-19.6-5.9c-2 0-4 .1-5.7.3l1.9 4 3.5-.2z" />
<path fill="#00B4FF"
d="M.5 251.9c29.6-.5 59.2-.8 88.8-1l88.7-.3 88.7.3 44.4.4 44.4.6-44.4.6-44.4.4-88.7.3-88.7-.3a7981 7981 0 01-88.8-1z" />
<path fill="none" d="M-565.2 324H-252v15.8h-313.2z" />
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

View File

@@ -0,0 +1,6 @@
import type { App } from 'vue';
import { i18n } from '@/i18n';
export default ({ app }: { app: App<Element> }) => {
app.use(i18n);
};

View File

@@ -0,0 +1,240 @@
<template>
<div class="row items-center q-gutter-md">
<q-avatar size="64px">
<img v-if="modelValue" :src="modelValue" />
<q-icon v-else name="person" size="40px" />
</q-avatar>
<div class="row items-center q-gutter-xs">
<q-btn round flat color="primary" icon="upload" :aria-label="t('actions.chooseFile')" @click="pickFile">
<q-tooltip>{{ t('actions.chooseFile') }}</q-tooltip>
</q-btn>
<q-btn round flat color="primary" icon="photo_camera" :aria-label="t('actions.camera')" @click="openCamera">
<q-tooltip>{{ t('actions.camera') }}</q-tooltip>
</q-btn>
<q-btn
v-if="modelValue"
round
flat
color="warning"
icon="delete"
:aria-label="t('actions.remove')"
@click="$emit('update:modelValue', '')"
>
<q-tooltip>{{ t('actions.remove') }}</q-tooltip>
</q-btn>
</div>
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="onFileSelected" />
<q-dialog v-model="cameraOpen" @hide="stopCamera">
<q-card style="min-width: 350px; max-width: 90vw;">
<q-card-section>
<div class="text-h6">{{ t('actions.camera') }}</div>
</q-card-section>
<q-card-section>
<video
ref="videoRef"
class="camera-preview"
autoplay
muted
playsinline
/>
<div v-if="cameraError" class="text-negative text-caption q-mt-sm">
{{ cameraError }}
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn v-close-popup flat :label="t('actions.cancel')" />
<q-btn color="primary" :label="t('actions.takePhoto')" :disable="!cameraReady" @click="capturePhoto" />
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="cropperOpen" @show="ready = true" @hide="onHide">
<q-card style="min-width: 350px; max-width: 90vw; position: relative;">
<q-card-section>
<div class="text-h6">{{ t('avatar.cropAvatar') }}</div>
</q-card-section>
<q-card-section>
<CropperImage
v-if="source && ready"
ref="cropperRef"
class="cropper"
:src="source"
:width="220"
:height="220"
:show-controls="false"
:show-preview="false"
/>
</q-card-section>
<q-card-actions align="right">
<q-btn v-close-popup flat :label="t('actions.cancel')" />
<q-btn color="primary" :label="t('actions.save')" :loading="uploading" @click="cropAndUpload" />
</q-card-actions>
</q-card>
</q-dialog>
</div>
</template>
<style scoped>
.cropper {
width: min(420px, 82vw);
padding: 0;
border: 0;
background: #DDD;
}
.camera-preview {
display: block;
width: min(420px, 82vw);
max-height: 420px;
background: #222;
}
</style>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import CropperImage from '@/components/CropperImage.vue';
const props = defineProps<{
modelValue: string;
/** Uploads the cropped image and resolves to its public URL. */
uploader: (image: Blob) => Promise<string>;
}>();
const { t } = useI18n();
const emit = defineEmits<{
'update:modelValue': [url: string];
}>();
const fileInput = ref<HTMLInputElement | null>(null);
const cropperRef = ref<InstanceType<typeof CropperImage> | null>(null);
const videoRef = ref<HTMLVideoElement | null>(null);
const cropperOpen = ref(false);
const cameraOpen = ref(false);
const ready = ref(false);
const uploading = ref(false);
const cameraReady = ref(false);
const cameraError = ref<string | null>(null);
const source = ref<string | null>(null);
let cameraStream: MediaStream | null = null;
function pickFile() {
fileInput.value?.click();
}
function onFileSelected(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
setCropperSource(file);
// Allow selecting the same file again later.
input.value = '';
}
function onHide() {
ready.value = false;
clearCropperSource();
}
async function openCamera() {
cameraError.value = null;
cameraReady.value = false;
cameraOpen.value = true;
await nextTick();
await startCamera();
}
async function startCamera() {
if (!navigator.mediaDevices?.getUserMedia) {
cameraError.value = t('avatar.cameraUnavailable');
return;
}
try {
stopCamera();
cameraStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'user' },
audio: false,
});
if (!videoRef.value) return;
videoRef.value.srcObject = cameraStream;
await videoRef.value.play();
cameraReady.value = true;
} catch (err) {
cameraError.value = err instanceof Error ? err.message : String(err);
stopCamera();
}
}
function stopCamera() {
cameraStream?.getTracks().forEach((track) => track.stop());
cameraStream = null;
cameraReady.value = false;
if (videoRef.value) {
videoRef.value.srcObject = null;
}
}
async function capturePhoto() {
const video = videoRef.value;
if (!video?.videoWidth || !video.videoHeight) return;
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const context = canvas.getContext('2d');
if (!context) return;
context.drawImage(video, 0, 0, canvas.width, canvas.height);
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob((b) => resolve(b), 'image/png');
});
if (!blob) return;
cameraOpen.value = false;
stopCamera();
setCropperSource(blob);
}
function setCropperSource(file: Blob) {
clearCropperSource();
source.value = URL.createObjectURL(file);
cropperOpen.value = true;
}
function clearCropperSource() {
if (!source.value) return;
URL.revokeObjectURL(source.value);
source.value = null;
}
async function cropAndUpload() {
const blob = await cropperRef.value?.getCroppedBlob('image/png');
if (!blob) return;
uploading.value = true;
try {
const url = await props.uploader(blob);
emit('update:modelValue', url);
cropperOpen.value = false;
} catch {
// Errors surface through the store's shared error state.
} finally {
uploading.value = false;
}
}
onBeforeUnmount(() => {
stopCamera();
clearCropperSource();
});
</script>

View File

@@ -0,0 +1,480 @@
<template>
<div class="cropper-image">
<div
ref="stageRef"
class="cropper-stage"
@wheel.prevent="zoomImage"
@pointerdown="startImageDrag"
@pointermove="dragImage"
@pointerup="stopImageDrag"
@pointercancel="stopImageDrag"
>
<img
ref="imageRef"
class="cropper-source"
:style="imageStyle"
:src="imageSrc"
alt="Crop source"
draggable="false"
@load="updateImageLayout"
/>
<div
class="cropper-mask"
:style="selectionStyle"
@pointerdown="startDrag"
@pointermove="dragSelection"
@pointerup="stopDrag"
@pointercancel="stopDrag"
/>
</div>
<div v-if="showControls" class="cropper-controls">
<button type="button" @click="cropImage">{{ t('actions.crop') }}</button>
</div>
<canvas
ref="canvasRef"
class="cropper-preview"
:class="{ 'cropper-preview--hidden': !showPreview }"
:width="width"
:height="height"
/>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import cropperImageUrl from '@/assets/pexels-photo-4323307.jpg';
const { t } = useI18n();
const props = withDefaults(
defineProps<{
src?: string;
width?: number;
height?: number;
showControls?: boolean;
showPreview?: boolean;
}>(),
{
src: cropperImageUrl,
width: 45,
height: 45,
showControls: true,
showPreview: true,
},
);
const imageSrc = computed(() => props.src);
const stageRef = ref<HTMLDivElement | null>(null);
const imageRef = ref<HTMLImageElement | null>(null);
const canvasRef = ref<HTMLCanvasElement | null>(null);
let resizeObserver: ResizeObserver | null = null;
let baseImageScale = 1;
const imageLayout = reactive({
left: 0,
top: 0,
width: 0,
height: 0,
zoom: 1,
});
const selection = reactive({
x: 20,
y: 15,
width: 45,
height: 45,
});
const dragState = reactive({
active: false,
pointerId: 0,
startX: 0,
startY: 0,
selectionX: 0,
selectionY: 0,
});
const imageDragState = reactive({
active: false,
pointerId: 0,
startX: 0,
startY: 0,
imageLeft: 0,
imageTop: 0,
});
const maxX = computed(() => 100 - selection.width);
const maxY = computed(() => 100 - selection.height);
const imageStyle = computed(() => ({
left: `${imageLayout.left}px`,
top: `${imageLayout.top}px`,
width: `${imageLayout.width}px`,
height: `${imageLayout.height}px`,
}));
const selectionStyle = computed(() => ({
left: `${selection.x}%`,
top: `${selection.y}%`,
width: `${selection.width}%`,
height: `${selection.height}%`,
}));
watch(
() => [selection.x, selection.y, selection.width, selection.height],
() => {
selection.x = Math.min(selection.x, maxX.value);
selection.y = Math.min(selection.y, maxY.value);
cropImage();
},
);
watch(
() => [props.width, props.height],
() => {
updateSelectionSizeFromProps();
},
);
watch(
() => props.src,
async () => {
imageLayout.zoom = 1;
imageLayout.width = 0;
imageLayout.height = 0;
await nextTick();
updateImageLayout();
},
);
onMounted(async () => {
await nextTick();
updateImageLayout();
if (stageRef.value) {
resizeObserver = new ResizeObserver(() => {
updateImageLayout();
});
resizeObserver.observe(stageRef.value);
}
});
onBeforeUnmount(() => {
resizeObserver?.disconnect();
});
function cropImage() {
const image = imageRef.value;
const canvas = canvasRef.value;
const stage = stageRef.value;
if (
!image ||
!canvas ||
!stage ||
!image.naturalWidth ||
!image.naturalHeight ||
!imageLayout.width ||
!imageLayout.height
) {
return;
}
const stageRect = stage.getBoundingClientRect();
const maskLeft = (selection.x / 100) * stageRect.width;
const maskTop = (selection.y / 100) * stageRect.height;
const maskWidth = (selection.width / 100) * stageRect.width;
const maskHeight = (selection.height / 100) * stageRect.height;
const sx = ((maskLeft - imageLayout.left) / imageLayout.width) * image.naturalWidth;
const sy = ((maskTop - imageLayout.top) / imageLayout.height) * image.naturalHeight;
const sw = (maskWidth / imageLayout.width) * image.naturalWidth;
const sh = (maskHeight / imageLayout.height) * image.naturalHeight;
const context = canvas.getContext('2d');
if (!context) return;
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(image, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
}
async function getCroppedBlob(type = 'image/png', quality?: number): Promise<Blob | null> {
cropImage();
const canvas = canvasRef.value;
if (!canvas) return null;
return await new Promise((resolve) => {
canvas.toBlob((blob) => resolve(blob), type, quality);
});
}
defineExpose({
cropImage,
getCroppedBlob,
});
function updateImageLayout() {
const image = imageRef.value;
const stage = stageRef.value;
if (!image || !stage || !image.naturalWidth || !image.naturalHeight) return;
const rect = stage.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const hadLayout = imageLayout.width > 0 && imageLayout.height > 0;
const visibleCenterX = hadLayout ? rect.width / 2 - imageLayout.left : 0;
const visibleCenterY = hadLayout ? rect.height / 2 - imageLayout.top : 0;
const centerRatioX = hadLayout ? visibleCenterX / imageLayout.width : 0.5;
const centerRatioY = hadLayout ? visibleCenterY / imageLayout.height : 0.5;
baseImageScale = Math.min(1, rect.width / image.naturalWidth, rect.height / image.naturalHeight);
const scale = baseImageScale * imageLayout.zoom;
imageLayout.width = image.naturalWidth * scale;
imageLayout.height = image.naturalHeight * scale;
imageLayout.left = hadLayout
? rect.width / 2 - imageLayout.width * centerRatioX
: (rect.width - imageLayout.width) / 2;
imageLayout.top = hadLayout
? rect.height / 2 - imageLayout.height * centerRatioY
: (rect.height - imageLayout.height) / 2;
clampImagePosition();
updateSelectionSizeFromProps();
}
function zoomImage(event: WheelEvent) {
const zoomStep = event.deltaY < 0 ? 1.1 : 0.9;
imageLayout.zoom = clamp(imageLayout.zoom * zoomStep, 1, 6);
updateImageLayout();
}
function startDrag(event: PointerEvent) {
const stage = stageRef.value;
if (!stage) return;
event.preventDefault();
event.stopPropagation();
const target = event.currentTarget as HTMLElement;
target.setPointerCapture(event.pointerId);
dragState.active = true;
dragState.pointerId = event.pointerId;
dragState.startX = event.clientX;
dragState.startY = event.clientY;
dragState.selectionX = selection.x;
dragState.selectionY = selection.y;
}
function startImageDrag(event: PointerEvent) {
const stage = stageRef.value;
if (!stage || !canDragImage()) return;
event.preventDefault();
stage.setPointerCapture(event.pointerId);
imageDragState.active = true;
imageDragState.pointerId = event.pointerId;
imageDragState.startX = event.clientX;
imageDragState.startY = event.clientY;
imageDragState.imageLeft = imageLayout.left;
imageDragState.imageTop = imageLayout.top;
}
function dragImage(event: PointerEvent) {
if (!imageDragState.active || event.pointerId !== imageDragState.pointerId) return;
imageLayout.left = imageDragState.imageLeft + event.clientX - imageDragState.startX;
imageLayout.top = imageDragState.imageTop + event.clientY - imageDragState.startY;
clampImagePosition();
clampSelectionToImage();
cropImage();
}
function stopImageDrag(event: PointerEvent) {
const stage = stageRef.value;
if (!stage || !imageDragState.active || event.pointerId !== imageDragState.pointerId) return;
if (stage.hasPointerCapture(event.pointerId)) {
stage.releasePointerCapture(event.pointerId);
}
imageDragState.active = false;
}
function dragSelection(event: PointerEvent) {
const stage = stageRef.value;
if (!dragState.active || event.pointerId !== dragState.pointerId || !stage) return;
const rect = stage.getBoundingClientRect();
const dx = ((event.clientX - dragState.startX) / rect.width) * 100;
const dy = ((event.clientY - dragState.startY) / rect.height) * 100;
selection.x = clamp(dragState.selectionX + dx, 0, maxX.value);
selection.y = clamp(dragState.selectionY + dy, 0, maxY.value);
clampSelectionToImage();
}
function stopDrag(event: PointerEvent) {
if (!dragState.active || event.pointerId !== dragState.pointerId) return;
const target = event.currentTarget as HTMLElement;
if (target.hasPointerCapture(event.pointerId)) {
target.releasePointerCapture(event.pointerId);
}
dragState.active = false;
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function updateSelectionSizeFromProps() {
const rect = stageRef.value?.getBoundingClientRect();
if (!rect?.width || !rect.height) return;
const maxMaskWidth = imageLayout.width ? Math.min(rect.width, imageLayout.width) : rect.width;
const maxMaskHeight = imageLayout.height ? Math.min(rect.height, imageLayout.height) : rect.height;
selection.width = sizePixelsToPercent(props.width, rect.width, maxMaskWidth);
selection.height = sizePixelsToPercent(props.height, rect.height, maxMaskHeight);
clampSelectionToImage();
cropImage();
}
function sizePixelsToPercent(value: number, total: number, maxValue = total) {
return (clamp(value, 10, maxValue) / total) * 100;
}
function positionPixelsToPercent(value: number, total: number) {
return (clamp(value, 0, total) / total) * 100;
}
function clampSelectionToImage() {
const rect = stageRef.value?.getBoundingClientRect();
if (!rect?.width || !rect.height) return;
if (!imageLayout.width || !imageLayout.height) {
selection.x = Math.min(selection.x, maxX.value);
selection.y = Math.min(selection.y, maxY.value);
return;
}
const maskWidth = (selection.width / 100) * rect.width;
const maskHeight = (selection.height / 100) * rect.height;
const minX = clamp(imageLayout.left, 0, rect.width - maskWidth);
const minY = clamp(imageLayout.top, 0, rect.height - maskHeight);
const maxXPosition = clamp(
imageLayout.left + imageLayout.width - maskWidth,
minX,
rect.width - maskWidth,
);
const maxYPosition = clamp(
imageLayout.top + imageLayout.height - maskHeight,
minY,
rect.height - maskHeight,
);
selection.x = positionPixelsToPercent(
clamp((selection.x / 100) * rect.width, minX, maxXPosition),
rect.width,
);
selection.y = positionPixelsToPercent(
clamp((selection.y / 100) * rect.height, minY, maxYPosition),
rect.height,
);
}
function canDragImage() {
const rect = stageRef.value?.getBoundingClientRect();
if (!rect?.width || !rect.height) return false;
return imageLayout.width > rect.width || imageLayout.height > rect.height;
}
function clampImagePosition() {
const rect = stageRef.value?.getBoundingClientRect();
if (!rect?.width || !rect.height || !imageLayout.width || !imageLayout.height) return;
if (imageLayout.width <= rect.width) {
imageLayout.left = (rect.width - imageLayout.width) / 2;
} else {
imageLayout.left = clamp(imageLayout.left, rect.width - imageLayout.width, 0);
}
if (imageLayout.height <= rect.height) {
imageLayout.top = (rect.height - imageLayout.height) / 2;
} else {
imageLayout.top = clamp(imageLayout.top, rect.height - imageLayout.height, 0);
}
}
</script>
<style scoped>
.cropper-image {
display: grid;
gap: 16px;
width: min(640px, 90vw);
padding: 16px;
border: 1px solid #d0d0d0;
background: #fff;
}
.cropper-stage {
position: relative;
height: 420px;
overflow: hidden;
background: #ddd;
cursor: grab;
touch-action: none;
user-select: none;
}
.cropper-stage:active {
cursor: grabbing;
}
.cropper-source {
position: absolute;
display: block;
max-width: none;
user-select: none;
}
.cropper-mask {
position: absolute;
border: 2px solid #1976d2;
box-shadow: 0 0 0 9999px rgb(0 0 0 / 45%);
cursor: move;
touch-action: none;
user-select: none;
}
.cropper-controls {
display: grid;
gap: 10px;
}
.cropper-controls button {
justify-self: start;
padding: 8px 14px;
border: 0;
color: #fff;
background: #1976d2;
cursor: pointer;
}
.cropper-preview {
width: 220px;
height: 220px;
border: 1px solid #d0d0d0;
background: #f5f5f5;
}
.cropper-preview--hidden {
display: none;
}
</style>

View File

@@ -0,0 +1,76 @@
<template>
<q-item
clickable
:to="profilesStore.isAuthenticated ? '/profile' : '/login'"
class="drawer-user-card"
>
<q-item-section avatar>
<q-avatar size="48px">
<img v-if="profile?.avatar_url" :src="profile.avatar_url" />
<q-icon v-else name="person" size="32px" />
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label class="text-weight-medium">
{{ profileName }}
</q-item-label>
<q-item-label v-if="profile?.email" caption>
{{ profile.email }}
</q-item-label>
</q-item-section>
<q-item-section v-if="profilesStore.isAuthenticated" side>
<div class="column items-center">
<q-btn
flat
round
dense
icon="edit"
:aria-label="t('actions.editProfile')"
to="/profile"
@click.stop
>
<q-tooltip>{{ t('actions.editProfile') }}</q-tooltip>
</q-btn>
<q-btn
flat
round
dense
icon="logout"
:aria-label="t('nav.logoutAction')"
@click.stop.prevent="logout"
>
<q-tooltip>{{ t('nav.logoutAction') }}</q-tooltip>
</q-btn>
</div>
</q-item-section>
</q-item>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useProfilesStore } from '@/stores/profiles-store';
const profilesStore = useProfilesStore();
const router = useRouter();
const { t } = useI18n();
const profile = computed(() => profilesStore.profile);
const profileName = computed(() => {
if (!profilesStore.isAuthenticated) return t('nav.login');
return profile.value?.display_name || profile.value?.email || '';
});
async function logout() {
profilesStore.logout();
await router.push('/');
}
</script>
<style scoped>
.drawer-user-card {
min-height: 72px;
}
</style>

View File

@@ -0,0 +1,35 @@
<template>
<q-item
clickable
tag="a"
target="_blank"
:href="link"
>
<q-item-section
v-if="icon"
avatar
>
<q-icon :name="icon" />
</q-item-section>
<q-item-section>
<q-item-label>{{ label }}</q-item-label>
<q-item-label caption>{{ caption }}</q-item-label>
</q-item-section>
</q-item>
</template>
<script setup lang="ts">
export interface EssentialLinkProps {
label: string;
caption?: string;
link?: string;
icon?: string;
};
withDefaults(defineProps<EssentialLinkProps>(), {
caption: '',
link: '#',
icon: '',
});
</script>

1
frontend/src/css/app.css Normal file
View File

@@ -0,0 +1 @@
/* app global css */

View File

@@ -0,0 +1,257 @@
export const countries = [
{'CH' : 'Switzerland'},
{'IT' : 'Italy'},
{'FR' : 'France'},
{'DE' : 'Germany'},
{'GB' : 'United Kingdom of Great Britain and Northern Ireland (the)'},
{'US' : 'United States of America (the)'},
{'AF' : 'Afghanistan'},
{'AL' : 'Albania'},
{'DZ' : 'Algeria'},
{'AS' : 'American Samoa'},
{'AD' : 'Andorra'},
{'AO' : 'Angola'},
{'AI' : 'Anguilla'},
{'AQ' : 'Antarctica'},
{'AG' : 'Antigua and Barbuda'},
{'AR' : 'Argentina'},
{'AM' : 'Armenia'},
{'AW' : 'Aruba'},
{'AU' : 'Australia'},
{'AT' : 'Austria'},
{'AZ' : 'Azerbaijan'},
{'BS' : 'Bahamas (The)'},
{'BH' : 'Bahrain'},
{'BD' : 'Bangladesh'},
{'BB' : 'Barbados'},
{'BY' : 'Belarus'},
{'BE' : 'Belgium'},
{'BZ' : 'Belize'},
{'BJ' : 'Benin'},
{'BM' : 'Bermuda'},
{'BT' : 'Bhutan'},
{'BO' : 'Bolivia (Plurinational State of)'},
{'BQ' : 'Bonaire, Sint Eustatius and Saba'},
{'BA' : 'Bosnia and Herzegovina'},
{'BW' : 'Botswana'},
{'BV' : 'Bouvet Island'},
{'BR' : 'Brazil'},
{'IO' : 'British Indian Ocean Territory (the)'},
{'BN' : 'Brunei Darussalam'},
{'BG' : 'Bulgaria'},
{'BF' : 'Burkina Faso'},
{'BI' : 'Burundi'},
{'CV' : 'Cabo Verde'},
{'KH' : 'Cambodia'},
{'CM' : 'Cameroon'},
{'CA' : 'Canada'},
{'KY' : 'Cayman Islands (the)'},
{'CF' : 'Central African Republic (the)'},
{'TD' : 'Chad'},
{'CL' : 'Chile'},
{'CN' : 'China'},
{'CX' : 'Christmas Island'},
{'CC' : 'Cocos (Keeling) Islands (the)'},
{'CO' : 'Colombia'},
{'KM' : 'Comoros (the)'},
{'CD' : 'Congo (the Democratic Republic of the)'},
{'CG' : 'Congo (the)'},
{'CK' : 'Cook Islands (the)'},
{'CR' : 'Costa Rica'},
{'HR' : 'Croatia'},
{'CU' : 'Cuba'},
{'CW' : 'Curaçao'},
{'CY' : 'Cyprus'},
{'CZ' : 'Czechia'},
{'CI' : "Côte d'Ivoire"},
{'DK' : 'Denmark'},
{'DJ' : 'Djibouti'},
{'DM' : 'Dominica'},
{'DO' : 'Dominican Republic (the)'},
{'EC' : 'Ecuador'},
{'EG' : 'Egypt'},
{'SV' : 'El Salvador'},
{'GQ' : 'Equatorial Guinea'},
{'ER' : 'Eritrea'},
{'EE' : 'Estonia'},
{'SZ' : 'Eswatini'},
{'ET' : 'Ethiopia'},
{'FK' : 'Falkland Islands (the) [Malvinas]'},
{'FO' : 'Faroe Islands (the)'},
{'FJ' : 'Fiji'},
{'FI' : 'Finland'},
{'FR' : 'France'},
{'GF' : 'French Guiana'},
{'PF' : 'French Polynesia'},
{'TF' : 'French Southern Territories (the)'},
{'GA' : 'Gabon'},
{'GM' : 'Gambia (the)'},
{'GE' : 'Georgia'},
{'GH' : 'Ghana'},
{'GI' : 'Gibraltar'},
{'GR' : 'Greece'},
{'GL' : 'Greenland'},
{'GD' : 'Grenada'},
{'GP' : 'Guadeloupe'},
{'GU' : 'Guam'},
{'GT' : 'Guatemala'},
{'GG' : 'Guernsey'},
{'GN' : 'Guinea'},
{'GW' : 'Guinea-Bissau'},
{'GY' : 'Guyana'},
{'HT' : 'Haiti'},
{'HM' : 'Heard Island and McDonald Islands'},
{'VA' : 'Holy See (the)'},
{'HN' : 'Honduras'},
{'HK' : 'Hong Kong'},
{'HU' : 'Hungary'},
{'IS' : 'Iceland'},
{'IN' : 'India'},
{'ID' : 'Indonesia'},
{'IR' : 'Iran (Islamic Republic of)'},
{'IQ' : 'Iraq'},
{'IE' : 'Ireland'},
{'IM' : 'Isle of Man'},
{'IL' : 'Israel'},
{'JM' : 'Jamaica'},
{'JP' : 'Japan'},
{'JE' : 'Jersey'},
{'JO' : 'Jordan'},
{'KZ' : 'Kazakhstan'},
{'KE' : 'Kenya'},
{'KI' : 'Kiribati'},
{'KP' : "Korea (the Democratic People's Republic of)"},
{'KR' : 'Korea (the Republic of)'},
{'KW' : 'Kuwait'},
{'KG' : 'Kyrgyzstan'},
{'LA' : "Lao People's Democratic Republic (the)"},
{'LV' : 'Latvia'},
{'LB' : 'Lebanon'},
{'LS' : 'Lesotho'},
{'LR' : 'Liberia'},
{'LY' : 'Libya'},
{'LI' : 'Liechtenstein'},
{'LT' : 'Lithuania'},
{'LU' : 'Luxembourg'},
{'MO' : 'Macao'},
{'MG' : 'Madagascar'},
{'MW' : 'Malawi'},
{'MY' : 'Malaysia'},
{'MV' : 'Maldives'},
{'ML' : 'Mali'},
{'MT' : 'Malta'},
{'MH' : 'Marshall Islands (the)'},
{'MQ' : 'Martinique'},
{'MR' : 'Mauritania'},
{'MU' : 'Mauritius'},
{'YT' : 'Mayotte'},
{'MX' : 'Mexico'},
{'FM' : 'Micronesia (Federated States of)'},
{'MD' : 'Moldova (the Republic of)'},
{'MC' : 'Monaco'},
{'MN' : 'Mongolia'},
{'ME' : 'Montenegro'},
{'MS' : 'Montserrat'},
{'MA' : 'Morocco'},
{'MZ' : 'Mozambique'},
{'MM' : 'Myanmar'},
{'NA' : 'Namibia'},
{'NR' : 'Nauru'},
{'NP' : 'Nepal'},
{'NL' : 'Netherlands (Kingdom of the)'},
{'NC' : 'New Caledonia'},
{'NZ' : 'New Zealand'},
{'NI' : 'Nicaragua'},
{'NE' : 'Niger (the)'},
{'NG' : 'Nigeria'},
{'NU' : 'Niue'},
{'NF' : 'Norfolk Island'},
{'MK' : 'North Macedonia'},
{'MP' : 'Northern Mariana Islands (the)'},
{'NO' : 'Norway'},
{'OM' : 'Oman'},
{'PK' : 'Pakistan'},
{'PW' : 'Palau'},
{'PS' : 'Palestine, State of'},
{'PA' : 'Panama'},
{'PG' : 'Papua New Guinea'},
{'PY' : 'Paraguay'},
{'PE' : 'Peru'},
{'PH' : 'Philippines (the)'},
{'PN' : 'Pitcairn'},
{'PL' : 'Poland'},
{'PT' : 'Portugal'},
{'PR' : 'Puerto Rico'},
{'QA' : 'Qatar'},
{'RO' : 'Romania'},
{'RU' : 'Russian Federation (the)'},
{'RW' : 'Rwanda'},
{'RE' : 'Réunion'},
{'BL' : 'Saint Barthélemy'},
{'SH' : 'Saint Helena, Ascension and Tristan da Cunha'},
{'KN' : 'Saint Kitts and Nevis'},
{'LC' : 'Saint Lucia'},
{'MF' : 'Saint Martin (French part)'},
{'PM' : 'Saint Pierre and Miquelon'},
{'VC' : 'Saint Vincent and the Grenadines'},
{'WS' : 'Samoa'},
{'SM' : 'San Marino'},
{'ST' : 'Sao Tome and Principe'},
{'SA' : 'Saudi Arabia'},
{'SN' : 'Senegal'},
{'RS' : 'Serbia'},
{'SC' : 'Seychelles'},
{'SL' : 'Sierra Leone'},
{'SG' : 'Singapore'},
{'SX' : 'Sint Maarten (Dutch part)'},
{'SK' : 'Slovakia'},
{'SI' : 'Slovenia'},
{'SB' : 'Solomon Islands'},
{'SO' : 'Somalia'},
{'ZA' : 'South Africa'},
{'GS' : 'South Georgia and the South Sandwich Islands'},
{'SS' : 'South Sudan'},
{'ES' : 'Spain'},
{'LK' : 'Sri Lanka'},
{'SD' : 'Sudan (the)'},
{'SR' : 'Suriname'},
{'SJ' : 'Svalbard and Jan Mayen'},
{'SE' : 'Sweden'},
{'SY' : 'Syrian Arab Republic (the)'},
{'TW' : 'Taiwan (Province of China)'},
{'TJ' : 'Tajikistan'},
{'TZ' : 'Tanzania, the United Republic of'},
{'TH' : 'Thailand'},
{'TL' : 'Timor-Leste'},
{'TG' : 'Togo'},
{'TK' : 'Tokelau'},
{'TO' : 'Tonga'},
{'TT' : 'Trinidad and Tobago'},
{'TN' : 'Tunisia'},
{'TM' : 'Turkmenistan'},
{'TC' : 'Turks and Caicos Islands (the)'},
{'TV' : 'Tuvalu'},
{'TR' : 'Türkiye'},
{'UG' : 'Uganda'},
{'UA' : 'Ukraine'},
{'AE' : 'United Arab Emirates (the)'},
{'GB' : 'United Kingdom of Great Britain and Northern Ireland (the)'},
{'UM' : 'United States Minor Outlying Islands (the)'},
{'UY' : 'Uruguay'},
{'UZ' : 'Uzbekistan'},
{'VU' : 'Vanuatu'},
{'VE' : 'Venezuela (Bolivarian Republic of)'},
{'VN' : 'Viet Nam'},
{'VG' : 'Virgin Islands (British)'},
{'VI' : 'Virgin Islands (U.S.)'},
{'WF' : 'Wallis and Futuna'},
{'EH' : 'Western Sahara*'},
{'YE' : 'Yemen'},
{'ZM' : 'Zambia'},
{'ZW' : 'Zimbabwe'},
]

1365
frontend/src/encore/client.ts Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
// Code synced from src/encore/client.ts by frontend/tools/zod-sync.mjs.
// Existing field validators are preserved when this file is synced again.
import { z } from 'zod';
export const AdminPersonalDataParamsSchema = z.object({
first_name: z.string().min(2).max(32),
last_name: z.string().min(2).max(32),
address: z.string().min(5).max(32),
city: z.string().min(2).max(32),
country: z.string(),
});
export const AdminProfileParamsSchema = z.object({
display_name: z.string().min(2).max(32),
avatar_url: z.string(),
});
export const AdminRegisterParamsSchema = z.object({
email: z.string(),
display_name: z.string(),
avatar_url: z.string(),
password: z.string(),
});
export const AdminUpdateProfileArtistParamsSchema = z.object({
is_artist: z.boolean(),
});
export const AdminUpdateProfileRoleParamsSchema = z.object({
role: z.string(),
});
export const AdminUpdateProfileStatusParamsSchema = z.object({
status: z.number(),
});
export const AuthSetPasswordParamsSchema = z.object({
password: z.string(),
});
export const ProfilesLoginParamsSchema = z.object({
user_email: z.string(),
password: z.string(),
});
export const ProfilesPersonalDataParamsSchema = z.object({
first_name: z.string(),
last_name: z.string(),
address: z.string(),
city: z.string(),
country: z.string(),
});
export const ProfilesProfileParamsSchema = z.object({
display_name: z.string(),
avatar_url: z.string(),
});
export const ProfilesRegisterParamsSchema = z.object({
email: z.string(),
display_name: z.string(),
avatar_url: z.string(),
password: z.string(),
});
export const RegistrationRegisterParamsSchema = z.object({
email: z.string().email(),
display_name: z.string().min(2).max(32),
avatar_url: z.string(),
password: z.string().min(8),
});

558
frontend/src/i18n/index.ts Normal file
View File

@@ -0,0 +1,558 @@
import { createI18n } from 'vue-i18n';
export type SupportedLocale = 'en' | 'it' | 'fr' | 'de' | 'es';
export const localeOptions: { label: string; value: SupportedLocale }[] = [
{ label: 'English', value: 'en' },
{ label: 'Italiano', value: 'it' },
{ label: 'Français', value: 'fr' },
{ label: 'Deutsch', value: 'de' },
{ label: 'Español', value: 'es' },
];
const fallbackLocale: SupportedLocale = 'en';
const storageKey = 'app.locale';
const messages = {
en: {
app: { title: 'Encore Profiles' },
nav: {
menu: 'Menu',
profiles: 'Profiles',
myProfile: 'My profile',
login: 'Log in',
register: 'Register',
logout: 'Log out ({name})',
logoutAction: 'Log out',
language: 'Language',
},
actions: {
cancel: 'Cancel',
save: 'Save',
create: 'Create',
remove: 'Remove',
chooseFile: 'Choose file',
camera: 'Camera',
takePhoto: 'Take photo',
crop: 'Crop',
newProfile: 'New profile',
register: 'Register',
editProfile: 'Edit profile',
updateRole: 'Update role',
updateStatus: 'Update status',
},
fields: {
email: 'Email',
password: 'Password',
displayName: 'Display name',
firstName: 'First name',
lastName: 'Last name',
address: 'Address',
city: 'City',
country: 'Country',
role: 'Role',
status: 'Status',
},
avatar: {
cropAvatar: 'Crop avatar',
cameraUnavailable: 'Camera not available in this browser.',
},
admin: {
dashboard: 'Dashboard',
profiles: 'Profiles',
personalData: 'Personal data',
profile: 'Profile',
notArtist: 'Not an artist',
artist: 'Artist',
columns: {
name: 'Name',
email: 'Email',
role: 'Role',
status: 'Status',
artist: 'Artist',
created: 'Created',
updated: 'Updated',
},
},
profile: {
title: 'My profile',
loginRequired: 'You must be logged in to view your profile.',
},
login: {
title: 'Log in',
needAccount: 'Create an account',
},
register: {
title: 'Create account',
welcomeTitle: 'Welcome',
haveAccount: 'I already have an account',
success: 'Registration completed. You can now log in.',
confirmationSent: 'We sent you a confirmation request. Please check your mailbox.',
emailUnavailable: 'This email is already registered.',
emailDomainInvalid: 'This email domain cannot receive email.',
},
welcome: {
title: 'Email confirmation',
success: 'Email confirmed for {email}.',
missingToken: 'Missing confirmation token.',
},
pages: {
home: 'Home',
goHome: 'Go Home',
goToIndex: 'Go to Index Page',
goToSecond: 'Go to Second Page',
cropperExample: 'Cropper example',
notFound: 'Oops. Nothing here...',
unauthorized: 'Unauthorized',
unauthorizedHint: 'You need to log in to access this page.',
},
status: {
0: 'Active',
1: 'Inactive',
2: 'Suspended',
3: 'Deleted',
4: 'Pending',
5: 'Waiting deletion',
6: 'Banned',
unknown: 'Unknown ({status})',
},
},
it: {
app: { title: 'Profili Encore' },
nav: {
menu: 'Menu',
profiles: 'Profili',
myProfile: 'Il mio profilo',
login: 'Accedi',
register: 'Registrati',
logout: 'Esci ({name})',
logoutAction: 'Esci',
language: 'Lingua',
},
actions: {
cancel: 'Annulla',
save: 'Salva',
create: 'Crea',
remove: 'Rimuovi',
chooseFile: 'Scegli file',
camera: 'Fotocamera',
takePhoto: 'Scatta foto',
crop: 'Ritaglia',
newProfile: 'Nuovo profilo',
register: 'Registrati',
editProfile: 'Modifica profilo',
updateRole: 'Aggiorna ruolo',
updateStatus: 'Aggiorna stato',
},
fields: {
email: 'Email',
password: 'Password',
displayName: 'Nome visualizzato',
firstName: 'Nome',
lastName: 'Cognome',
address: 'Indirizzo',
city: 'Città',
country: 'Paese',
role: 'Ruolo',
status: 'Stato',
},
avatar: {
cropAvatar: 'Ritaglia avatar',
cameraUnavailable: 'Fotocamera non disponibile in questo browser.',
},
admin: {
dashboard: 'Dashboard',
profiles: 'Profili',
personalData: 'Dati personali',
profile: 'Profilo',
notArtist: 'Non artista',
artist: 'Artista',
columns: {
name: 'Nome',
email: 'Email',
role: 'Ruolo',
status: 'Stato',
artist: 'Artista',
created: 'Creato',
updated: 'Aggiornato',
},
},
profile: {
title: 'Il mio profilo',
loginRequired: 'Devi effettuare laccesso per visualizzare il profilo.',
},
login: {
title: 'Accedi',
needAccount: 'Crea un account',
},
register: {
title: 'Crea account',
welcomeTitle: 'Benvenuto',
haveAccount: 'Ho già un account',
success: 'Registrazione completata. Ora puoi accedere.',
confirmationSent: 'Ti abbiamo inviato una richieta di conferma, consulta la tua casella di posta.',
emailUnavailable: 'Questa email è già registrata.',
emailDomainInvalid: 'Il dominio di questa email non può ricevere posta.',
},
welcome: {
title: 'Conferma email',
success: 'Email confermata per {email}.',
missingToken: 'Token di conferma mancante.',
},
pages: {
home: 'Home',
goHome: 'Vai alla home',
goToIndex: 'Vai alla pagina iniziale',
goToSecond: 'Vai alla seconda pagina',
cropperExample: 'Esempio cropper',
notFound: 'Ops. Qui non cè niente...',
unauthorized: 'Non autorizzato',
unauthorizedHint: 'Devi effettuare laccesso per visualizzare questa pagina.',
},
status: {
0: 'Attivo',
1: 'Inattivo',
2: 'Sospeso',
3: 'Eliminato',
4: 'In attesa',
5: 'In attesa di eliminazione',
6: 'Bannato',
unknown: 'Sconosciuto ({status})',
},
},
fr: {
app: { title: 'Profils Encore' },
nav: {
menu: 'Menu',
profiles: 'Profils',
myProfile: 'Mon profil',
login: 'Connexion',
register: 'Inscription',
logout: 'Déconnexion ({name})',
logoutAction: 'Déconnexion',
language: 'Langue',
},
actions: {
cancel: 'Annuler',
save: 'Enregistrer',
create: 'Créer',
remove: 'Supprimer',
chooseFile: 'Choisir un fichier',
camera: 'Caméra',
takePhoto: 'Prendre une photo',
crop: 'Recadrer',
newProfile: 'Nouveau profil',
register: 'Sinscrire',
editProfile: 'Modifier le profil',
updateRole: 'Modifier le rôle',
updateStatus: 'Modifier le statut',
},
fields: {
email: 'Email',
password: 'Mot de passe',
displayName: 'Nom affiché',
firstName: 'Prénom',
lastName: 'Nom',
address: 'Adresse',
city: 'Ville',
country: 'Pays',
role: 'Rôle',
status: 'Statut',
},
avatar: {
cropAvatar: 'Recadrer lavatar',
cameraUnavailable: 'Caméra non disponible dans ce navigateur.',
},
admin: {
dashboard: 'Tableau de bord',
profiles: 'Profils',
personalData: 'Données personnelles',
profile: 'Profil',
notArtist: 'Pas artiste',
artist: 'Artiste',
columns: {
name: 'Nom',
email: 'Email',
role: 'Rôle',
status: 'Statut',
artist: 'Artiste',
created: 'Créé',
updated: 'Mis à jour',
},
},
profile: {
title: 'Mon profil',
loginRequired: 'Vous devez être connecté pour voir votre profil.',
},
login: {
title: 'Connexion',
needAccount: 'Créer un compte',
},
register: {
title: 'Créer un compte',
welcomeTitle: 'Bienvenue',
haveAccount: 'Jai déjà un compte',
success: 'Inscription terminée. Vous pouvez maintenant vous connecter.',
confirmationSent: 'Nous vous avons envoyé une demande de confirmation. Veuillez consulter votre boîte mail.',
emailUnavailable: 'Cet email est déjà enregistré.',
emailDomainInvalid: 'Ce domaine email ne peut pas recevoir de-mails.',
},
welcome: {
title: 'Confirmation email',
success: 'Email confirmée pour {email}.',
missingToken: 'Jeton de confirmation manquant.',
},
pages: {
home: 'Accueil',
goHome: 'Accueil',
goToIndex: 'Aller à la page daccueil',
goToSecond: 'Aller à la deuxième page',
cropperExample: 'Exemple de recadrage',
notFound: 'Oups. Rien ici...',
unauthorized: 'Non autorisé',
unauthorizedHint: 'Vous devez vous connecter pour accéder à cette page.',
},
status: {
0: 'Actif',
1: 'Inactif',
2: 'Suspendu',
3: 'Supprimé',
4: 'En attente',
5: 'Suppression en attente',
6: 'Banni',
unknown: 'Inconnu ({status})',
},
},
de: {
app: { title: 'Encore Profile' },
nav: {
menu: 'Menü',
profiles: 'Profile',
myProfile: 'Mein Profil',
login: 'Anmelden',
register: 'Registrieren',
logout: 'Abmelden ({name})',
logoutAction: 'Abmelden',
language: 'Sprache',
},
actions: {
cancel: 'Abbrechen',
save: 'Speichern',
create: 'Erstellen',
remove: 'Entfernen',
chooseFile: 'Datei wählen',
camera: 'Kamera',
takePhoto: 'Foto aufnehmen',
crop: 'Zuschneiden',
newProfile: 'Neues Profil',
register: 'Registrieren',
editProfile: 'Profil bearbeiten',
updateRole: 'Rolle ändern',
updateStatus: 'Status ändern',
},
fields: {
email: 'E-Mail',
password: 'Passwort',
displayName: 'Anzeigename',
firstName: 'Vorname',
lastName: 'Nachname',
address: 'Adresse',
city: 'Stadt',
country: 'Land',
role: 'Rolle',
status: 'Status',
},
avatar: {
cropAvatar: 'Avatar zuschneiden',
cameraUnavailable: 'Kamera ist in diesem Browser nicht verfügbar.',
},
admin: {
dashboard: 'Dashboard',
profiles: 'Profile',
personalData: 'Persönliche Daten',
profile: 'Profil',
notArtist: 'Kein Künstler',
artist: 'Künstler',
columns: {
name: 'Name',
email: 'E-Mail',
role: 'Rolle',
status: 'Status',
artist: 'Künstler',
created: 'Erstellt',
updated: 'Aktualisiert',
},
},
profile: {
title: 'Mein Profil',
loginRequired: 'Sie müssen angemeldet sein, um Ihr Profil zu sehen.',
},
login: {
title: 'Anmelden',
needAccount: 'Konto erstellen',
},
register: {
title: 'Konto erstellen',
welcomeTitle: 'Willkommen',
haveAccount: 'Ich habe bereits ein Konto',
success: 'Registrierung abgeschlossen. Sie können sich jetzt anmelden.',
confirmationSent: 'Wir haben Ihnen eine Bestätigungsanfrage gesendet. Bitte prüfen Sie Ihr Postfach.',
emailUnavailable: 'Diese E-Mail ist bereits registriert.',
emailDomainInvalid: 'Diese E-Mail-Domain kann keine E-Mails empfangen.',
},
welcome: {
title: 'E-Mail-Bestätigung',
success: 'E-Mail für {email} bestätigt.',
missingToken: 'Bestätigungstoken fehlt.',
},
pages: {
home: 'Startseite',
goHome: 'Zur Startseite',
goToIndex: 'Zur Startseite',
goToSecond: 'Zur zweiten Seite',
cropperExample: 'Cropper-Beispiel',
notFound: 'Hoppla. Hier ist nichts...',
unauthorized: 'Nicht autorisiert',
unauthorizedHint: 'Sie müssen angemeldet sein, um diese Seite aufzurufen.',
},
status: {
0: 'Aktiv',
1: 'Inaktiv',
2: 'Gesperrt',
3: 'Gelöscht',
4: 'Ausstehend',
5: 'Löschung ausstehend',
6: 'Gebannt',
unknown: 'Unbekannt ({status})',
},
},
es: {
app: { title: 'Perfiles Encore' },
nav: {
menu: 'Menú',
profiles: 'Perfiles',
myProfile: 'Mi perfil',
login: 'Iniciar sesión',
register: 'Registrarse',
logout: 'Cerrar sesión ({name})',
logoutAction: 'Cerrar sesión',
language: 'Idioma',
},
actions: {
cancel: 'Cancelar',
save: 'Guardar',
create: 'Crear',
remove: 'Eliminar',
chooseFile: 'Elegir archivo',
camera: 'Cámara',
takePhoto: 'Tomar foto',
crop: 'Recortar',
newProfile: 'Nuevo perfil',
register: 'Registrarse',
editProfile: 'Editar perfil',
updateRole: 'Actualizar rol',
updateStatus: 'Actualizar estado',
},
fields: {
email: 'Email',
password: 'Contraseña',
displayName: 'Nombre visible',
firstName: 'Nombre',
lastName: 'Apellido',
address: 'Dirección',
city: 'Ciudad',
country: 'País',
role: 'Rol',
status: 'Estado',
},
avatar: {
cropAvatar: 'Recortar avatar',
cameraUnavailable: 'La cámara no está disponible en este navegador.',
},
admin: {
dashboard: 'Panel',
profiles: 'Perfiles',
personalData: 'Datos personales',
profile: 'Perfil',
notArtist: 'No artista',
artist: 'Artista',
columns: {
name: 'Nombre',
email: 'Email',
role: 'Rol',
status: 'Estado',
artist: 'Artista',
created: 'Creado',
updated: 'Actualizado',
},
},
profile: {
title: 'Mi perfil',
loginRequired: 'Debes iniciar sesión para ver tu perfil.',
},
login: {
title: 'Iniciar sesión',
needAccount: 'Crear una cuenta',
},
register: {
title: 'Crear cuenta',
welcomeTitle: 'Bienvenido',
haveAccount: 'Ya tengo una cuenta',
success: 'Registro completado. Ahora puedes iniciar sesión.',
confirmationSent: 'Te hemos enviado una solicitud de confirmación. Revisa tu buzón de correo.',
emailUnavailable: 'Este email ya está registrado.',
emailDomainInvalid: 'Este dominio de email no puede recibir correo.',
},
welcome: {
title: 'Confirmación de email',
success: 'Email confirmado para {email}.',
missingToken: 'Falta el token de confirmación.',
},
pages: {
home: 'Inicio',
goHome: 'Ir al inicio',
goToIndex: 'Ir a la página inicial',
goToSecond: 'Ir a la segunda página',
cropperExample: 'Ejemplo de recorte',
notFound: 'Vaya. Aquí no hay nada...',
unauthorized: 'No autorizado',
unauthorizedHint: 'Debes iniciar sesión para acceder a esta página.',
},
status: {
0: 'Activo',
1: 'Inactivo',
2: 'Suspendido',
3: 'Eliminado',
4: 'Pendiente',
5: 'Esperando eliminación',
6: 'Bloqueado',
unknown: 'Desconocido ({status})',
},
},
};
function detectLocale(): SupportedLocale {
const stored = window.localStorage.getItem(storageKey);
if (isSupportedLocale(stored)) return stored;
const browserLocale = navigator.language.split('-')[0] ?? null;
if (isSupportedLocale(browserLocale)) return browserLocale;
return fallbackLocale;
}
function isSupportedLocale(locale: string | null): locale is SupportedLocale {
return localeOptions.some((option) => option.value === locale);
}
export const i18n = createI18n({
legacy: false,
locale: detectLocale(),
fallbackLocale,
messages,
});
export function setLocale(locale: SupportedLocale) {
i18n.global.locale.value = locale;
window.localStorage.setItem(storageKey, locale);
}

View File

@@ -0,0 +1,132 @@
<template>
<q-layout view="lHh Lpr lFf">
<q-header elevated class="bg-warning text-black">
<q-toolbar>
<q-btn
flat
dense
round
icon="menu"
:aria-label="t('nav.menu')"
@click="toggleLeftDrawer"
/>
<q-toolbar-title>
{{ t('app.title') }}
</q-toolbar-title>
<q-select
v-model="selectedLocale"
:options="localeOptions"
option-label="label"
option-value="value"
emit-value
map-options
dense
borderless
class="q-ml-md language-select"
:aria-label="t('nav.language')"
/>
</q-toolbar>
</q-header>
<q-drawer
v-model="leftDrawerOpen"
show-if-above
bordered
>
<q-list>
<DrawerUserCard />
<q-separator />
<q-item clickable to="/">
<q-item-section avatar>
<q-icon name="home" />
</q-item-section>
<q-item-section>
<q-item-label>{{ t('pages.home') }}</q-item-label>
</q-item-section>
</q-item>
<q-item clickable to="/admin/dashboard">
<q-item-section avatar>
<q-icon name="dashboard" />
</q-item-section>
<q-item-section>
<q-item-label>{{ t('admin.dashboard') }}</q-item-label>
</q-item-section>
</q-item>
<q-item clickable to="/admin/profiles">
<q-item-section avatar>
<q-icon name="people" />
</q-item-section>
<q-item-section>
<q-item-label>{{ t('nav.profiles') }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-drawer>
<q-page-container>
<router-view />
</q-page-container>
</q-layout>
</template>
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import DrawerUserCard from '@/components/DrawerUserCard.vue';
import { useProfilesStore } from '@/stores/profiles-store';
import { localeOptions, setLocale, type SupportedLocale } from '@/i18n';
const profilesStore = useProfilesStore();
const router = useRouter();
const { locale, t } = useI18n();
const selectedLocale = computed({
get: () => locale.value as SupportedLocale,
set: (value: SupportedLocale) => setLocale(value),
});
onMounted(async () => {
try {
if (profilesStore.isAuthenticated && !profilesStore.profile) {
await profilesStore.me();
}
} catch {
profilesStore.logout();
}
if (!profilesStore.isAuthenticated || profilesStore.profile?.role !== 'admin') {
await router.replace('/401');
}
});
const leftDrawerOpen = ref(false);
function toggleLeftDrawer () {
leftDrawerOpen.value = !leftDrawerOpen.value;
}
async function logout() {
profilesStore.logout();
await router.push('/');
}
</script>
<style scoped>
.language-select {
width: 90px;
max-width: 90px;
color: #000;
font-weight: 700;
}
.language-select :deep(.q-field__native),
.language-select :deep(.q-field__append),
.language-select :deep(.q-icon) {
color: #000;
font-weight: 700;
}
</style>

View File

@@ -0,0 +1,126 @@
<template>
<q-layout view="lHh Lpr lFf">
<q-header elevated>
<q-toolbar>
<q-btn
flat
dense
round
icon="menu"
:aria-label="t('nav.menu')"
@click="toggleLeftDrawer"
/>
<q-toolbar-title>
{{ t('app.title') }}
</q-toolbar-title>
<q-select
v-model="selectedLocale"
:options="localeOptions"
option-label="label"
option-value="value"
emit-value
map-options
dense
borderless
class="q-ml-md language-select"
:aria-label="t('nav.language')"
/>
</q-toolbar>
</q-header>
<q-drawer
v-model="leftDrawerOpen"
show-if-above
bordered
>
<q-list>
<DrawerUserCard />
<q-separator />
<q-item v-if="isAdmin" clickable to="/admin/dashboard">
<q-item-section avatar>
<q-icon name="dashboard" />
</q-item-section>
<q-item-section>
<q-item-label>{{ t('admin.dashboard') }}</q-item-label>
</q-item-section>
</q-item>
<q-item-label
header
>
Essential Links
</q-item-label>
</q-list>
</q-drawer>
<q-page-container>
<router-view />
</q-page-container>
</q-layout>
</template>
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import DrawerUserCard from '@/components/DrawerUserCard.vue';
import { useProfilesStore } from '@/stores/profiles-store';
import { localeOptions, setLocale, type SupportedLocale } from '@/i18n';
const profilesStore = useProfilesStore();
const router = useRouter();
const { locale, t } = useI18n();
const selectedLocale = computed({
get: () => locale.value as SupportedLocale,
set: (value: SupportedLocale) => setLocale(value),
});
const isAdmin = computed(() => profilesStore.profile?.role === 'admin');
onMounted(async () => {
if (profilesStore.isAuthenticated && !profilesStore.profile) {
try {
await profilesStore.me();
} catch {
profilesStore.logout();
}
}
});
const leftDrawerOpen = ref(false);
function toggleLeftDrawer () {
leftDrawerOpen.value = !leftDrawerOpen.value;
}
async function logout() {
profilesStore.logout();
await router.push('/');
}
</script>
<style scoped>
.language-select {
width: 90px;
max-width: 90px;
color: #fff;
font-weight: 700;
}
.language-select :deep(.q-field__native),
.language-select :deep(.q-field__append),
.language-select :deep(.q-icon) {
color: #fff;
font-weight: 700;
}
</style>

View File

@@ -0,0 +1,23 @@
<template>
<div class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center">
<div>
<div style="font-size: 30vh">
404
</div>
<div class="text-h2" style="opacity:.4">
{{ $t('pages.notFound') }}
</div>
<q-btn
class="q-mt-xl"
color="white"
text-color="blue"
unelevated
to="/"
:label="$t('pages.goHome')"
no-caps
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,35 @@
<template>
<div class="fullscreen bg-deep-orange text-white text-center q-pa-md flex flex-center">
<div>
<div style="font-size: 30vh">
401
</div>
<div class="text-h2" style="opacity:.55">
{{ $t('pages.unauthorized') }}
</div>
<div class="text-subtitle1 q-mt-md" style="opacity:.8">
{{ $t('pages.unauthorizedHint') }}
</div>
<div class="row justify-center q-gutter-sm q-mt-xl">
<q-btn
color="white"
text-color="deep-orange"
unelevated
to="/login"
:label="$t('nav.login')"
no-caps
/>
<q-btn
flat
color="white"
to="/"
:label="$t('pages.goHome')"
no-caps
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,19 @@
<template>
<q-page class="flex flex-center">
<div class="column items-center">
<q-btn
class="q-mt-md"
color="primary"
to="/second"
:label="$t('pages.goToSecond')"
no-caps
/>
</div>
</q-page>
</template>
<script setup lang="ts">
</script>

View File

@@ -0,0 +1,78 @@
<template>
<q-page class="flex flex-center">
<q-card class="q-pa-md" style="width: 100%; max-width: 400px">
<q-card-section>
<div class="text-h6">{{ t('login.title') }}</div>
</q-card-section>
<q-card-section>
<q-form class="column q-gutter-md" @submit.prevent="onSubmit">
<q-banner v-if="registered" class="bg-positive text-white" rounded>
{{ t('register.success') }}
</q-banner>
<q-input
v-model="email"
:label="t('fields.email')"
type="email"
autocomplete="email"
:rules="[(val) => !!val || t('fields.email')]"
/>
<q-input
v-model="password"
:label="t('fields.password')"
type="password"
autocomplete="current-password"
:rules="[(val) => !!val || t('fields.password')]"
/>
<q-banner v-if="store.error" class="bg-negative text-white" rounded>
{{ store.error }}
</q-banner>
<q-btn
type="submit"
color="primary"
:label="t('nav.login')"
:loading="store.loading"
class="full-width"
no-caps
/>
<q-btn flat no-caps to="/register" :label="t('login.needAccount')" />
</q-form>
</q-card-section>
</q-card>
</q-page>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useRoute } from 'vue-router';
import { useProfilesStore } from '@/stores/profiles-store';
const { t } = useI18n();
const email = ref('');
const password = ref('');
const store = useProfilesStore();
const router = useRouter();
const route = useRoute();
const registered = route.query.registered === '1';
if (typeof route.query.email === 'string') {
email.value = route.query.email;
}
async function onSubmit() {
try {
await store.login({ user_email: email.value, password: password.value });
await router.push('/');
} catch {
// store.error already holds the failure message
}
}
</script>

View File

@@ -0,0 +1,69 @@
<template>
<q-page class="q-pa-md">
<div class="text-h5 q-mb-md">{{ t('profile.title') }}</div>
<q-banner v-if="profilesStore.error" class="bg-negative text-white q-mb-md" rounded>
{{ profilesStore.error }}
</q-banner>
<q-card v-if="profilesStore.profile" flat bordered style="max-width: 480px">
<q-card-section class="q-gutter-md">
<AvatarUpload v-model="form.avatar_url" :uploader="profilesStore.uploadAvatar" />
<q-input v-model="form.display_name" :label="t('fields.displayName')" />
<div class="text-caption text-grey">
{{ profilesStore.profile.email }} · {{ profilesStore.profile.role }}
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn color="primary" :label="t('actions.save')" :loading="profilesStore.loading" @click="save" />
</q-card-actions>
</q-card>
<div v-else-if="!profilesStore.isAuthenticated" class="text-grey">
{{ t('profile.loginRequired') }}
</div>
</q-page>
</template>
<script setup lang="ts">
import { onMounted, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useProfilesStore, type Profile } from '@/stores/profiles-store';
import AvatarUpload from '@/components/AvatarUpload.vue';
const { t } = useI18n();
const profilesStore = useProfilesStore();
const form = reactive({
display_name: '',
avatar_url: '',
});
function syncForm(profile: Profile | null) {
form.display_name = profile?.display_name ?? '';
form.avatar_url = profile?.avatar_url ?? '';
}
watch(() => profilesStore.profile, syncForm, { immediate: true });
async function save() {
try {
await profilesStore.update({ ...form });
} catch {
// profilesStore.error already holds the failure message
}
}
onMounted(async () => {
if (profilesStore.isAuthenticated && !profilesStore.profile) {
try {
await profilesStore.me();
} catch {
// profilesStore.error already holds the failure message
}
}
});
</script>

View File

@@ -0,0 +1,170 @@
<template>
<q-page class="flex flex-center">
<q-card class="q-pa-md" style="width: 100%; max-width: 440px">
<template v-if="success">
<q-card-section>
<div class="text-h6">{{ t('register.welcomeTitle') }}</div>
</q-card-section>
<q-card-section>
<q-banner class="bg-positive text-white" rounded>
{{ t('register.confirmationSent') }}
</q-banner>
</q-card-section>
</template>
<template v-else>
<q-card-section>
<div class="text-h6">{{ t('register.title') }}</div>
</q-card-section>
<q-card-section>
<q-form class="column q-gutter-md" @submit.prevent="onSubmit">
<q-input
ref="emailInputRef"
v-model="form.email"
:label="t('fields.email')"
type="email"
autocomplete="email"
:error="Boolean(fieldErrors.email)"
:error-message="fieldErrors.email"
:loading="checkingEmail"
@blur="checkEmailAvailability"
/>
<q-input
v-model="form.password"
:label="t('fields.password')"
type="password"
autocomplete="new-password"
:error="Boolean(fieldErrors.password)"
:error-message="fieldErrors.password"
/>
<q-input
v-model="form.display_name"
:label="t('fields.displayName')"
:error="Boolean(fieldErrors.display_name)"
:error-message="fieldErrors.display_name"
/>
<q-banner v-if="error" class="bg-negative text-white" rounded>
{{ error }}
</q-banner>
<q-btn
type="submit"
color="primary"
:label="t('actions.register')"
:loading="loading || checkingEmail"
class="full-width"
no-caps
/>
<q-btn flat no-caps to="/login" :label="t('register.haveAccount')" />
</q-form>
</q-card-section>
</template>
</q-card>
</q-page>
</template>
<script setup lang="ts">
import { nextTick, onMounted, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Client, { Local, type registration } from '@/encore/client';
import { RegistrationRegisterParamsSchema } from '@/encore/zod';
type Focusable = { focus: () => void };
const { t } = useI18n();
const client = new Client(Local);
const emailInputRef = ref<Focusable | null>(null);
const loading = ref(false);
const checkingEmail = ref(false);
const error = ref<string | null>(null);
const success = ref(false);
const fieldErrors = reactive<Partial<Record<keyof registration.RegisterParams, string>>>({});
const form = reactive<registration.RegisterParams>({
email: '',
password: '',
display_name: '',
avatar_url: '',
});
onMounted(async () => {
await nextTick();
emailInputRef.value?.focus();
});
async function onSubmit() {
const params = validateForm();
if (!params) return;
const emailAvailable = await checkEmailAvailability();
if (!emailAvailable) return;
loading.value = true;
error.value = null;
success.value = false;
try {
await client.registration.Register(params);
success.value = true;
resetForm();
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
loading.value = false;
}
}
async function checkEmailAvailability(): Promise<boolean> {
const emailResult = RegistrationRegisterParamsSchema.shape.email.safeParse(form.email);
if (!emailResult.success) return false;
checkingEmail.value = true;
fieldErrors.email = '';
try {
const res = await client.registration.CheckEmail(form.email);
if (!res.mx_valid) {
fieldErrors.email = t('register.emailDomainInvalid');
return false;
}
if (!res.available) {
fieldErrors.email = t('register.emailUnavailable');
return false;
}
return true;
} catch (err) {
fieldErrors.email = err instanceof Error ? err.message : String(err);
return false;
} finally {
checkingEmail.value = false;
}
}
function validateForm(): registration.RegisterParams | null {
clearFieldErrors();
const result = RegistrationRegisterParamsSchema.safeParse({ ...form });
if (result.success) return result.data;
const flattened = result.error.flatten().fieldErrors;
for (const key of Object.keys(flattened) as (keyof registration.RegisterParams)[]) {
fieldErrors[key] = flattened[key]?.[0] ?? '';
}
return null;
}
function clearFieldErrors() {
fieldErrors.email = '';
fieldErrors.password = '';
fieldErrors.display_name = '';
fieldErrors.avatar_url = '';
}
function resetForm() {
form.email = '';
form.password = '';
form.display_name = '';
form.avatar_url = '';
}
</script>

View File

@@ -0,0 +1,9 @@
<template>
<q-page class="flex flex-center">
<q-btn color="secondary" to="/" :label="$t('pages.goToIndex')" no-caps />
</q-page>
</template>
<script setup lang="ts">
//
</script>

View File

@@ -0,0 +1,60 @@
<template>
<q-page class="flex flex-center">
<q-card class="q-pa-md" style="width: 100%; max-width: 440px">
<q-card-section>
<div class="text-h6">{{ t('welcome.title') }}</div>
</q-card-section>
<q-card-section>
<q-inner-loading :showing="loading" />
<q-banner v-if="success" class="bg-positive text-white" rounded>
{{ t('welcome.success', { email }) }}
</q-banner>
<q-banner v-else-if="error" class="bg-negative text-white" rounded>
{{ error }}
</q-banner>
<q-banner v-else-if="!loading" class="bg-warning text-white" rounded>
{{ t('welcome.missingToken') }}
</q-banner>
</q-card-section>
<q-card-actions align="right">
<q-btn color="primary" to="/login" :label="t('nav.login')" no-caps />
</q-card-actions>
</q-card>
</q-page>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import Client, { Local } from '@/encore/client';
const { t } = useI18n();
const route = useRoute();
const client = new Client(Local);
const loading = ref(false);
const success = ref(false);
const error = ref('');
const email = ref('');
onMounted(async () => {
const token = typeof route.query.token === 'string' ? route.query.token : '';
if (!token) return;
loading.value = true;
try {
const res = await client.registration.ConfirmWelcome(token);
success.value = res.confirmed;
email.value = res.email;
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
loading.value = false;
}
});
</script>

View File

@@ -0,0 +1,11 @@
<template>
<q-page class="q-pa-md">
<div class="text-h5">{{ t('admin.dashboard') }}</div>
</q-page>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>

View File

@@ -0,0 +1,151 @@
<template>
<q-page class="q-pa-md">
<div class="row items-center q-mb-md">
<div class="text-h5">{{ t('admin.profiles') }}</div>
<q-space />
<q-btn color="primary" icon="add" :label="t('actions.newProfile')" @click="openCreate" />
</div>
<q-banner v-if="adminStore.error" class="bg-negative text-white q-mb-md" rounded>
{{ adminStore.error }}
</q-banner>
<q-table
:rows="adminStore.profiles"
:columns="columns"
row-key="user_id"
:loading="adminStore.loading"
flat
bordered
>
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-badge :color="statusInfo(props.value).color">{{ statusLabel(props.value) }}</q-badge>
</q-td>
</template>
<template v-slot:body-cell-is_artist="props">
<q-td :props="props">
<q-icon
:name="props.value ? 'check_circle' : 'cancel'"
:color="props.value ? 'positive' : 'grey-5'"
size="sm"
>
<q-tooltip>{{ props.value ? t('admin.artist') : t('admin.notArtist') }}</q-tooltip>
</q-icon>
</q-td>
</template>
<template v-slot:body-cell-avatar_url="props">
<q-td :props="props" auto-width>
<q-avatar size="32px">
<img v-if="props.value" :src="props.value" />
<q-icon v-else name="person" />
</q-avatar>
<q-btn flat round dense icon="more_vert" size="sm" class="q-ml-xs">
<q-menu>
<q-list>
<q-item v-close-popup clickable @click="openEdit(props.row)">
<q-item-section>{{ t('actions.editProfile') }}</q-item-section>
</q-item>
<q-item v-close-popup clickable @click="openRoleEdit(props.row)">
<q-item-section>{{ t('actions.updateRole') }}</q-item-section>
</q-item>
<q-item v-close-popup clickable @click="openStatusEdit(props.row)">
<q-item-section>{{ t('actions.updateStatus') }}</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
</q-td>
</template>
</q-table>
<EditProfileDialog v-model="editDialogOpen" :profile="editingProfile" />
<UpdateRoleDialog v-model="roleDialogOpen" :profile="roleEditingProfile" />
<UpdateStatusDialog v-model="statusDialogOpen" :profile="statusEditingProfile" />
<CreateProfileDialog v-model="createDialogOpen" />
</q-page>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { QTableColumn } from 'quasar';
import { useAdminStore, type Profile } from '@/stores/admin-store';
import { statusInfo } from '@/stores/profiles-store';
import CreateProfileDialog from './dialogs/CreateProfileDialog.vue';
import EditProfileDialog from './dialogs/EditProfileDialog.vue';
import UpdateRoleDialog from './dialogs/UpdateRoleDialog.vue';
import UpdateStatusDialog from './dialogs/UpdateStatusDialog.vue';
const adminStore = useAdminStore();
const { t } = useI18n();
const editDialogOpen = ref(false);
const editingProfile = ref<Profile | null>(null);
function openEdit(profile: Profile) {
editingProfile.value = profile;
editDialogOpen.value = true;
}
const createDialogOpen = ref(false);
function openCreate() {
createDialogOpen.value = true;
}
const roleDialogOpen = ref(false);
const roleEditingProfile = ref<Profile | null>(null);
function openRoleEdit(profile: Profile) {
roleEditingProfile.value = profile;
roleDialogOpen.value = true;
}
const statusDialogOpen = ref(false);
const statusEditingProfile = ref<Profile | null>(null);
function openStatusEdit(profile: Profile) {
statusEditingProfile.value = profile;
statusDialogOpen.value = true;
}
const columns = computed<QTableColumn[]>(() => [
{ name: 'avatar_url', label: '', field: 'avatar_url', align: 'left' },
{ name: 'display_name', label: t('admin.columns.name'), field: 'display_name', align: 'left', sortable: true },
{ name: 'email', label: t('admin.columns.email'), field: 'email', align: 'left', sortable: true },
{ name: 'role', label: t('admin.columns.role'), field: 'role', align: 'left', sortable: true },
{ name: 'status', label: t('admin.columns.status'), field: 'status', align: 'left', sortable: true },
{ name: 'is_artist', label: t('admin.columns.artist'), field: 'is_artist', align: 'center', sortable: true },
{
name: 'created_at',
label: t('admin.columns.created'),
field: 'created_at',
align: 'left',
sortable: true,
format: (val: string) => new Date(val).toLocaleString(),
},
{
name: 'updated_at',
label: t('admin.columns.updated'),
field: 'updated_at',
align: 'left',
sortable: true,
format: (val: string) => new Date(val).toLocaleString(),
},
]);
function statusLabel(status: number) {
const key = `status.${status}`;
const translated = t(key);
return translated === key ? t('status.unknown', { status }) : translated;
}
onMounted(() => {
void adminStore.listProfiles();
void adminStore.fetchSystemOptions();
});
</script>

View File

@@ -0,0 +1,86 @@
<template>
<q-dialog v-model="open" @show="focusFirstField">
<q-card style="min-width: 350px">
<q-card-section>
<div class="text-h6">{{ t('actions.newProfile') }}</div>
</q-card-section>
<q-card-section class="q-gutter-md">
<q-input
ref="emailInputRef"
v-model="createForm.email"
:label="t('fields.email')"
type="email"
autocomplete="off"
name="new-profile-email"
/>
<q-input
v-model="createForm.password"
:label="t('fields.password')"
type="password"
autocomplete="new-password"
name="new-profile-password"
/>
<q-input v-model="createForm.display_name" :label="t('fields.displayName')" />
<AvatarUpload v-model="createForm.avatar_url" :uploader="adminStore.uploadAvatar" />
<q-banner v-if="adminStore.error" class="bg-negative text-white" rounded>
{{ adminStore.error }}
</q-banner>
</q-card-section>
<q-card-actions align="right">
<q-btn v-close-popup flat :label="t('actions.cancel')" />
<q-btn color="primary" :label="t('actions.create')" :loading="adminStore.loading" @click="save" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { nextTick, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAdminStore, type RegisterParams } from '@/stores/admin-store';
import AvatarUpload from '@/components/AvatarUpload.vue';
type Focusable = { focus: () => void };
const open = defineModel<boolean>({ required: true });
const adminStore = useAdminStore();
const { t } = useI18n();
const emailInputRef = ref<Focusable | null>(null);
const createForm = reactive<RegisterParams>({
email: '',
password: '',
display_name: '',
avatar_url: '',
});
watch(open, (isOpen) => {
if (isOpen) {
resetForm();
}
});
async function save() {
try {
await adminStore.insertProfile({ ...createForm });
open.value = false;
} catch {
// adminStore.error already holds the failure message
}
}
function resetForm() {
createForm.email = '';
createForm.password = '';
createForm.display_name = '';
createForm.avatar_url = '';
}
async function focusFirstField() {
await nextTick();
emailInputRef.value?.focus();
}
</script>

View File

@@ -0,0 +1,249 @@
<template>
<q-dialog v-model="open" @show="focusFirstField">
<q-card style="min-width: 350px">
<q-card-section v-if="profile" class="row items-center q-gutter-sm">
<q-avatar size="48px">
<img v-if="editForm.avatar_url" :src="editForm.avatar_url" />
<q-icon v-else name="person" />
</q-avatar>
<div>
<div class="text-subtitle1">{{ profile.email }}</div>
<div class="text-caption text-grey">{{ profile.role }} · {{ profile.user_id }}</div>
</div>
</q-card-section>
<q-tabs v-model="editTab" align="left" class="text-primary" dense>
<q-tab name="profile" :label="t('admin.profile')" />
<q-tab name="personal" :label="t('admin.personalData')" />
</q-tabs>
<q-separator />
<q-tab-panels v-model="editTab" animated>
<q-tab-panel name="profile" class="q-gutter-md">
<AvatarUpload v-model="editForm.avatar_url" :uploader="adminStore.uploadAvatar" />
<div v-if="editFormErrors.avatar_url" class="text-negative text-caption">
{{ editFormErrors.avatar_url }}
</div>
<q-input
ref="displayNameInputRef"
v-model="editForm.display_name"
:label="t('fields.displayName')"
:error="Boolean(editFormErrors.display_name)"
:error-message="editFormErrors.display_name"
/>
</q-tab-panel>
<q-tab-panel name="personal" class="q-gutter-md">
<q-input
ref="firstNameInputRef"
v-model="personalForm.first_name"
:label="t('fields.firstName')"
:error="Boolean(personalFormErrors.first_name)"
:error-message="personalFormErrors.first_name"
/>
<q-input
v-model="personalForm.last_name"
:label="t('fields.lastName')"
:error="Boolean(personalFormErrors.last_name)"
:error-message="personalFormErrors.last_name"
/>
<q-input
v-model="personalForm.address"
:label="t('fields.address')"
:error="Boolean(personalFormErrors.address)"
:error-message="personalFormErrors.address"
/>
<q-input
v-model="personalForm.city"
:label="t('fields.city')"
:error="Boolean(personalFormErrors.city)"
:error-message="personalFormErrors.city"
/>
<q-select
v-model="personalForm.country"
:options="filteredCountries"
option-label="label"
option-value="value"
emit-value
map-options
use-input
clearable
input-debounce="200"
:label="t('fields.country')"
:error="Boolean(personalFormErrors.country)"
:error-message="personalFormErrors.country"
@filter="filterCountries"
/>
</q-tab-panel>
</q-tab-panels>
<q-card-section v-if="adminStore.error">
<q-banner class="bg-negative text-white" rounded>{{ adminStore.error }}</q-banner>
</q-card-section>
<q-card-actions align="right">
<q-btn v-close-popup flat :label="t('actions.cancel')" />
<q-btn color="primary" :label="t('actions.save')" :loading="adminStore.loading" @click="save" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { nextTick, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAdminStore, type PersonalDataParams, type Profile, type ProfileParams } from '@/stores/admin-store';
import { countries } from '@/data/countries';
import { AdminPersonalDataParamsSchema, AdminProfileParamsSchema } from '@/encore/zod';
import AvatarUpload from '@/components/AvatarUpload.vue';
const props = defineProps<{
profile: Profile | null;
}>();
const open = defineModel<boolean>({ required: true });
const adminStore = useAdminStore();
const { t } = useI18n();
type CountryOption = { label: string; value: string };
type EditTab = 'profile' | 'personal';
type Focusable = { focus: () => void };
const countryOptions: CountryOption[] = countries.map((c) => {
const [value, label] = Object.entries(c)[0] as [string, string];
return { label, value };
});
const filteredCountries = ref<CountryOption[]>(countryOptions);
const editTab = ref<EditTab>('profile');
const displayNameInputRef = ref<Focusable | null>(null);
const firstNameInputRef = ref<Focusable | null>(null);
const editForm = reactive<ProfileParams>({
display_name: '',
avatar_url: '',
});
const editFormErrors = reactive<Partial<Record<keyof ProfileParams, string>>>({});
const personalForm = reactive<PersonalDataParams>({
first_name: '',
last_name: '',
address: '',
city: '',
country: '',
});
const personalFormErrors = reactive<Partial<Record<keyof PersonalDataParams, string>>>({});
watch(
() => [open.value, props.profile] as const,
([isOpen, profile]) => {
if (isOpen && profile) {
void loadProfile(profile);
}
},
{ immediate: true },
);
watch(editTab, () => {
if (open.value) {
void focusFirstField();
}
});
function filterCountries(val: string, update: (cb: () => void) => void) {
update(() => {
const needle = val.toLowerCase();
filteredCountries.value = needle
? countryOptions.filter(
(c) => c.label.toLowerCase().includes(needle) || c.value.toLowerCase().includes(needle),
)
: countryOptions;
});
}
async function loadProfile(profile: Profile) {
editTab.value = 'profile';
editForm.display_name = profile.display_name;
editForm.avatar_url = profile.avatar_url;
clearEditFormErrors();
resetPersonalForm();
try {
const data = await adminStore.getPersonalData(profile.user_id);
resetPersonalForm(data);
} catch {
// No personal data yet (404) — leave the form empty for creation.
adminStore.error = null;
}
}
async function save() {
if (!props.profile) return;
try {
if (editTab.value === 'profile') {
const parsed = validateEditForm();
if (!parsed) return;
await adminStore.updateProfile(props.profile.user_id, parsed);
} else {
const parsed = validatePersonalForm();
if (!parsed) return;
await adminStore.upsertPersonalData(props.profile.user_id, parsed);
}
open.value = false;
} catch {
// adminStore.error already holds the failure message
}
}
function resetPersonalForm(data?: PersonalDataParams) {
personalForm.first_name = data?.first_name ?? '';
personalForm.last_name = data?.last_name ?? '';
personalForm.address = data?.address ?? '';
personalForm.city = data?.city ?? '';
personalForm.country = data?.country ?? '';
clearPersonalFormErrors();
}
function validateEditForm(): ProfileParams | null {
clearEditFormErrors();
const result = AdminProfileParamsSchema.safeParse({ ...editForm });
if (result.success) return result.data;
const fieldErrors = result.error.flatten().fieldErrors;
for (const key of Object.keys(fieldErrors) as (keyof ProfileParams)[]) {
editFormErrors[key] = fieldErrors[key]?.[0] ?? '';
}
return null;
}
function clearEditFormErrors() {
editFormErrors.display_name = '';
editFormErrors.avatar_url = '';
}
function validatePersonalForm(): PersonalDataParams | null {
clearPersonalFormErrors();
const result = AdminPersonalDataParamsSchema.safeParse({ ...personalForm });
if (result.success) return result.data;
const fieldErrors = result.error.flatten().fieldErrors;
for (const key of Object.keys(fieldErrors) as (keyof PersonalDataParams)[]) {
personalFormErrors[key] = fieldErrors[key]?.[0] ?? '';
}
return null;
}
function clearPersonalFormErrors() {
personalFormErrors.first_name = '';
personalFormErrors.last_name = '';
personalFormErrors.address = '';
personalFormErrors.city = '';
personalFormErrors.country = '';
}
async function focusFirstField() {
await nextTick();
if (editTab.value === 'profile') {
displayNameInputRef.value?.focus();
} else {
firstNameInputRef.value?.focus();
}
}
</script>

View File

@@ -0,0 +1,76 @@
<template>
<q-dialog v-model="open" @show="focusFirstField">
<q-card style="min-width: 350px">
<q-card-section>
<div class="text-h6">{{ t('actions.updateRole') }}</div>
</q-card-section>
<q-card-section v-if="profile" class="q-gutter-md">
<div class="text-subtitle1">{{ profile.email }}</div>
<q-select
ref="roleSelectRef"
v-model="selectedRole"
:options="adminStore.roles"
option-label="name"
option-value="value"
emit-value
map-options
:label="t('fields.role')"
/>
<q-banner v-if="adminStore.error" class="bg-negative text-white" rounded>
{{ adminStore.error }}
</q-banner>
</q-card-section>
<q-card-actions align="right">
<q-btn v-close-popup flat :label="t('actions.cancel')" />
<q-btn color="primary" :label="t('actions.save')" :loading="adminStore.loading" @click="save" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAdminStore, type Profile } from '@/stores/admin-store';
type Focusable = { focus: () => void };
const props = defineProps<{
profile: Profile | null;
}>();
const open = defineModel<boolean>({ required: true });
const adminStore = useAdminStore();
const { t } = useI18n();
const selectedRole = ref('');
const roleSelectRef = ref<Focusable | null>(null);
watch(
() => [open.value, props.profile] as const,
([isOpen, profile]) => {
if (isOpen && profile) {
selectedRole.value = profile.role;
}
},
{ immediate: true },
);
async function save() {
if (!props.profile) return;
try {
await adminStore.updateProfileRole(props.profile.user_id, selectedRole.value);
open.value = false;
} catch {
// adminStore.error already holds the failure message
}
}
async function focusFirstField() {
await nextTick();
roleSelectRef.value?.focus();
}
</script>

View File

@@ -0,0 +1,77 @@
<template>
<q-dialog v-model="open" @show="focusFirstField">
<q-card style="min-width: 350px">
<q-card-section>
<div class="text-h6">{{ t('actions.updateStatus') }}</div>
</q-card-section>
<q-card-section v-if="profile" class="q-gutter-md">
<div class="text-subtitle1">{{ profile.email }}</div>
<q-select
ref="statusSelectRef"
v-model="selectedStatus"
:options="adminStore.updatableStatuses"
option-label="name"
option-value="value"
emit-value
map-options
:label="t('fields.status')"
/>
<q-banner v-if="adminStore.error" class="bg-negative text-white" rounded>
{{ adminStore.error }}
</q-banner>
</q-card-section>
<q-card-actions align="right">
<q-btn v-close-popup flat :label="t('actions.cancel')" />
<q-btn color="primary" :label="t('actions.save')" :loading="adminStore.loading" @click="save" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAdminStore, type Profile } from '@/stores/admin-store';
import type { Status } from '@/stores/profiles-store';
type Focusable = { focus: () => void };
const props = defineProps<{
profile: Profile | null;
}>();
const open = defineModel<boolean>({ required: true });
const adminStore = useAdminStore();
const { t } = useI18n();
const selectedStatus = ref<Status>(0);
const statusSelectRef = ref<Focusable | null>(null);
watch(
() => [open.value, props.profile] as const,
([isOpen, profile]) => {
if (isOpen && profile) {
selectedStatus.value = profile.status;
}
},
{ immediate: true },
);
async function save() {
if (!props.profile) return;
try {
await adminStore.updateProfileStatus(props.profile.user_id, selectedStatus.value);
open.value = false;
} catch {
// adminStore.error already holds the failure message
}
}
async function focusFirstField() {
await nextTick();
statusSelectRef.value?.focus();
}
</script>

View File

@@ -0,0 +1,36 @@
import { defineRouter } from '#q-app';
import {
createMemoryHistory,
createRouter,
createWebHashHistory,
createWebHistory,
} from 'vue-router';
import routes from './routes';
/*
* If not building with SSR mode, you can
* directly export the Router instantiation;
*
* The function below can be async too; either use
* async/await or return a Promise which resolves
* with the Router instance.
*/
export default defineRouter((/* { store, ssrContext } */) => {
const createHistory = import.meta.env.QUASAR_SERVER
? createMemoryHistory
: (import.meta.env.QUASAR_VUE_ROUTER_MODE === 'history' ? createWebHistory : createWebHashHistory);
const Router = createRouter({
scrollBehavior: () => ({ left: 0, top: 0 }),
routes,
// Leave this as is and make changes in quasar.conf.js instead!
// quasar.conf.js -> build -> vueRouterMode
// quasar.conf.js -> build -> publicPath
history: createHistory(import.meta.env.QUASAR_VUE_ROUTER_BASE)
});
return Router;
});

View File

@@ -0,0 +1,33 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('@/layouts/MainLayout.vue'),
children: [
{ path: '', component: () => import('@/pages/IndexPage.vue') },
{ path: 'second', component: () => import('@/pages/SecondPage.vue') },
{ path: 'login', component: () => import('@/pages/LoginPage.vue') },
{ path: 'register', component: () => import('@/pages/RegisterPage.vue') },
{ path: 'welcome', component: () => import('@/pages/WelcomePage.vue') },
{ path: 'profile', component: () => import('@/pages/ProfilePage.vue') },
{ path: '401', component: () => import('@/pages/ErrorUnauthorized.vue') },
],
},
{
path: '/admin',
component: () => import('@/layouts/AdminLayout.vue'),
children: [
{ path: 'dashboard', component: () => import('@/pages/admin/DashboardPage.vue') },
{ path: 'profiles', component: () => import('@/pages/admin/ProfilesPage.vue') }
],
},
// Always leave this as last one,
// but you can also remove it
{
path: '/:catchAll(.*)*',
component: () => import('@/pages/ErrorNotFound.vue'),
},
];
export default routes;

View File

@@ -0,0 +1,150 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import Client, { Local, admin as AdminNS, auth as AuthNS, profiles as ProfilesNS } from '@/encore/client';
import { useProfilesStore } from '@/stores/profiles-store';
export type Profile = AdminNS.Profile;
export type ProfileParams = AdminNS.ProfileParams;
export type RegisterParams = AdminNS.RegisterParams;
export type PersonalData = AdminNS.PersonalData;
export type PersonalDataParams = AdminNS.PersonalDataParams;
export type RoleOption = AuthNS.RoleOption;
export type StatusOption = ProfilesNS.StatusOption;
export const useAdminStore = defineStore('admin', () => {
const profiles = ref<Profile[]>([]);
const roles = ref<RoleOption[]>([]);
const statuses = ref<StatusOption[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const profilesStore = useProfilesStore();
const client = new Client(Local, {
auth: () => (profilesStore.token ? { Authorization: `Bearer ${profilesStore.token}` } : undefined),
});
async function withLoading<T>(fn: () => Promise<T>): Promise<T> {
loading.value = true;
error.value = null;
try {
return await fn();
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
throw err;
} finally {
loading.value = false;
}
}
/** Fetches all profiles into `profiles`. Requires the caller to be logged in as an admin. */
async function listProfiles(): Promise<Profile[]> {
return withLoading(async () => {
const res = await client.admin.ListProfiles();
profiles.value = res.profiles;
return res.profiles;
});
}
/** Creates a new user profile with credentials. Requires the caller to be logged in as an admin. */
async function insertProfile(params: RegisterParams): Promise<Profile> {
return withLoading(async () => {
const res = await client.admin.InsertProfile(params);
profiles.value.push(res);
return res;
});
}
/** Fetches the valid roles and profile statuses into `roles` and `statuses`. */
async function fetchSystemOptions(): Promise<void> {
return withLoading(async () => {
const res = await client.admin.GetSystemOptions();
roles.value = res.roles;
statuses.value = res.statuses;
});
}
/** Statuses that an admin can manually set on a profile. */
const updatableStatuses = computed(() => statuses.value.filter((s) => s.updatable));
function replaceProfile(updated: Profile) {
const idx = profiles.value.findIndex((p) => p.user_id === updated.user_id);
if (idx !== -1) {
profiles.value[idx] = updated;
}
}
/** Updates any user's profile. Requires the caller to be logged in as an admin. */
async function updateProfile(userID: string, params: ProfileParams): Promise<Profile> {
return withLoading(async () => {
const res = await client.admin.UpdateProfile(userID, params);
replaceProfile(res);
return res;
});
}
/** Updates the role of any user's profile. */
async function updateProfileRole(userID: string, role: string): Promise<Profile> {
return withLoading(async () => {
const res = await client.admin.UpdateProfileRole(userID, { role });
replaceProfile(res);
return res;
});
}
/** Updates the status of any user's profile. */
async function updateProfileStatus(userID: string, status: ProfilesNS.Status): Promise<Profile> {
return withLoading(async () => {
const res = await client.admin.UpdateProfileStatus(userID, { status });
replaceProfile(res);
return res;
});
}
/** Fetches the personal data of any user. Requires the caller to be logged in as an admin. */
async function getPersonalData(userID: string): Promise<PersonalData> {
return withLoading(async () => {
return await client.admin.GetPersonalData(userID);
});
}
/** Creates or replaces the personal data of any user. */
async function upsertPersonalData(userID: string, params: PersonalDataParams): Promise<PersonalData> {
return withLoading(async () => {
return await client.admin.UpsertPersonalData(userID, params);
});
}
/** Uploads an avatar image and returns its public URL. */
async function uploadAvatar(image: Blob): Promise<string> {
return withLoading(async () => {
const resp = await client.profiles.UploadAvatar('POST', image);
if (!resp.ok) {
throw new Error(`avatar upload failed (${resp.status})`);
}
const data = (await resp.json()) as { url: string };
return data.url;
});
}
return {
// state
profiles,
roles,
statuses,
loading,
error,
// getters
updatableStatuses,
// actions
listProfiles,
fetchSystemOptions,
insertProfile,
updateProfile,
updateProfileRole,
updateProfileStatus,
getPersonalData,
upsertPersonalData,
uploadAvatar,
};
});

View File

@@ -0,0 +1,32 @@
import { defineStore } from '#q-app';
import { createPinia } from 'pinia';
/*
* When adding new properties to stores, you should also
* extend the `PiniaCustomProperties` interface.
* @see https://pinia.vuejs.org/core-concepts/plugins.html#Typing-new-store-properties
*/
declare module 'pinia' {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface PiniaCustomProperties {
// add your custom properties here, if any
}
}
/*
* If not building with SSR mode, you can
* directly export the Store instantiation;
*
* The function below can be async too; either use
* async/await or return a Promise which resolves
* with the Store instance.
*/
export default defineStore((/* { ssrContext } */) => {
const pinia = createPinia();
// You can add Pinia plugins here
// pinia.use(SomePiniaPlugin)
return pinia;
});

View File

@@ -0,0 +1,167 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { LocalStorage } from 'quasar';
import Client, { Local, profiles as ProfilesNS } from '@/encore/client';
export type Profile = ProfilesNS.Profile;
export type ProfileParams = ProfilesNS.ProfileParams;
export type RegisterParams = ProfilesNS.RegisterParams;
export type LoginParams = ProfilesNS.LoginParams;
export type LoginResponse = ProfilesNS.LoginResponse;
export type Status = ProfilesNS.Status;
export type PersonalData = ProfilesNS.PersonalData;
export type PersonalDataParams = ProfilesNS.PersonalDataParams;
/** Display info for each Status value, mirroring profiles/status.go. */
export const STATUS_INFO: { label: string; color: string }[] = [
{ label: 'Active', color: 'positive' },
{ label: 'Inactive', color: 'grey' },
{ label: 'Suspended', color: 'warning' },
{ label: 'Deleted', color: 'negative' },
{ label: 'Pending', color: 'info' },
{ label: 'Waiting deletion', color: 'warning' },
{ label: 'Banned', color: 'negative' },
];
export function statusInfo(status: Status): { label: string; color: string } {
return STATUS_INFO[status] ?? { label: `Unknown (${status})`, color: 'grey' };
}
const TOKEN_STORAGE_KEY = 'profiles.token';
export const useProfilesStore = defineStore('profiles', () => {
const token = ref<string | null>(LocalStorage.getItem(TOKEN_STORAGE_KEY));
const profile = ref<Profile | null>(null);
const personalData = ref<PersonalData | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const isAuthenticated = computed(() => token.value !== null);
const client = new Client(Local, {
auth: () => (token.value ? { Authorization: `Bearer ${token.value}` } : undefined),
});
async function withLoading<T>(fn: () => Promise<T>): Promise<T> {
loading.value = true;
error.value = null;
try {
return await fn();
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
throw err;
} finally {
loading.value = false;
}
}
function setToken(newToken: string | null) {
token.value = newToken;
if (newToken) {
LocalStorage.set(TOKEN_STORAGE_KEY, newToken);
} else {
LocalStorage.remove(TOKEN_STORAGE_KEY);
}
}
/** Logs in and fetches the resulting profile into `profile`. */
async function login(params: LoginParams): Promise<LoginResponse> {
return withLoading(async () => {
const res = await client.profiles.Login(params);
setToken(res.token);
await me();
return res;
});
}
/** Clears the local session. Does not call the auth service's Logout endpoint. */
function logout() {
setToken(null);
profile.value = null;
personalData.value = null;
}
/** Fetches the authenticated caller's profile, or null if not authenticated. */
async function me(): Promise<Profile | null> {
return withLoading(async () => {
const res = await client.profiles.Me();
profile.value = res;
return res;
});
}
/** Registers a new profile (and credentials) and returns it. */
async function register(params: RegisterParams): Promise<Profile> {
return withLoading(async () => {
return await client.profiles.Insert(params);
});
}
/** Updates the authenticated caller's own profile. */
async function update(params: ProfileParams): Promise<Profile> {
return withLoading(async () => {
const res = await client.profiles.Update(params);
profile.value = res;
return res;
});
}
/** Deletes the authenticated caller's own profile and clears the session. */
async function remove(): Promise<void> {
return withLoading(async () => {
await client.profiles.Delete();
logout();
});
}
/** Fetches the authenticated caller's own personal data into `personalData`. */
async function fetchPersonalData(): Promise<PersonalData> {
return withLoading(async () => {
const res = await client.profiles.GetPersonalData();
personalData.value = res;
return res;
});
}
/** Creates or replaces the authenticated caller's own personal data. */
async function savePersonalData(params: PersonalDataParams): Promise<PersonalData> {
return withLoading(async () => {
const res = await client.profiles.UpsertPersonalData(params);
personalData.value = res;
return res;
});
}
/** Uploads an avatar image and returns its public URL. */
async function uploadAvatar(image: Blob): Promise<string> {
return withLoading(async () => {
const resp = await client.profiles.UploadAvatar('POST', image);
if (!resp.ok) {
throw new Error(`avatar upload failed (${resp.status})`);
}
const data = (await resp.json()) as { url: string };
return data.url;
});
}
return {
// state
token,
profile,
personalData,
loading,
error,
// getters
isAuthenticated,
// actions
login,
logout,
me,
register,
update,
remove,
fetchPersonalData,
savePersonalData,
uploadAvatar,
};
});

327
frontend/tools/zod-sync.mjs Normal file
View File

@@ -0,0 +1,327 @@
import fs from 'node:fs';
import path from 'node:path';
import ts from 'typescript';
const rootDir = process.cwd();
const clientPath = path.join(rootDir, 'src/encore/client.ts');
const outputPath = path.join(rootDir, 'src/encore/zod.ts');
const clientSource = fs.readFileSync(clientPath, 'utf8');
const clientFile = ts.createSourceFile(clientPath, clientSource, ts.ScriptTarget.Latest, true);
const existingSource = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf8') : '';
const existingFile = ts.createSourceFile(outputPath, existingSource, ts.ScriptTarget.Latest, true);
const schemas = collectEncoreSchemas(clientFile);
const existingSchemas = collectExistingSchemas(existingFile, existingSource);
const generated = renderOutput(schemas, existingSchemas);
fs.writeFileSync(outputPath, generated);
console.log(`Synced ${schemas.length} Zod schemas to ${path.relative(rootDir, outputPath)}`);
function collectEncoreSchemas(sourceFile) {
const found = [];
const typeAliases = new Map();
const writableParamTypes = collectWritableParamTypes(sourceFile);
for (const statement of sourceFile.statements) {
if (!isExportedNamespace(statement)) continue;
const namespaceName = statement.name.text;
const body = statement.body;
if (!body || !ts.isModuleBlock(body)) continue;
for (const child of body.statements) {
if (ts.isTypeAliasDeclaration(child) && isExported(child)) {
typeAliases.set(`${namespaceName}.${child.name.text}`, child.type);
}
}
}
for (const statement of sourceFile.statements) {
if (!isExportedNamespace(statement)) continue;
const namespaceName = statement.name.text;
const body = statement.body;
if (!body || !ts.isModuleBlock(body)) continue;
for (const child of body.statements) {
const qualifiedName = `${namespaceName}.${child.name?.text ?? ''}`;
if (!writableParamTypes.has(qualifiedName)) continue;
if (ts.isInterfaceDeclaration(child) && isExported(child)) {
found.push({
kind: 'object',
namespaceName,
typeName: child.name.text,
schemaName: schemaName(namespaceName, child.name.text),
fields: child.members
.filter(ts.isPropertySignature)
.map((member) => ({
name: propertyName(member.name),
zod: zodForType(member.type, namespaceName, typeAliases),
}))
.filter((field) => field.name),
});
}
if (ts.isTypeAliasDeclaration(child) && isExported(child)) {
found.push({
kind: 'alias',
namespaceName,
typeName: child.name.text,
schemaName: schemaName(namespaceName, child.name.text),
zod: zodForType(child.type, namespaceName, typeAliases),
});
}
}
}
return found;
}
function collectWritableParamTypes(sourceFile) {
const found = new Set();
for (const statement of sourceFile.statements) {
if (!isExportedNamespace(statement)) continue;
const namespaceName = statement.name.text;
const body = statement.body;
if (!body || !ts.isModuleBlock(body)) continue;
for (const child of body.statements) {
if (!ts.isClassDeclaration(child) || child.name?.text !== 'ServiceClient') continue;
for (const member of child.members) {
if (!ts.isMethodDeclaration(member)) continue;
const httpMethod = writableHttpMethod(member);
if (!httpMethod) continue;
const bodyParamName = jsonStringifiedParamName(member);
if (!bodyParamName) continue;
const param = member.parameters.find((candidate) => propertyName(candidate.name) === bodyParamName);
if (!param?.type || !ts.isTypeReferenceNode(param.type)) continue;
const ref = typeNameText(param.type.typeName);
const qualifiedRef = ref.includes('.') ? ref : `${namespaceName}.${ref}`;
found.add(qualifiedRef);
}
}
}
return found;
}
function writableHttpMethod(method) {
let result = '';
visit(method.body);
return result;
function visit(node) {
if (result || !node) return;
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
const callName = node.expression.name.text;
if (callName === 'callTypedAPI' || callName === 'callAPI') {
const [methodArg] = node.arguments;
if (methodArg && ts.isStringLiteral(methodArg) && ['POST', 'PUT'].includes(methodArg.text)) {
result = methodArg.text;
return;
}
}
}
ts.forEachChild(node, visit);
}
}
function jsonStringifiedParamName(method) {
let result = '';
visit(method.body);
return result;
function visit(node) {
if (result || !node) return;
if (
ts.isCallExpression(node) &&
ts.isPropertyAccessExpression(node.expression) &&
ts.isIdentifier(node.expression.expression) &&
node.expression.expression.text === 'JSON' &&
node.expression.name.text === 'stringify'
) {
const [arg] = node.arguments;
if (arg && ts.isIdentifier(arg)) {
result = arg.text;
return;
}
}
ts.forEachChild(node, visit);
}
}
function collectExistingSchemas(sourceFile, sourceText) {
const result = new Map();
for (const statement of sourceFile.statements) {
if (!ts.isVariableStatement(statement) || !isExported(statement)) continue;
for (const declaration of statement.declarationList.declarations) {
if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue;
if (!declaration.name.text.endsWith('Schema')) continue;
const objectLiteral = zodObjectLiteral(declaration.initializer);
if (!objectLiteral) continue;
const fields = new Map();
for (const prop of objectLiteral.properties) {
if (!ts.isPropertyAssignment(prop)) continue;
const name = propertyName(prop.name);
if (!name) continue;
fields.set(name, prop.initializer.getText(sourceFile));
}
result.set(declaration.name.text, { fields });
}
}
if (!sourceText.trim()) return result;
return result;
}
function renderOutput(schemas, existingSchemas) {
const lines = [
'// Code synced from src/encore/client.ts by frontend/tools/zod-sync.mjs.',
'// Existing field validators are preserved when this file is synced again.',
"import { z } from 'zod';",
'',
];
for (const schema of schemas) {
if (schema.kind === 'alias') {
lines.push(`export const ${schema.schemaName} = ${schema.zod};`);
lines.push('');
continue;
}
const existing = existingSchemas.get(schema.schemaName);
const currentNames = new Set(schema.fields.map((field) => field.name));
lines.push(`export const ${schema.schemaName} = z.object({`);
for (const field of schema.fields) {
const expression = existing?.fields.get(field.name) ?? field.zod;
lines.push(` ${quoteKey(field.name)}: ${expression},`);
}
for (const [name, expression] of existing?.fields ?? []) {
if (currentNames.has(name)) continue;
lines.push(` // TODO: no longer present in Encore type ${schema.namespaceName}.${schema.typeName}`);
lines.push(` ${quoteKey(name)}: ${expression},`);
}
lines.push('});');
lines.push('');
}
return `${lines.join('\n').trimEnd()}\n`;
}
function zodObjectLiteral(initializer) {
if (!ts.isCallExpression(initializer)) return null;
if (!ts.isPropertyAccessExpression(initializer.expression)) return null;
if (initializer.expression.name.text !== 'object') return null;
if (!ts.isIdentifier(initializer.expression.expression)) return null;
if (initializer.expression.expression.text !== 'z') return null;
const [arg] = initializer.arguments;
return arg && ts.isObjectLiteralExpression(arg) ? arg : null;
}
function zodForType(type, namespaceName, typeAliases) {
if (!type) return 'z.unknown()';
if (type.kind === ts.SyntaxKind.StringKeyword) return 'z.string()';
if (type.kind === ts.SyntaxKind.NumberKeyword) return 'z.number()';
if (type.kind === ts.SyntaxKind.BooleanKeyword) return 'z.boolean()';
if (type.kind === ts.SyntaxKind.AnyKeyword) return 'z.any()';
if (type.kind === ts.SyntaxKind.UnknownKeyword) return 'z.unknown()';
if (ts.isArrayTypeNode(type)) {
return `z.array(${zodForType(type.elementType, namespaceName, typeAliases)})`;
}
if (ts.isUnionTypeNode(type)) {
const literals = type.types.filter(ts.isLiteralTypeNode);
if (literals.length === type.types.length && literals.length > 0) {
return `z.union([${literals.map((literal) => zodLiteral(literal)).join(', ')}])`;
}
return 'z.unknown()';
}
if (ts.isTypeLiteralNode(type)) {
const fields = type.members
.filter(ts.isPropertySignature)
.map((member) => `${quoteKey(propertyName(member.name))}: ${zodForType(member.type, namespaceName, typeAliases)}`);
return `z.object({ ${fields.join(', ')} })`;
}
if (ts.isTypeReferenceNode(type)) {
const ref = typeNameText(type.typeName);
const qualifiedRef = ref.includes('.') ? ref : `${namespaceName}.${ref}`;
const aliasType = typeAliases.get(qualifiedRef);
if (aliasType) return zodForType(aliasType, namespaceName, typeAliases);
return `z.lazy(() => ${schemaNameFromReference(ref, namespaceName)})`;
}
return 'z.unknown()';
}
function zodLiteral(literal) {
const node = literal.literal;
if (ts.isStringLiteral(node)) return `z.literal(${JSON.stringify(node.text)})`;
if (ts.isNumericLiteral(node)) return `z.literal(${node.text})`;
if (node.kind === ts.SyntaxKind.TrueKeyword) return 'z.literal(true)';
if (node.kind === ts.SyntaxKind.FalseKeyword) return 'z.literal(false)';
return 'z.unknown()';
}
function schemaNameFromReference(ref, namespaceName) {
if (ref.includes('.')) {
const [ns, name] = ref.split('.');
return schemaName(ns, name);
}
return schemaName(namespaceName, ref);
}
function schemaName(namespaceName, typeName) {
return `${pascal(namespaceName)}${pascal(typeName)}Schema`;
}
function propertyName(name) {
if (!name) return '';
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text;
return '';
}
function quoteKey(key) {
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
}
function typeNameText(name) {
if (ts.isIdentifier(name)) return name.text;
if (ts.isQualifiedName(name)) return `${typeNameText(name.left)}.${name.right.text}`;
return 'unknown';
}
function pascal(value) {
return value
.split(/[^A-Za-z0-9]+/)
.filter(Boolean)
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
.join('');
}
function isExportedNamespace(node) {
return ts.isModuleDeclaration(node) && isExported(node) && ts.isIdentifier(node.name);
}
function isExported(node) {
return Boolean(node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword));
}

3
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "./.quasar/tsconfig.json"
}