migrate to vue+vueuse+valerieui bc quasar customisation is sad
This commit is contained in:
parent
9230d1f626
commit
227a3d6186
@ -58,9 +58,8 @@ Organic Bananas
|
||||
API_DOCS_URL: str = "/api/docs"
|
||||
API_REDOC_URL: str = "/api/redoc"
|
||||
CORS_ORIGINS: list[str] = [
|
||||
"http://localhost:5174",
|
||||
"http://localhost:5173",
|
||||
"http://localhost:8000",
|
||||
"http://localhost:9000",
|
||||
# Add your deployed frontend URL here later
|
||||
# "https://your-frontend-domain.com",
|
||||
]
|
||||
|
@ -1,7 +1,9 @@
|
||||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
|
||||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
end_of_line = lf
|
||||
max_line_length = 100
|
||||
|
1
fe/.gitattributes
vendored
Normal file
1
fe/.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
44
fe/.gitignore
vendored
44
fe/.gitignore
vendored
@ -1,33 +1,33 @@
|
||||
.DS_Store
|
||||
.thumbs.db
|
||||
node_modules
|
||||
|
||||
# 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
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# local .env files
|
||||
.env.local*
|
||||
*.tsbuildinfo
|
||||
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
@ -1,5 +0,0 @@
|
||||
# pnpm-related options
|
||||
shamefully-hoist=true
|
||||
strict-peer-dependencies=false
|
||||
# to get the latest compatible packages when creating the project https://github.com/pnpm/pnpm/issues/6463
|
||||
resolution-mode=highest
|
@ -1,5 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
16
fe/.vscode/extensions.json
vendored
16
fe/.vscode/extensions.json
vendored
@ -1,15 +1,11 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"vitest.explorer",
|
||||
"ms-playwright.playwright",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"editorconfig.editorconfig",
|
||||
"vue.volar",
|
||||
"wayou.vscode-todo-highlight"
|
||||
],
|
||||
"unwantedRecommendations": [
|
||||
"octref.vetur",
|
||||
"hookyqr.beautify",
|
||||
"dbaeumer.jshint",
|
||||
"ms-vscode.vscode-typescript-tslint-plugin"
|
||||
"EditorConfig.EditorConfig",
|
||||
"oxc.oxc-vscode",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
23
fe/.vscode/settings.json
vendored
23
fe/.vscode/settings.json
vendored
@ -1,16 +1,13 @@
|
||||
{
|
||||
"editor.bracketPairColorization.enabled": true,
|
||||
"editor.guides.bracketPairs": true,
|
||||
"explorer.fileNesting.enabled": true,
|
||||
"explorer.fileNesting.patterns": {
|
||||
"tsconfig.json": "tsconfig.*.json, env.d.ts",
|
||||
"vite.config.*": "jsconfig*, vitest.config.*, cypress.config.*, playwright.config.*",
|
||||
"package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .oxlint*, oxlint*, .prettier*, prettier*, .editorconfig"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit"
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": [
|
||||
"source.fixAll.eslint"
|
||||
],
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"vue"
|
||||
],
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
84
fe/README.md
84
fe/README.md
@ -1,40 +1,64 @@
|
||||
# mitlist (mitlist)
|
||||
# fe
|
||||
|
||||
mitlist pwa
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Install the dependencies
|
||||
```bash
|
||||
yarn
|
||||
# or
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Start the app in development mode (hot-code reloading, error reporting, etc.)
|
||||
```bash
|
||||
quasar dev
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
### Lint the files
|
||||
```bash
|
||||
yarn lint
|
||||
# or
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Run Unit Tests with [Vitest](https://vitest.dev/)
|
||||
|
||||
```sh
|
||||
npm run test:unit
|
||||
```
|
||||
|
||||
### Run End-to-End Tests with [Playwright](https://playwright.dev)
|
||||
|
||||
```sh
|
||||
# Install browsers for the first run
|
||||
npx playwright install
|
||||
|
||||
# When testing on CI, must build the project first
|
||||
npm run build
|
||||
|
||||
# Runs the end-to-end tests
|
||||
npm run test:e2e
|
||||
# Runs the tests only on Chromium
|
||||
npm run test:e2e -- --project=chromium
|
||||
# Runs the tests of a specific file
|
||||
npm run test:e2e -- tests/example.spec.ts
|
||||
# Runs the tests in debug mode
|
||||
npm run test:e2e -- --debug
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
npm run lint
|
||||
```
|
||||
|
||||
|
||||
### Format the files
|
||||
```bash
|
||||
yarn format
|
||||
# or
|
||||
npm run format
|
||||
```
|
||||
|
||||
|
||||
### 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).
|
||||
|
1
fe/dev-dist/registerSW.js
Normal file
1
fe/dev-dist/registerSW.js
Normal file
@ -0,0 +1 @@
|
||||
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'module' })
|
4
fe/e2e/tsconfig.json
Normal file
4
fe/e2e/tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"include": ["./**/*"]
|
||||
}
|
8
fe/e2e/vue.spec.ts
Normal file
8
fe/e2e/vue.spec.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// See here how to get started:
|
||||
// https://playwright.dev/docs/intro
|
||||
test('visits the app root url', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('h1')).toHaveText('You did it!');
|
||||
})
|
1
fe/env.d.ts
vendored
Normal file
1
fe/env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
@ -1,86 +0,0 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import pluginQuasar from '@quasar/app-vite/eslint'
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||
import prettierSkipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
||||
|
||||
export default defineConfigWithVueTs(
|
||||
{
|
||||
/**
|
||||
* Ignore the following files.
|
||||
* Please note that pluginQuasar.configs.recommended() already ignores
|
||||
* the "node_modules" folder for you (and all other Quasar project
|
||||
* relevant folders and files).
|
||||
*
|
||||
* ESLint requires "ignores" key to be the only one in this object
|
||||
*/
|
||||
// ignores: []
|
||||
},
|
||||
|
||||
pluginQuasar.configs.recommended(),
|
||||
js.configs.recommended,
|
||||
|
||||
/**
|
||||
* https://eslint.vuejs.org
|
||||
*
|
||||
* pluginVue.configs.base
|
||||
* -> Settings and rules to enable correct ESLint parsing.
|
||||
* pluginVue.configs[ 'flat/essential']
|
||||
* -> base, plus rules to prevent errors or unintended behavior.
|
||||
* pluginVue.configs["flat/strongly-recommended"]
|
||||
* -> Above, plus rules to considerably improve code readability and/or dev experience.
|
||||
* pluginVue.configs["flat/recommended"]
|
||||
* -> Above, plus rules to enforce subjective community defaults to ensure consistency.
|
||||
*/
|
||||
pluginVue.configs[ 'flat/essential' ],
|
||||
|
||||
{
|
||||
files: ['**/*.ts', '**/*.vue'],
|
||||
rules: {
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{ prefer: 'type-imports' }
|
||||
],
|
||||
}
|
||||
},
|
||||
// https://github.com/vuejs/eslint-config-typescript
|
||||
vueTsConfigs.recommendedTypeChecked,
|
||||
|
||||
{
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node, // SSR, Electron, config files
|
||||
process: 'readonly', // process.env.*
|
||||
ga: 'readonly', // Google Analytics
|
||||
cordova: 'readonly',
|
||||
Capacitor: 'readonly',
|
||||
chrome: 'readonly', // BEX related
|
||||
browser: 'readonly' // BEX related
|
||||
}
|
||||
},
|
||||
|
||||
// add your custom rules here
|
||||
rules: {
|
||||
'prefer-promise-reject-errors': 'off',
|
||||
|
||||
// allow debugger during development only
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
files: [ 'src-pwa/custom-service-worker.ts' ],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.serviceworker
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
prettierSkipFormatting
|
||||
)
|
36
fe/eslint.config.ts
Normal file
36
fe/eslint.config.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import pluginVitest from '@vitest/eslint-plugin'
|
||||
import pluginPlaywright from 'eslint-plugin-playwright'
|
||||
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
||||
|
||||
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||
|
||||
export default defineConfigWithVueTs(
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{ts,mts,tsx,vue}'],
|
||||
},
|
||||
|
||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||
|
||||
pluginVue.configs['flat/essential'],
|
||||
vueTsConfigs.recommended,
|
||||
|
||||
{
|
||||
...pluginVitest.configs.recommended,
|
||||
files: ['src/**/__tests__/*'],
|
||||
},
|
||||
|
||||
{
|
||||
...pluginPlaywright.configs['flat/recommended'],
|
||||
files: ['e2e/**/*.{test,spec}.{js,ts,jsx,tsx}'],
|
||||
},
|
||||
...pluginOxlint.configs['flat/recommended'],
|
||||
skipFormatting,
|
||||
)
|
@ -1,21 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title><%= productName %></title>
|
||||
|
||||
<meta charset="utf-8">
|
||||
<meta name="description" content="<%= productDescription %>">
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <!-- Or your favicon -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="mitlist pwa">
|
||||
<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<% } %>">
|
||||
|
||||
<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">
|
||||
<!-- PWA manifest and theme color will be injected by vite-plugin-pwa -->
|
||||
<title>mitlist</title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- quasar:entry-point -->
|
||||
<svg width="0" height="0" style="position: absolute">
|
||||
<defs>
|
||||
<symbol viewBox="0 0 24 24" id="icon-plus"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" /></symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-edit"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" /></symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-trash"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" /></symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-check"><path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" /></symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-close"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" /></symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-alert-triangle"><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" /></symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-clipboard"><path d="M16 2H8C6.9 2 6 2.9 6 4v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-4 18c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm4-10H8V8h8v2zm2-4V4l4 4h-4z" /></symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-info"><path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-settings"><path d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z"/></symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-user"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-bell"> <path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2zm-2 1H8v-6c0-2.21 1.79-4 4-4s4 1.79 4 4v6z"/> </symbol>
|
||||
</defs>
|
||||
</svg>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
6429
fe/package-lock.json
generated
6429
fe/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,55 +1,62 @@
|
||||
{
|
||||
"name": "mitlist",
|
||||
"version": "0.0.1",
|
||||
"description": "mitlist pwa",
|
||||
"productName": "mitlist",
|
||||
"author": "Mohamad <Mohamad.elsena@edvring.de>",
|
||||
"type": "module",
|
||||
"name": "fe",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lint": "eslint -c ./eslint.config.js \"./src*/**/*.{ts,js,cjs,mjs,vue}\"",
|
||||
"format": "prettier --write \"**/*.{js,ts,vue,scss,html,md,json}\" --ignore-path .gitignore",
|
||||
"test": "echo \"No test specified\" && exit 0",
|
||||
"dev": "quasar dev",
|
||||
"build": "quasar build",
|
||||
"postinstall": "quasar prepare"
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore",
|
||||
"lint:eslint": "eslint . --fix",
|
||||
"lint": "run-s lint:*",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@quasar/extras": "^1.16.4",
|
||||
"axios": "^1.2.1",
|
||||
"pinia": "^3.0.1",
|
||||
"quasar": "^2.16.0",
|
||||
"register-service-worker": "^1.7.2",
|
||||
"vue": "^3.4.18",
|
||||
"vue-i18n": "^11.0.0",
|
||||
"vue-router": "^4.0.12"
|
||||
"@vueuse/core": "^13.1.0",
|
||||
"axios": "^1.9.0",
|
||||
"pinia": "^3.0.2",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^12.0.0-alpha.2",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.14.0",
|
||||
"@intlify/unplugin-vue-i18n": "^4.0.0",
|
||||
"@quasar/app-vite": "^2.1.0",
|
||||
"@types/node": "^20.5.9",
|
||||
"@vue/eslint-config-prettier": "^10.1.0",
|
||||
"@vue/eslint-config-typescript": "^14.4.0",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-plugin-vue": "^9.30.0",
|
||||
"globals": "^15.12.0",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "~5.5.3",
|
||||
"vite-plugin-checker": "^0.9.0",
|
||||
"vue-tsc": "^2.0.29",
|
||||
"workbox-build": "^7.3.0",
|
||||
"@intlify/unplugin-vue-i18n": "^6.0.8",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@tsconfig/node22": "^22.0.1",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/node": "^22.15.17",
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"@vitest/eslint-plugin": "^1.1.39",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.5.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"eslint": "^9.26.0",
|
||||
"eslint-plugin-oxlint": "^0.16.0",
|
||||
"eslint-plugin-playwright": "^2.2.0",
|
||||
"eslint-plugin-vue": "~10.0.0",
|
||||
"jiti": "^2.4.2",
|
||||
"jsdom": "^26.0.0",
|
||||
"npm-run-all2": "^7.0.2",
|
||||
"oxlint": "^0.16.0",
|
||||
"prettier": "^3.5.3",
|
||||
"sass": "^1.88.0",
|
||||
"typescript": "~5.8.0",
|
||||
"vite": "^6.2.4",
|
||||
"vite-plugin-pwa": "^1.0.0",
|
||||
"vite-plugin-vue-devtools": "^7.7.2",
|
||||
"vitest": "^3.1.1",
|
||||
"vue-tsc": "^2.2.8",
|
||||
"workbox-cacheable-response": "^7.3.0",
|
||||
"workbox-core": "^7.3.0",
|
||||
"workbox-expiration": "^7.3.0",
|
||||
"workbox-precaching": "^7.3.0",
|
||||
"workbox-routing": "^7.3.0",
|
||||
"workbox-strategies": "^7.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^28 || ^26 || ^24 || ^22 || ^20 || ^18",
|
||||
"npm": ">= 6.13.4",
|
||||
"yarn": ">= 1.21.1"
|
||||
}
|
||||
}
|
||||
|
110
fe/playwright.config.ts
Normal file
110
fe/playwright.config.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import process from 'node:process'
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
/* Maximum time one test can run for. */
|
||||
timeout: 30 * 1000,
|
||||
expect: {
|
||||
/**
|
||||
* Maximum time expect() should wait for the condition to be met.
|
||||
* For example in `await expect(locator).toHaveText();`
|
||||
*/
|
||||
timeout: 5000,
|
||||
},
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
||||
actionTimeout: 0,
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: process.env.CI ? 'http://localhost:4173' : 'http://localhost:5173',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
|
||||
/* Only on CI systems run the tests headless */
|
||||
headless: !!process.env.CI,
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: {
|
||||
...devices['Desktop Firefox'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: {
|
||||
...devices['Desktop Safari'],
|
||||
},
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: {
|
||||
// ...devices['Pixel 5'],
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: {
|
||||
// ...devices['iPhone 12'],
|
||||
// },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: {
|
||||
// channel: 'msedge',
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: {
|
||||
// channel: 'chrome',
|
||||
// },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
||||
// outputDir: 'test-results/',
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
/**
|
||||
* Use the dev server by default for faster feedback loop.
|
||||
* Use the preview server on CI for more realistic testing.
|
||||
* Playwright will re-use the local server if there is already a dev-server running.
|
||||
*/
|
||||
command: process.env.CI ? 'npm run preview' : 'npm run dev',
|
||||
port: process.env.CI ? 4173 : 5173,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
})
|
@ -1,29 +0,0 @@
|
||||
// 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()
|
||||
]
|
||||
}
|
Binary file not shown.
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 4.2 KiB |
@ -1,235 +0,0 @@
|
||||
// Configuration for your app
|
||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file
|
||||
|
||||
import { defineConfig } from '#q-app/wrappers';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
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', 'axios'],
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css
|
||||
css: ['app.scss'],
|
||||
|
||||
// https://github.com/quasarframework/quasar/tree/dev/extras
|
||||
extras: [
|
||||
// 'ionicons-v4',
|
||||
// 'mdi-v7',
|
||||
// 'fontawesome-v6',
|
||||
// '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
|
||||
],
|
||||
|
||||
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#build
|
||||
build: {
|
||||
target: {
|
||||
browser: ['es2022', 'firefox115', 'chrome115', 'safari14'],
|
||||
node: 'node20',
|
||||
},
|
||||
|
||||
typescript: {
|
||||
strict: true,
|
||||
vueShim: true,
|
||||
// extendTsConfig (tsConfig) {}
|
||||
},
|
||||
|
||||
vueRouterMode: 'hash', // available values: 'hash', 'history'
|
||||
// vueRouterBase,
|
||||
// vueDevtools,
|
||||
// vueOptionsAPI: false,
|
||||
|
||||
// rebuildCache: true, // rebuilds Vite/linter/etc cache on startup
|
||||
|
||||
// publicPath: '/',
|
||||
// analyze: true,
|
||||
// env: {},
|
||||
// rawDefine: {}
|
||||
// ignorePublicFolder: true,
|
||||
// minify: false,
|
||||
// polyfillModulePreload: true,
|
||||
// distDir
|
||||
|
||||
// extendViteConf (viteConf) {},
|
||||
// viteVuePluginOptions: {},
|
||||
|
||||
vitePlugins: [
|
||||
[
|
||||
'@intlify/unplugin-vue-i18n/vite',
|
||||
{
|
||||
// if you want to use Vue I18n Legacy API, you need to set `compositionOnly: false`
|
||||
// compositionOnly: false,
|
||||
|
||||
// if you want to use named tokens in your Vue I18n messages, such as 'Hello {name}',
|
||||
// you need to set `runtimeOnly: false`
|
||||
// runtimeOnly: false,
|
||||
|
||||
ssr: ctx.modeName === 'ssr',
|
||||
|
||||
// you need to set i18n resource including paths !
|
||||
include: [fileURLToPath(new URL('./src/i18n', import.meta.url))],
|
||||
},
|
||||
],
|
||||
|
||||
[
|
||||
'vite-plugin-checker',
|
||||
{
|
||||
vueTsc: true,
|
||||
eslint: {
|
||||
lintCommand: 'eslint -c ./eslint.config.js "./src*/**/*.{ts,js,mjs,cjs,vue}"',
|
||||
useFlatConfig: true,
|
||||
},
|
||||
},
|
||||
{ server: false },
|
||||
],
|
||||
],
|
||||
},
|
||||
|
||||
// Full list of options: 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: ['Notify'],
|
||||
},
|
||||
|
||||
// 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/store/index',
|
||||
// pwaRegisterServiceWorker: 'src-pwa/register-service-worker',
|
||||
// pwaServiceWorker: 'src-pwa/custom-service-worker',
|
||||
// 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
|
||||
],
|
||||
|
||||
// extendPackageJson (json) {},
|
||||
// extendSSRWebserverConf (esbuildConf) {},
|
||||
|
||||
// manualStoreSerialization: true,
|
||||
// manualStoreSsrContextInjection: true,
|
||||
// manualStoreHydration: true,
|
||||
// manualPostHydrationTrigger: true,
|
||||
|
||||
pwa: false,
|
||||
// pwaOfflineHtmlFilename: 'offline.html', // do NOT use index.html as name!
|
||||
|
||||
// pwaExtendGenerateSWOptions (cfg) {},
|
||||
// pwaExtendInjectManifestOptions (cfg) {}
|
||||
},
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-vite/developing-pwa/configuring-pwa
|
||||
pwa: {
|
||||
workboxMode: 'InjectManifest', // Changed from 'GenerateSW' to 'InjectManifest'
|
||||
swFilename: 'sw.js',
|
||||
manifestFilename: 'manifest.json',
|
||||
injectPwaMetaTags: true,
|
||||
// extendManifestJson (json) {},
|
||||
// useCredentialsForManifestTag: true,
|
||||
// extendPWACustomSWConf (esbuildConf) {},
|
||||
// extendGenerateSWOptions (cfg) {},
|
||||
// extendInjectManifestOptions (cfg) {}
|
||||
},
|
||||
|
||||
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-cordova-apps/configuring-cordova
|
||||
cordova: {
|
||||
// noIosLegacyBuildFlag: true, // uncomment only if you know what you are doing
|
||||
},
|
||||
|
||||
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-capacitor-apps/configuring-capacitor
|
||||
capacitor: {
|
||||
hideSplashscreen: true,
|
||||
},
|
||||
|
||||
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-electron-apps/configuring-electron
|
||||
electron: {
|
||||
// extendElectronMainConf (esbuildConf) {},
|
||||
// extendElectronPreloadConf (esbuildConf) {},
|
||||
|
||||
// extendPackageJson (json) {},
|
||||
|
||||
// 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/configuration
|
||||
|
||||
appId: 'mitlist',
|
||||
},
|
||||
},
|
||||
|
||||
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-browser-extensions/configuring-bex
|
||||
bex: {
|
||||
// extendBexScriptsConf (esbuildConf) {},
|
||||
// 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: [],
|
||||
},
|
||||
};
|
||||
});
|
@ -1,32 +0,0 @@
|
||||
{
|
||||
"orientation": "portrait",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#027be3",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/icon-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-256x256.png",
|
||||
"sizes": "256x256",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
7
fe/src-pwa/pwa-env.d.ts
vendored
7
fe/src-pwa/pwa-env.d.ts
vendored
@ -1,7 +0,0 @@
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
SERVICE_WORKER_FILE: string;
|
||||
PWA_FALLBACK_HTML: string;
|
||||
PWA_SERVICE_WORKER_REGEX: string;
|
||||
}
|
||||
}
|
@ -1,41 +0,0 @@
|
||||
import { register } from 'register-service-worker';
|
||||
|
||||
// The ready(), registered(), cached(), updatefound() and updated()
|
||||
// events passes a ServiceWorkerRegistration instance in their arguments.
|
||||
// ServiceWorkerRegistration: https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration
|
||||
|
||||
register(process.env.SERVICE_WORKER_FILE, {
|
||||
// The registrationOptions object will be passed as the second argument
|
||||
// to ServiceWorkerContainer.register()
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register#Parameter
|
||||
|
||||
// registrationOptions: { scope: './' },
|
||||
|
||||
ready (/* registration */) {
|
||||
// console.log('Service worker is active.')
|
||||
},
|
||||
|
||||
registered (/* registration */) {
|
||||
// console.log('Service worker has been registered.')
|
||||
},
|
||||
|
||||
cached (/* registration */) {
|
||||
// console.log('Content has been cached for offline use.')
|
||||
},
|
||||
|
||||
updatefound (/* registration */) {
|
||||
// console.log('New content is downloading.')
|
||||
},
|
||||
|
||||
updated (/* registration */) {
|
||||
// console.log('New content is available; please refresh.')
|
||||
},
|
||||
|
||||
offline () {
|
||||
// console.log('No internet connection found. App is running in offline mode.')
|
||||
},
|
||||
|
||||
error (/* err */) {
|
||||
// console.error('Error during service worker registration:', err)
|
||||
},
|
||||
});
|
@ -1,7 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["WebWorker", "ESNext"]
|
||||
},
|
||||
"include": ["*.ts", "*.d.ts"]
|
||||
}
|
@ -1,7 +1,29 @@
|
||||
<template>
|
||||
<router-view />
|
||||
<NotificationDisplay /> <!-- For custom notifications -->
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
//
|
||||
import NotificationDisplay from '@/components/global/NotificationDisplay.vue';
|
||||
// Potentially initialize offline store or other global listeners here if needed
|
||||
// import { useOfflineStore } from './stores/offline';
|
||||
// const offlineStore = useOfflineStore();
|
||||
// offlineStore.init(); // If you move init logic here
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Avenir, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
color: #2c3e50;
|
||||
background-color: #f0f2f5; /* Example background */
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
1
fe/src/assets/logo.svg
Normal file
1
fe/src/assets/logo.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
After Width: | Height: | Size: 276 B |
26
fe/src/assets/main.scss
Normal file
26
fe/src/assets/main.scss
Normal file
@ -0,0 +1,26 @@
|
||||
// src/assets/main.scss
|
||||
// @import './variables.scss'; // Your custom variables
|
||||
@import './valerie-ui.scss';
|
||||
|
||||
// Example global styles
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
margin: 0;
|
||||
background-color: var(--bg-color-page, #f4f4f8);
|
||||
color: var(--text-color, #333);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
// Add more global utility classes or base styles
|
@ -1,15 +0,0 @@
|
||||
<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>
|
Before Width: | Height: | Size: 4.4 KiB |
737
fe/src/assets/valerie-ui.scss
Normal file
737
fe/src/assets/valerie-ui.scss
Normal file
@ -0,0 +1,737 @@
|
||||
|
||||
:root {
|
||||
/* Colors */
|
||||
--primary: #ff7b54;
|
||||
--secondary: #ffb26b;
|
||||
--accent: #ffd56b; /* Yellow - for sticky notes */
|
||||
--secondary-accent: #54c7ff; /* Light Blue - for contrast */
|
||||
--danger: #ff4d4d;
|
||||
--success: #a0e7a0;
|
||||
--warning: var(--accent);
|
||||
--dark: #393e46;
|
||||
--light: #fff8f0;
|
||||
--black: #000000;
|
||||
|
||||
/* Shadows & Borders */
|
||||
--shadow-lg: 8px 8px 0px var(--black);
|
||||
--shadow-md: 6px 6px 0px var(--black);
|
||||
--shadow-sm: 3px 3px 0px var(--black);
|
||||
--shadow-inset: inset 2px 2px 1px rgba(0, 0, 0, 0.15);
|
||||
--border-width: 3px;
|
||||
--border-style: solid;
|
||||
--border-color: var(--black);
|
||||
--border: var(--border-width) var(--border-style) var(--border-color);
|
||||
|
||||
/* Transitions */
|
||||
--transition-speed: 0.25s;
|
||||
--transition-speed-fast: 0.1s;
|
||||
--transition-ease-out: ease-out;
|
||||
--transition-press: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
/* Focus */
|
||||
--focus-outline-color: var(--secondary-accent);
|
||||
--focus-outline-width: 3px;
|
||||
--focus-outline-offset: 2px;
|
||||
--focus-outline: var(--focus-outline-width) solid
|
||||
var(--focus-outline-color);
|
||||
|
||||
/* Textures */
|
||||
--paper-texture: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='60' viewBox='0 0 60 60'%3E%3Cg fill-rule='evenodd'%3E%3Cg fill='%23a59a8a' fill-opacity='0.15'%3E%3Cpath opacity='.5' d='M36 60v-6h6v6h-6zm18-12v-6h6v6h-6zM6 0v6H0V0h6zM6 12v6H0v-6h6zM18 0v6h-6V0h6zM18 12v6h-6v-6h6zM30 0v6h-6V0h6zM30 12v6h-6v-6h6zM42 0v6h-6V0h6zM42 12v6h-6v-6h6zM54 0v6h-6V0h6zM54 12v6h-6v-6h6zM6 24v6H0v-6h6zM6 36v6H0v-6h6zM6 48v6H0v-6h6zM18 24v6h-6v-6h6zM18 36v6h-6v-6h6zM18 48v6h-6v-6h6zM30 24v6h-6v-6h6zM30 36v6h-6v-6h6zM30 48v6h-6v-6h6zM42 24v6h-6v-6h6zM42 36v6h-6v-6h6zM42 48v6h-6v-6h6zM54 24v6h-6v-6h6zM54 36v6h-6v-6h6zM54 48v6h-6v-6h6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
|
||||
--progress-texture: repeating-linear-gradient(
|
||||
45deg,
|
||||
rgba(0, 0, 0, 0.05),
|
||||
rgba(0, 0, 0, 0.05) 5px,
|
||||
transparent 5px,
|
||||
transparent 10px
|
||||
);
|
||||
}
|
||||
|
||||
/* Accessibility Helpers */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Reduced Motion Preference */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
transition-delay: 0ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Basic Reset & Body */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: "Patrick Hand", cursive;
|
||||
background-color: var(--light);
|
||||
background-image: var(--paper-texture);
|
||||
padding: 2rem 1rem;
|
||||
color: var(--dark);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
overflow-x: hidden; /* Prevent horizontal scroll */
|
||||
margin: 0;
|
||||
}
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Focus Visible Styles */
|
||||
:is(a, button, input, select, textarea, [tabindex]):not(
|
||||
[tabindex="-1"]
|
||||
):focus-visible {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: var(--focus-outline-offset);
|
||||
box-shadow: 0 0 0 var(--focus-outline-width) var(--focus-outline-color),
|
||||
var(--shadow-md);
|
||||
z-index: 10; /* Bring focused element forward */
|
||||
}
|
||||
.btn:focus-visible {
|
||||
box-shadow: 0 0 0 var(--focus-outline-width) var(--focus-outline-color),
|
||||
var(--shadow-lg);
|
||||
}
|
||||
.form-input:focus-visible {
|
||||
box-shadow: var(--shadow-inset),
|
||||
0 0 0 var(--focus-outline-width) var(--focus-outline-color),
|
||||
var(--shadow-sm);
|
||||
}
|
||||
.list-item:focus-visible { /* Focus on list item might need adjustment if using swipe */
|
||||
outline-offset: -var(--border-width);
|
||||
z-index: 10;
|
||||
}
|
||||
.tab-item:focus-visible {
|
||||
outline-offset: 4px;
|
||||
z-index: 5; /* Higher than non-focused tabs */
|
||||
}
|
||||
.checkbox-label input:focus-visible ~ .checkmark,
|
||||
.radio-label input:focus-visible ~ .checkmark {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: var(--focus-outline-offset);
|
||||
box-shadow: var(--shadow-sm),
|
||||
0 0 0 var(--focus-outline-width) var(--focus-outline-color);
|
||||
}
|
||||
.switch-container input:focus-visible + .switch {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: var(--focus-outline-offset);
|
||||
}
|
||||
.avatar:focus-visible {
|
||||
box-shadow: 0 0 0 var(--focus-outline-width) var(--focus-outline-color),
|
||||
var(--shadow-sm);
|
||||
}
|
||||
.tooltip .tooltip-trigger:focus-visible {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: var(--focus-outline-offset);
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-weight: bold;
|
||||
color: var(--dark);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
h1::after,
|
||||
h2::after,
|
||||
h3::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: var(--border-width);
|
||||
background-color: var(--primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
h1 { font-size: clamp(2rem, 5vw, 2.5rem); }
|
||||
h2 { font-size: clamp(1.6rem, 4vw, 2rem); }
|
||||
h3 { font-size: clamp(1.3rem, 3.5vw, 1.5rem); }
|
||||
|
||||
/* Icon Base Style */
|
||||
.icon {
|
||||
display: inline-block;
|
||||
width: 1.1em;
|
||||
height: 1.1em;
|
||||
vertical-align: -0.15em;
|
||||
margin-right: 0.4em;
|
||||
fill: currentColor;
|
||||
}
|
||||
.btn .icon { margin-right: 0.5em; }
|
||||
button > .icon:last-child { margin-right: 0; }
|
||||
.icon-sm { width: 0.9em; height: 0.9em; }
|
||||
.icon-lg { width: 1.5em; height: 1.5em; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
background-color: var(--primary);
|
||||
color: var(--dark);
|
||||
border: var(--border);
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
padding: 0.8rem 1.6rem;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: transform var(--transition-speed) var(--transition-ease-out),
|
||||
box-shadow var(--transition-speed) var(--transition-ease-out),
|
||||
background-color var(--transition-speed) var(--transition-ease-out);
|
||||
position: relative;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin: 0.5rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
touch-action: manipulation;
|
||||
overflow: hidden;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
transform: translate(-3px, -3px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.btn:active:not(:disabled) {
|
||||
transform: translate(2px, 2px) scale(0.98);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition-duration: var(--transition-speed-fast);
|
||||
transition-timing-function: var(--transition-press);
|
||||
animation: jiggle-subtle 0.3s ease-out forwards;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.btn-secondary { background-color: var(--secondary); }
|
||||
.btn-neutral { background-color: var(--light); color: var(--dark); }
|
||||
.btn-danger { background-color: var(--danger); color: var(--light); }
|
||||
.btn-sm { padding: 0.5rem 1rem; font-size: 0.9rem; }
|
||||
.btn-icon-only { padding: 0.6rem; }
|
||||
.btn-icon-only .icon { margin-right: 0; }
|
||||
|
||||
@keyframes jiggle-subtle {
|
||||
0%, 100% { transform: translate(2px, 2px) scale(0.98) rotate(0deg); }
|
||||
25% { transform: translate(2px, 2px) scale(0.98) rotate(-0.5deg); }
|
||||
75% { transform: translate(2px, 2px) scale(0.98) rotate(0.5deg); }
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background-color: var(--light);
|
||||
border: var(--border);
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
/* overflow: hidden; */ /* REMOVED overflow: hidden which can clip tooltips */
|
||||
/* Add transition back if subtle hover is desired, but not transform */
|
||||
transition: box-shadow var(--transition-speed) var(--transition-ease-out);
|
||||
}
|
||||
/* Removed .card:hover transform/shadow increase */
|
||||
.card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: var(--paper-texture);
|
||||
opacity: 0.6;
|
||||
z-index: 0;
|
||||
pointer-events: none; /* Prevent texture from interfering */
|
||||
}
|
||||
.card > * {
|
||||
position: relative;
|
||||
z-index: 1; /* Ensure content is above texture */
|
||||
}
|
||||
.card-header {
|
||||
margin: -1.5rem -1.5rem 1.5rem -1.5rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: var(--border);
|
||||
font-weight: bold;
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.card-body {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
margin: 1.5rem -1.5rem -1.5rem -1.5rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: var(--border);
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
/* Empty State Card */
|
||||
.empty-state-card {
|
||||
text-align: center;
|
||||
padding: 3rem 1.5rem;
|
||||
}
|
||||
.empty-state-card .icon-lg {
|
||||
display: block;
|
||||
margin: 0 auto 1rem auto;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.empty-state-card h3 { border: none; padding: 0; margin-bottom: 0.5rem; }
|
||||
.empty-state-card h3::after { display: none; }
|
||||
.empty-state-card p { margin-bottom: 1.5rem; opacity: 0.8; }
|
||||
|
||||
/* Forms */
|
||||
.form-group { margin-bottom: 1.5rem; }
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--dark);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.form-input,
|
||||
select.form-input,
|
||||
textarea.form-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: var(--border);
|
||||
background-color: var(--light);
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
box-shadow: var(--shadow-inset), var(--shadow-sm);
|
||||
transition: transform var(--transition-speed) var(--transition-ease-out),
|
||||
box-shadow var(--transition-speed) var(--transition-ease-out),
|
||||
border-color var(--transition-speed) var(--transition-ease-out);
|
||||
box-sizing: border-box;
|
||||
appearance: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
textarea.form-input { line-height: 1.5; min-height: 80px; }
|
||||
select.form-input {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23393e46'%3E%3Cpath d='M7 10l5 5 5-5H7z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
background-size: 1.5em;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
.form-input:focus {
|
||||
outline: none; /* Use focus-visible */
|
||||
transform: translate(1px, 1px);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.form-input.error { border-color: var(--danger); background-color: #ffeeee; }
|
||||
.form-input:disabled { background-color: rgba(0, 0, 0, 0.05); opacity: 0.7; cursor: not-allowed; box-shadow: var(--shadow-inset); }
|
||||
|
||||
/* Checkbox & Radio */
|
||||
.checkbox-group, .radio-group { margin-bottom: 1rem; background: rgba(0,0,0,0.02); padding: 0.5rem; border: 1px solid rgba(0,0,0,0.1); }
|
||||
.checkbox-label, .radio-label { position: relative; padding-left: 2.8rem; margin-bottom: 0.75rem; cursor: pointer; display: flex; align-items: center; min-height: 30px; }
|
||||
.checkbox-label input, .radio-label input { position: absolute; opacity: 0; cursor: pointer; height: 100%; width: 100%; top: 0; left: 0; margin: 0; }
|
||||
.checkmark { position: absolute; top: 50%; transform: translateY(-50%); left: 0.5rem; height: 28px; width: 28px; background-color: var(--light); border: var(--border); box-shadow: var(--shadow-sm); transition: background-color var(--transition-speed) var(--transition-ease-out); pointer-events: none; }
|
||||
.radio-mark { border-radius: 50%; }
|
||||
.checkbox-label input:checked ~ .checkmark:after, .radio-label input:checked ~ .checkmark:after { content: ""; position: absolute; display: block; transform-origin: center center; }
|
||||
.checkbox-label .checkmark:after { left: 8px; top: 3px; width: 7px; height: 14px; border: solid var(--black); border-width: 0 4px 4px 0; transform: rotate(45deg) scale(0); animation: checkmark-appear var(--transition-speed) var(--transition-ease-out) forwards; }
|
||||
.radio-label .checkmark:after { top: 5px; left: 5px; width: 12px; height: 12px; background-color: var(--black); border-radius: 50%; transform: scale(0); animation: radio-appear var(--transition-speed) var(--transition-ease-out) forwards; }
|
||||
@keyframes checkmark-appear { 0% { transform: scale(0) rotate(45deg); opacity: 0; } 70% { transform: scale(1.1) rotate(45deg); opacity: 1; } 100% { transform: scale(1) rotate(45deg); opacity: 1; } }
|
||||
@keyframes radio-appear { 0% { transform: scale(0); opacity: 0; } 70% { transform: scale(1.1); opacity: 1; } 100% { transform: scale(1); opacity: 1; } }
|
||||
|
||||
/* Lists */
|
||||
.item-list { list-style: none; padding: 0; margin: 0; }
|
||||
.list-item {
|
||||
border: var(--border);
|
||||
padding: 0; /* Remove padding here, add to content */
|
||||
margin-bottom: 1rem;
|
||||
background-color: var(--light);
|
||||
box-shadow: var(--shadow-md);
|
||||
display: flex; /* Use flex on the outer item */
|
||||
align-items: stretch; /* Stretch children vertically */
|
||||
transition: box-shadow var(--transition-speed) var(--transition-ease-out);
|
||||
position: relative;
|
||||
overflow: hidden; /* Needed for swipe actions reveal */
|
||||
}
|
||||
.list-item::before { content: ""; position: absolute; inset: 0; background-image: var(--paper-texture); opacity: 0.4; z-index: 0; pointer-events: none; }
|
||||
.list-item.completed .item-text { text-decoration: line-through; text-decoration-thickness: 2px; text-decoration-color: var(--dark); }
|
||||
.list-item.completed { opacity: 0.7; }
|
||||
/* --- Swipe Functionality Structure --- */
|
||||
.list-item-content {
|
||||
display: flex;
|
||||
flex-wrap: wrap; /* Allow wrapping inside content area */
|
||||
align-items: center;
|
||||
padding: 1rem; /* Add padding here */
|
||||
flex-grow: 1; /* Take available space */
|
||||
background-color: var(--light); /* Ensure bg covers area */
|
||||
position: relative; /* To stay above ::before */
|
||||
z-index: 1; /* Above texture */
|
||||
transition: transform 0.3s ease-out; /* Swipe animation */
|
||||
width: 100%; /* Ensure it takes full width initially */
|
||||
}
|
||||
.swipe-actions {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: stretch; /* Make buttons fill height */
|
||||
z-index: 0; /* Below content initially */
|
||||
transform: translateX(100%); /* Initially hidden off-screen */
|
||||
transition: transform 0.3s ease-out;
|
||||
}
|
||||
.swipe-actions .btn {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
border-left: var(--border); /* Separator */
|
||||
border-top: none;
|
||||
border-bottom: none;
|
||||
border-right: none;
|
||||
box-shadow: none;
|
||||
font-size: 0.9rem;
|
||||
min-width: 80px; /* Ensure button has width */
|
||||
}
|
||||
/* State when swiped (JS adds/removes this class) */
|
||||
.list-item.is-swiped .list-item-content {
|
||||
/* Adjust translateX based on desired swipe distance/action width */
|
||||
transform: translateX(-160px); /* Example: width of two buttons */
|
||||
}
|
||||
.list-item.is-swiped .swipe-actions {
|
||||
transform: translateX(0); /* Reveal actions */
|
||||
}
|
||||
/* -------------------------------------- */
|
||||
/* Content layout inside .list-item-content */
|
||||
.list-item-main { /* Wrapper for checkbox and text */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-grow: 1; /* Takes up space, pushing details right */
|
||||
margin-right: 1rem; /* Space before details */
|
||||
min-width: 150px; /* Prevent excessive shrinking */
|
||||
}
|
||||
.list-item .checkbox-label { flex-shrink: 0; margin-bottom: 0; }
|
||||
.item-text { margin-left: 1rem; flex-grow: 1; min-width: 50px; } /* Allow text to grow */
|
||||
.list-item-details { /* Wrapper for avatar, badges, actions (non-swipe) */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap; /* Prevent wrapping of these items */
|
||||
flex-shrink: 0; /* Don't shrink this container */
|
||||
margin-left: auto; /* Push to the right on larger screens */
|
||||
}
|
||||
.list-item-details > * { margin-left: 0.5rem; } /* Spacing between items in details */
|
||||
.list-item-details > :first-child { margin-left: 0; }
|
||||
.list-item-details .avatar { margin: 0; } /* Reset avatar margin */
|
||||
.list-item-actions { /* Non-swipe actions styling */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 0.5rem; /* Space before non-swipe actions */
|
||||
}
|
||||
.list-item-actions .btn { padding: 0.4rem 0.8rem; font-size: 0.9rem; margin: 0 0 0 0.25rem; }
|
||||
|
||||
/* Badges & Avatars */
|
||||
.item-badge { display: inline-block; padding: 0.25rem 0.6rem; background-color: var(--secondary); border: 2px solid var(--black); margin: 0.25rem 0; font-weight: bold; box-shadow: var(--shadow-sm); font-size: 0.85rem; text-transform: uppercase; flex-shrink: 0; position: relative; }
|
||||
.badge-accent { background-color: var(--accent); }
|
||||
.badge-accent.badge-sticky::after { content: ""; position: absolute; bottom: -1px; right: -1px; width: 50%; height: 50%; background: linear-gradient( 135deg, transparent 50%, rgba(0,0,0,0.15) 50% ); transform: skew(-15deg, -15deg) translate(2px, 2px); filter: blur(1px); border-bottom-right-radius: 3px; z-index: -1; box-shadow: 2px 2px 3px rgba(0,0,0,0.2); }
|
||||
.badge-settled { background-color: var(--secondary-accent); color: var(--dark); }
|
||||
.badge-pending { background-color: var(--warning); color: var(--dark); }
|
||||
.avatar { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 50%; background-color: var(--secondary); color: var(--dark); border: 2px solid var(--black); box-shadow: var(--shadow-sm); font-weight: bold; font-size: 1rem; text-transform: uppercase; margin: 0.25rem; flex-shrink: 0; overflow: hidden; cursor: default; }
|
||||
.avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
/* Tabs */
|
||||
.tabs { margin-bottom: 2rem; }
|
||||
.tab-list { display: flex; flex-wrap: wrap; list-style: none; padding: 0; margin: 0 0 -3px 0; /* Overlap border */ position: relative; z-index: 1; /* Above content by default */ }
|
||||
.tab-item { padding: 0.75rem 1.5rem; background-color: var(--light); border: var(--border); border-bottom-color: var(--border-color); margin-right: 0.5rem; margin-bottom: 0; /* Remove bottom margin */ cursor: pointer; font-weight: bold; position: relative; box-shadow: var(--shadow-sm); transition: transform var(--transition-speed) var(--transition-ease-out), box-shadow var(--transition-speed) var(--transition-ease-out), background-color var(--transition-speed) var(--transition-ease-out), color var(--transition-speed) var(--transition-ease-out), border-color var(--transition-speed) var(--transition-ease-out); text-transform: uppercase; letter-spacing: 0.5px; flex-grow: 0; }
|
||||
.tab-item[aria-selected="true"] {
|
||||
background-color: var(--primary);
|
||||
color: var(--dark);
|
||||
border-color: var(--black); /* Ensure all borders are black */
|
||||
border-bottom-color: var(--primary); /* Match background to hide border */
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 2; /* Bring active tab forward */
|
||||
/* transform: translateY(-3px); REMOVED */
|
||||
}
|
||||
.tab-item:not([aria-selected="true"]):hover { background-color: var(--secondary); transform: translateY(-2px); box-shadow: var(--shadow-md); }
|
||||
.tab-content {
|
||||
padding: 1.5rem;
|
||||
border: var(--border);
|
||||
background-color: var(--light);
|
||||
box-shadow: var(--shadow-md);
|
||||
position: relative;
|
||||
overflow: hidden; /* Keep hidden for content flow */
|
||||
margin-top: -3px; /* Pull up behind active tab */
|
||||
z-index: 0; /* Ensure content is behind tab list */
|
||||
}
|
||||
.tab-content[hidden] { display: none; }
|
||||
.tab-content::before { content: ""; position: absolute; inset: 0; background-image: var(--paper-texture); opacity: 0.6; z-index: 0; pointer-events: none; }
|
||||
.tab-content > * { position: relative; z-index: 1; }
|
||||
|
||||
/* Modal */
|
||||
.modal-backdrop { position: fixed; inset: 0; background-color: rgba(57, 62, 70, 0.7); display: flex; align-items: center; justify-content: center; z-index: 1000; opacity: 0; visibility: hidden; transition: opacity var(--transition-speed) var(--transition-ease-out), visibility 0s linear var(--transition-speed); }
|
||||
.modal-backdrop.open { opacity: 1; visibility: visible; transition: opacity var(--transition-speed) var(--transition-ease-out), visibility 0s linear 0s; }
|
||||
.modal-container { background-color: var(--light); border: var(--border); width: 90%; max-width: 550px; box-shadow: var(--shadow-lg); position: relative; /* overflow: hidden; */ /* Can cause tooltip clipping */ transform: scale(0.95) translateY(-20px); transition: transform var(--transition-speed) var(--transition-ease-out); max-height: 90vh; display: flex; flex-direction: column; }
|
||||
.modal-container::before { content: ""; position: absolute; inset: 0; background-image: var(--paper-texture); opacity: 0.6; z-index: 0; pointer-events: none; }
|
||||
.modal-backdrop.open .modal-container { transform: scale(1) translateY(0); }
|
||||
.modal-header { padding: 1rem 1.5rem; border-bottom: var(--border); display: flex; justify-content: space-between; align-items: center; background-color: rgba(255, 255, 255, 0.5); flex-shrink: 0; position: relative; z-index: 1; }
|
||||
.modal-header h3 { margin-bottom: 0; padding-bottom: 0; }
|
||||
.modal-header h3::after { display: none; }
|
||||
.modal-body { padding: 1.5rem; overflow-y: auto; position: relative; z-index: 1; flex-grow: 1; }
|
||||
.modal-footer { padding: 1rem 1.5rem; border-top: var(--border); display: flex; justify-content: flex-end; background-color: rgba(255, 255, 255, 0.5); flex-shrink: 0; position: relative; z-index: 1; }
|
||||
.close-button { background: none; border: none; font-size: 2rem; font-weight: bold; cursor: pointer; padding: 0; line-height: 1; color: var(--dark); transition: color var(--transition-speed) var(--transition-ease-out), transform var(--transition-speed-fast) ease-out; }
|
||||
.close-button .icon { margin: 0; }
|
||||
.close-button:hover { color: var(--danger); transform: scale(1.1); }
|
||||
.modal-container.confirm-modal .modal-body { text-align: center; }
|
||||
.modal-container.confirm-modal .modal-footer { justify-content: center; }
|
||||
.modal-container.confirm-modal .icon-lg { color: var(--danger); margin-bottom: 1rem; display: inline-block; }
|
||||
|
||||
/* Alerts */
|
||||
.alert {
|
||||
padding: 1rem 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border: var(--border);
|
||||
border-left-width: 6px; /* Thicker left border */
|
||||
box-shadow: var(--shadow-md);
|
||||
font-weight: bold;
|
||||
position: relative;
|
||||
overflow: hidden; /* Keep overflow for ::before */
|
||||
letter-spacing: 0.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between; /* Space out content and close button */
|
||||
}
|
||||
.alert::before { content: ""; position: absolute; inset: 0; background-image: var(--paper-texture); opacity: 0.6; z-index: 0; pointer-events: none;}
|
||||
.alert > .alert-content { /* Wrap main content */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-grow: 1; /* Take available space */
|
||||
margin-right: 1rem; /* Space before close button */
|
||||
position: relative; z-index: 1;
|
||||
}
|
||||
.alert .icon { margin-right: 0.8em; flex-shrink: 0; }
|
||||
.alert-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit; /* Inherit color from alert text */
|
||||
opacity: 0.7;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
padding: 0 0.25rem;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease-out;
|
||||
flex-shrink: 0; /* Prevent shrinking */
|
||||
position: relative; z-index: 1;
|
||||
}
|
||||
.alert-close-btn:hover { opacity: 1; }
|
||||
.alert-close-btn .icon { margin: 0; }
|
||||
|
||||
.alert-success { background-color: var(--success); color: var(--dark); border-left-color: hsl(120, 60%, 45%); } /* Darker green border */
|
||||
.alert-warning { background-color: var(--warning); color: var(--dark); border-left-color: hsl(45, 100%, 45%); } /* Darker yellow/orange border */
|
||||
.alert-error { background-color: #ffcccc; border-color: var(--danger); color: var(--dark); border-left-color: var(--danger); }
|
||||
.alert-info { background-color: var(--secondary-accent); color: var(--dark); border-left-color: hsl(200, 100%, 45%); } /* Darker blue border */
|
||||
|
||||
|
||||
/* Progress Bar */
|
||||
.progress-container { width: 100%; height: 35px; background-color: var(--light); border: var(--border); box-shadow: var(--shadow-md); margin-bottom: 1.5rem; position: relative; overflow: hidden; }
|
||||
.progress-bar { height: 100%; background-color: var(--primary); position: relative; transition: width 0.5s var(--transition-ease-out); background-image: var(--progress-texture); display: flex; align-items: center; justify-content: center; }
|
||||
.progress-text { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--dark); font-weight: bold; z-index: 1; text-shadow: 1px 1px 0px rgba(255, 255, 255, 0.7); font-size: 0.9rem; white-space: nowrap; }
|
||||
|
||||
/* Toggle Switch - Refined */
|
||||
.switch-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 64px; /* Slightly smaller */
|
||||
height: 34px; /* Slightly smaller */
|
||||
margin-bottom: 1rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.switch-container input { position: absolute; opacity: 0; width: 100%; height: 100%; top: 0; left: 0; margin: 0; cursor: pointer; z-index: 2; }
|
||||
.switch {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background-color: var(--light);
|
||||
border: var(--border);
|
||||
/* box-shadow: var(--shadow-inset), var(--shadow-sm); REMOVED - potential conflict */
|
||||
box-shadow: var(--shadow-inset); /* Keep inset shadow only */
|
||||
transition: background-color 0.4s var(--transition-ease-out);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.switch:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 24px; /* Adjusted size */
|
||||
width: 24px; /* Adjusted size */
|
||||
left: 2px; /* Adjusted position */
|
||||
top: 50%; /* Center vertically */
|
||||
background-color: var(--dark);
|
||||
border: 2px solid var(--light);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform 0.4s var(--transition-ease-out),
|
||||
background-color 0.4s var(--transition-ease-out),
|
||||
border-color 0.4s var(--transition-ease-out);
|
||||
transform: translateY(-50%); /* Vertical centering */
|
||||
}
|
||||
.switch-container input:checked + .switch { background-color: var(--secondary-accent); }
|
||||
.switch-container input:checked + .switch:before {
|
||||
background-color: var(--light);
|
||||
border-color: var(--dark);
|
||||
/* Width (64) - border*2 (6) - left (2) - width (24) = 32 */
|
||||
transform: translate(30px, -50%); /* Adjusted translation */
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table-container { overflow-x: auto; margin-bottom: 1.5rem; border: var(--border); box-shadow: var(--shadow-md); background-color: var(--light); position: relative; /* overflow: hidden; */ /* Can clip tooltips */ }
|
||||
.table-container::before { content: ""; position: absolute; inset: 0; background-image: var(--paper-texture); opacity: 0.6; z-index: 0; pointer-events: none; }
|
||||
.table { width: 100%; border-collapse: collapse; border-spacing: 0; position: relative; z-index: 1; min-width: 600px; }
|
||||
.table th, .table td { padding: 0.8rem 1rem; text-align: left; border-bottom: var(--border-width) var(--border-style) var(--border-color); border-right: 1px dashed rgba(0,0,0,0.1); white-space: nowrap; vertical-align: middle; }
|
||||
.table th:last-child, .table td:last-child { border-right: none; }
|
||||
.table thead th { background-color: var(--primary); color: var(--dark); text-transform: uppercase; letter-spacing: 1px; font-weight: bold; border-top: none; border-bottom-width: var(--border-width); position: sticky; top: -1px; z-index: 2; }
|
||||
.table tbody tr:last-child td { border-bottom: none; }
|
||||
.table tbody tr:hover { background-color: rgba(255, 178, 107, 0.2); }
|
||||
.table tfoot td { border-top: var(--border); font-weight: bold; background-color: rgba(255, 255, 255, 0.5); position: sticky; bottom: -1px; z-index: 2; }
|
||||
.table .wrap-text { white-space: normal; }
|
||||
.table .avatar { width: 30px; height: 30px; font-size: 0.9rem; margin-right: 0.5rem; }
|
||||
|
||||
/* Utilities */
|
||||
.mb-1 { margin-bottom: 0.5rem !important; } .mb-2 { margin-bottom: 1rem !important; } .mb-3 { margin-bottom: 1.5rem !important; } .mb-4 { margin-bottom: 2rem !important; }
|
||||
.mt-1 { margin-top: 0.5rem !important; } .mt-2 { margin-top: 1rem !important; } .mt-3 { margin-top: 1.5rem !important; } .mt-4 { margin-top: 2rem !important; }
|
||||
.ml-1 { margin-left: 0.5rem !important; } .ml-2 { margin-left: 1rem !important; }
|
||||
.mr-1 { margin-right: 0.5rem !important; } .mr-2 { margin-right: 1rem !important; }
|
||||
.text-center { text-align: center; } .text-right { text-align: right; }
|
||||
.flex { display: flex; } .justify-between { justify-content: space-between; } .justify-end { justify-content: flex-end; } .justify-center { justify-content: center; } .items-center { align-items: center; }
|
||||
.flex-wrap { flex-wrap: wrap; } .flex-nowrap { flex-wrap: nowrap; }
|
||||
.flex-grow { flex-grow: 1; } .flex-shrink-0 { flex-shrink: 0; } .w-full { width: 100%; }
|
||||
|
||||
/* Tooltip */
|
||||
.tooltip { position: relative; display: inline-block; }
|
||||
.tooltip-trigger { cursor: help; border-bottom: 1px dashed var(--dark); }
|
||||
.btn.tooltip-trigger { border-bottom: none; }
|
||||
.tooltip-text {
|
||||
visibility: hidden; opacity: 0;
|
||||
min-width: 150px; max-width: 250px;
|
||||
background-color: var(--dark); color: var(--light);
|
||||
text-align: center;
|
||||
border: 2px solid var(--black); padding: 0.75rem;
|
||||
position: absolute;
|
||||
z-index: 1500; /* Increased Z-index significantly */
|
||||
bottom: 130%; left: 50%;
|
||||
transform: translateX(-50%) scale(0.95);
|
||||
transform-origin: bottom center;
|
||||
transition: opacity var(--transition-speed) var(--transition-ease-out),
|
||||
visibility 0s linear var(--transition-speed),
|
||||
transform var(--transition-speed) var(--transition-ease-out);
|
||||
box-shadow: var(--shadow-md);
|
||||
font-size: 0.9rem; line-height: 1.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* NOTE on Tooltip Clipping: Tooltips use position:absolute and high z-index.
|
||||
However, if a PARENT element (like .card, .modal-container, .table-container)
|
||||
has `overflow: hidden` or `overflow: auto/scroll`, the tooltip WILL BE CLIPPED
|
||||
by that parent's boundaries, regardless of z-index.
|
||||
The most reliable fix involves JavaScript to append the tooltip to the document body
|
||||
when shown, removing it from the parent's clipping context.
|
||||
This CSS provides structure but cannot overcome parent overflow clipping. */
|
||||
.tooltip-text::after { content: ""; position: absolute; top: 100%; left: 50%; margin-left: -8px; border-width: 8px; border-style: solid; border-color: var(--dark) transparent transparent transparent; }
|
||||
.tooltip .tooltip-trigger:hover + .tooltip-text,
|
||||
.tooltip .tooltip-trigger:focus-visible + .tooltip-text,
|
||||
.tooltip-text.visible {
|
||||
visibility: visible; opacity: 1;
|
||||
transform: translateX(-50%) scale(1);
|
||||
transition-delay: 0s, 0s, 0s;
|
||||
}
|
||||
|
||||
/* Spinner - Pulsing Dots */
|
||||
.spinner-dots {
|
||||
display: inline-flex; /* Use flex for alignment */
|
||||
align-items: center; /* Vertically center dots */
|
||||
justify-content: center;
|
||||
width: auto; /* Adjust width based on content */
|
||||
height: 50px; /* Match old spinner height or adjust */
|
||||
}
|
||||
.spinner-dots span {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin: 0 4px; /* Spacing between dots */
|
||||
background-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
box-shadow: var(--shadow-sm);
|
||||
animation: pulse-dot 1.2s infinite ease-in-out both;
|
||||
}
|
||||
.spinner-dots span:nth-child(1) { animation-delay: -0.32s; }
|
||||
.spinner-dots span:nth-child(2) { animation-delay: -0.16s; }
|
||||
.spinner-dots span:nth-child(3) { animation-delay: 0s; }
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 80%, 100% { transform: scale(0.8); opacity: 0.5; }
|
||||
40% { transform: scale(1.1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Small spinner dots */
|
||||
.spinner-dots-sm { height: 30px; } /* Adjust height */
|
||||
.spinner-dots-sm span { width: 8px; height: 8px; margin: 0 3px; box-shadow: var(--shadow-sm); }
|
||||
|
||||
.btn .spinner-dots-sm { height: auto; vertical-align: middle; margin: 0 0.5em; } /* Spinner inside button */
|
||||
.btn .spinner-dots-sm span { background-color: currentColor; } /* Match button text color */
|
||||
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
body { padding: 1rem; font-size: 1rem; }
|
||||
.card, .modal-container, .tab-content { padding: 1rem; }
|
||||
.card-header, .card-footer, .modal-header, .modal-footer { padding: 0.75rem 1rem; margin-left: -1rem; margin-right: -1rem; }
|
||||
.card-header { margin-top: -1rem; margin-bottom: 1rem; }
|
||||
.card-footer { margin-bottom: -1rem; margin-top: 1rem; }
|
||||
.modal-header { margin-top: -1rem; margin-bottom: 1rem; }
|
||||
.modal-footer { margin-bottom: -1rem; margin-top: 1rem; }
|
||||
.modal-footer .btn { margin: 0.25rem; }
|
||||
|
||||
/* Tabs on mobile */
|
||||
.tab-list { margin-bottom: -3px; /* Adjust for border width */ }
|
||||
.tab-item { flex-grow: 1; margin-right: 0; text-align: center; border-left: none; border-right: none; border-bottom-width: var(--border-width); }
|
||||
.tab-item:not(:last-child) { border-right: var(--border); }
|
||||
.tab-item[aria-selected="true"] { border-bottom-color: var(--primary); margin-bottom: 0; /* No longer needs negative margin */ z-index: 2; }
|
||||
.tab-content { margin-top: -3px; }
|
||||
|
||||
/* List items on mobile */
|
||||
.list-item-content { flex-direction: column; align-items: flex-start; }
|
||||
.list-item-main { margin-right: 0; width: 100%; margin-bottom: 0.5rem;}
|
||||
.list-item-details { margin-left: 0; margin-top: 0.5rem; flex-wrap: wrap; /* Allow badges/avatar wrap on mobile */ }
|
||||
.list-item-details > * { margin-left: 0; margin-right: 0.5rem; margin-bottom: 0.25rem; } /* Adjust spacing for wrap */
|
||||
.list-item-actions { /* Non-swipe actions */
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.5rem;
|
||||
padding-left: 0;
|
||||
}
|
||||
/* Adjust swipe reveal distance if needed for mobile */
|
||||
.list-item.is-swiped .list-item-content { transform: translateX(-120px); } /* Example smaller distance */
|
||||
|
||||
|
||||
.table { min-width: unset; }
|
||||
.table thead th { top: -1px; } .table tfoot td { bottom: -1px; }
|
||||
.table th, .table td { padding: 0.6rem 0.5rem; font-size: 0.9rem; white-space: normal; }
|
||||
|
||||
.flex-layout-stack-mobile { flex-direction: column; }
|
||||
.flex-layout-stack-mobile > .card { width: 100% !important; margin: 0 0 1.5rem 0 !important; flex-basis: auto !important; }
|
||||
.form-row-wrap-mobile { flex-direction: column; }
|
||||
.form-row-wrap-mobile > .form-group { margin-right: 0 !important; width: 100%; }
|
||||
.form-row-wrap-mobile > .form-group:not(:last-child) { margin-bottom: 1.5rem; }
|
||||
}
|
21
fe/src/assets/variables.scss
Normal file
21
fe/src/assets/variables.scss
Normal file
@ -0,0 +1,21 @@
|
||||
// src/assets/variables.scss
|
||||
:root {
|
||||
--primary-color: #1976D2;
|
||||
--secondary-color: #26A69A;
|
||||
--accent-color: #9C27B0;
|
||||
|
||||
--dark-color: #1D1D1D;
|
||||
--dark-page-color: #121212;
|
||||
|
||||
--positive-color: #21BA45;
|
||||
--negative-color: #C10015;
|
||||
--info-color: #31CCEC;
|
||||
--warning-color: #F2C037;
|
||||
|
||||
--text-color: #333;
|
||||
--bg-color: #fff;
|
||||
--bg-color-page: #f0f2f5;
|
||||
|
||||
--header-height: 56px;
|
||||
--footer-height: 56px;
|
||||
}
|
@ -1,72 +0,0 @@
|
||||
import { boot } from 'quasar/wrappers';
|
||||
import axios from 'axios';
|
||||
import { API_BASE_URL } from 'src/config/api-config';
|
||||
|
||||
// Create axios instance
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(new Error(String(error)));
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// If error is 401 and we haven't tried to refresh token yet
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
// Call refresh token endpoint
|
||||
const response = await api.post('/api/v1/auth/refresh-token', {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
const { access_token } = response.data;
|
||||
localStorage.setItem('token', access_token);
|
||||
|
||||
// Retry the original request with new token
|
||||
originalRequest.headers.Authorization = `Bearer ${access_token}`;
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
// If refresh token fails, clear storage and redirect to login
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('refreshToken');
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(new Error(String(refreshError)));
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(String(error)));
|
||||
}
|
||||
);
|
||||
|
||||
export default boot(({ app }) => {
|
||||
app.config.globalProperties.$axios = axios;
|
||||
app.config.globalProperties.$api = api;
|
||||
});
|
||||
|
||||
export { api };
|
@ -1,33 +0,0 @@
|
||||
import { defineBoot } from '#q-app/wrappers';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
|
||||
import messages from 'src/i18n';
|
||||
|
||||
export type MessageLanguages = keyof typeof messages;
|
||||
// Type-define 'en-US' as the master schema for the resource
|
||||
export type MessageSchema = typeof messages['en-US'];
|
||||
|
||||
// See https://vue-i18n.intlify.dev/guide/advanced/typescript.html#global-resource-schema-type-definition
|
||||
/* eslint-disable @typescript-eslint/no-empty-object-type */
|
||||
declare module 'vue-i18n' {
|
||||
// define the locale messages schema
|
||||
export interface DefineLocaleMessage extends MessageSchema {}
|
||||
|
||||
// define the datetime format schema
|
||||
export interface DefineDateTimeFormat {}
|
||||
|
||||
// define the number format schema
|
||||
export interface DefineNumberFormat {}
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-empty-object-type */
|
||||
|
||||
export default defineBoot(({ app }) => {
|
||||
const i18n = createI18n<{ message: MessageSchema }, MessageLanguages>({
|
||||
locale: 'en-US',
|
||||
legacy: false,
|
||||
messages,
|
||||
});
|
||||
|
||||
// Set i18n instance on app
|
||||
app.use(i18n);
|
||||
});
|
@ -1,174 +1,171 @@
|
||||
<template>
|
||||
<q-dialog v-model="show" persistent>
|
||||
<q-card style="min-width: 600px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Conflict Resolution</div>
|
||||
<div class="text-subtitle2 q-mt-sm">
|
||||
<div v-if="show" class="modal-backdrop open" @click.self="closeDialog">
|
||||
<div class="modal-container" style="min-width: 600px" ref="modalContentRef" role="dialog" aria-modal="true" aria-labelledby="conflictDialogTitle">
|
||||
<div class="modal-header">
|
||||
<h3 id="conflictDialogTitle">Conflict Resolution</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-2">
|
||||
This item was modified while you were offline. Please review the changes and choose how to resolve the conflict.
|
||||
</div>
|
||||
</q-card-section>
|
||||
</p>
|
||||
|
||||
<q-card-section class="q-pt-none">
|
||||
<q-tabs
|
||||
v-model="activeTab"
|
||||
class="text-primary"
|
||||
active-color="primary"
|
||||
indicator-color="primary"
|
||||
align="justify"
|
||||
narrow-indicator
|
||||
<div class="tabs">
|
||||
<ul class="tab-list" role="tablist" aria-label="Conflict Resolution Options">
|
||||
<li
|
||||
class="tab-item"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === 'compare'"
|
||||
:tabindex="activeTab === 'compare' ? 0 : -1"
|
||||
@click="activeTab = 'compare'"
|
||||
@keydown.enter.space="activeTab = 'compare'"
|
||||
>
|
||||
<q-tab name="compare" label="Compare Versions" />
|
||||
<q-tab name="merge" label="Merge Changes" />
|
||||
</q-tabs>
|
||||
Compare Versions
|
||||
</li>
|
||||
<li
|
||||
class="tab-item"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === 'merge'"
|
||||
:tabindex="activeTab === 'merge' ? 0 : -1"
|
||||
@click="activeTab = 'merge'"
|
||||
@keydown.enter.space="activeTab = 'merge'"
|
||||
>
|
||||
Merge Changes
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<q-tab-panels v-model="activeTab" animated>
|
||||
<!-- Compare Versions Tab -->
|
||||
<q-tab-panel name="compare">
|
||||
<div class="row q-col-gutter-md">
|
||||
<div v-if="activeTab === 'compare'" class="tab-content" role="tabpanel" aria-labelledby="tab-compare">
|
||||
<div class="flex" style="gap: 1rem;">
|
||||
<!-- Local Version -->
|
||||
<div class="col-6">
|
||||
<q-card flat bordered>
|
||||
<q-card-section>
|
||||
<div class="text-subtitle1">Your Version</div>
|
||||
<div class="text-caption">
|
||||
Last modified: {{ formatDate(conflictData?.localVersion.timestamp ?? 0) }}
|
||||
<div class="card flex-grow" style="width: 50%;">
|
||||
<div class="card-header">
|
||||
<h4>Your Version</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-caption mb-1">
|
||||
Last modified: {{ formatDate(conflictData?.localVersion.timestamp ?? 0) }}
|
||||
</p>
|
||||
<ul class="item-list simple-list">
|
||||
<li v-for="(value, key) in conflictData?.localVersion.data" :key="key" class="list-item-simple">
|
||||
<strong class="text-caption-strong">{{ formatKey(key) }}</strong>
|
||||
<span :class="{ 'text-positive-inline': isDifferent(key as string) }">{{ formatValue(value) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pt-none">
|
||||
<q-list>
|
||||
<q-item v-for="(value, key) in conflictData?.localVersion.data" :key="key">
|
||||
<q-item-section>
|
||||
<q-item-label class="text-caption text-grey">
|
||||
{{ formatKey(key) }}
|
||||
</q-item-label>
|
||||
<q-item-label :class="{ 'text-positive': isDifferent(key) }">
|
||||
{{ formatValue(value) }}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<!-- Server Version -->
|
||||
<div class="col-6">
|
||||
<q-card flat bordered>
|
||||
<q-card-section>
|
||||
<div class="text-subtitle1">Server Version</div>
|
||||
<div class="text-caption">
|
||||
<div class="card flex-grow" style="width: 50%;">
|
||||
<div class="card-header">
|
||||
<h4>Server Version</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-caption mb-1">
|
||||
Last modified: {{ formatDate(conflictData?.serverVersion.timestamp ?? 0) }}
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pt-none">
|
||||
<q-list>
|
||||
<q-item v-for="(value, key) in conflictData?.serverVersion.data" :key="key">
|
||||
<q-item-section>
|
||||
<q-item-label class="text-caption text-grey">
|
||||
{{ formatKey(key) }}
|
||||
</q-item-label>
|
||||
<q-item-label :class="{ 'text-positive': isDifferent(key) }">
|
||||
{{ formatValue(value) }}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</p>
|
||||
<ul class="item-list simple-list">
|
||||
<li v-for="(value, key) in conflictData?.serverVersion.data" :key="key" class="list-item-simple">
|
||||
<strong class="text-caption-strong">{{ formatKey(key) }}</strong>
|
||||
<span :class="{ 'text-positive-inline': isDifferent(key as string) }">{{ formatValue(value) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
|
||||
<!-- Merge Changes Tab -->
|
||||
<q-tab-panel name="merge">
|
||||
<q-card flat bordered>
|
||||
<q-card-section>
|
||||
<div class="text-subtitle1">Merge Changes</div>
|
||||
<div class="text-caption">
|
||||
Select which version to keep for each field
|
||||
<div v-if="activeTab === 'merge'" class="tab-content" role="tabpanel" aria-labelledby="tab-merge">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4>Merge Changes</h4>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pt-none">
|
||||
<q-list>
|
||||
<q-item v-for="(value, key) in conflictData?.localVersion.data" :key="key">
|
||||
<q-item-section>
|
||||
<q-item-label class="text-caption text-grey">
|
||||
{{ formatKey(key) }}
|
||||
</q-item-label>
|
||||
<div class="row q-col-gutter-sm q-mt-xs">
|
||||
<div class="col">
|
||||
<q-radio
|
||||
v-model="mergeChoices[key]"
|
||||
val="local"
|
||||
label="Your Version"
|
||||
/>
|
||||
<div class="text-caption">
|
||||
{{ formatValue(value) }}
|
||||
<div class="card-body">
|
||||
<p class="text-caption mb-2">Select which version to keep for each field.</p>
|
||||
<ul class="item-list simple-list">
|
||||
<li v-for="(localValue, key) in conflictData?.localVersion.data" :key="key" class="list-item-simple merge-choice-item">
|
||||
<strong class="text-caption-strong">{{ formatKey(key) }}</strong>
|
||||
<div class="flex" style="gap: 1rem; margin-top: 0.5rem;">
|
||||
<div class="radio-group-inline">
|
||||
<label class="radio-label">
|
||||
<input type="radio" :name="String(key)" v-model="mergeChoices[key]" value="local" />
|
||||
<span class="checkmark radio-mark"></span>
|
||||
Your Version: <span class="value-preview">{{ formatValue(localValue) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="radio-group-inline">
|
||||
<label class="radio-label">
|
||||
<input type="radio" :name="String(key)" v-model="mergeChoices[key]" value="server" />
|
||||
<span class="checkmark radio-mark"></span>
|
||||
Server Version: <span class="value-preview">{{ formatValue(conflictData?.serverVersion.data[key]) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-radio
|
||||
v-model="mergeChoices[key]"
|
||||
val="server"
|
||||
label="Server Version"
|
||||
/>
|
||||
<div class="text-caption">
|
||||
{{ formatValue(conflictData?.serverVersion.data[key]) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
v-if="activeTab === 'compare'"
|
||||
flat
|
||||
label="Keep Local Version"
|
||||
color="primary"
|
||||
type="button"
|
||||
class="btn btn-neutral"
|
||||
@click="resolveConflict('local')"
|
||||
/>
|
||||
<q-btn
|
||||
>
|
||||
Keep Local Version
|
||||
</button>
|
||||
<button
|
||||
v-if="activeTab === 'compare'"
|
||||
flat
|
||||
label="Keep Server Version"
|
||||
color="primary"
|
||||
type="button"
|
||||
class="btn btn-neutral ml-2"
|
||||
@click="resolveConflict('server')"
|
||||
/>
|
||||
<q-btn
|
||||
>
|
||||
Keep Server Version
|
||||
</button>
|
||||
<button
|
||||
v-if="activeTab === 'compare'"
|
||||
flat
|
||||
label="Merge Changes"
|
||||
color="primary"
|
||||
type="button"
|
||||
class="btn btn-primary ml-2"
|
||||
@click="activeTab = 'merge'"
|
||||
/>
|
||||
<q-btn
|
||||
>
|
||||
Merge Changes
|
||||
</button>
|
||||
<button
|
||||
v-if="activeTab === 'merge'"
|
||||
flat
|
||||
label="Apply Merged Changes"
|
||||
color="primary"
|
||||
type="button"
|
||||
class="btn btn-primary ml-2"
|
||||
@click="applyMergedChanges"
|
||||
/>
|
||||
<q-btn
|
||||
flat
|
||||
label="Cancel"
|
||||
color="negative"
|
||||
@click="show = false"
|
||||
/>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
>
|
||||
Apply Merged Changes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger ml-2"
|
||||
@click="closeDialog"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import type { OfflineAction } from 'src/stores/offline';
|
||||
import { useVModel, onClickOutside } from '@vueuse/core';
|
||||
// Assuming OfflineAction is defined elsewhere, e.g. in a Pinia store or a types file
|
||||
// For this example, let's define a placeholder if not available from `src/stores/offline`
|
||||
// import type { OfflineAction } from 'src/stores/offline';
|
||||
interface OfflineAction {
|
||||
id: string | number;
|
||||
type: string;
|
||||
payload: unknown;
|
||||
timestamp: number;
|
||||
// other potential fields
|
||||
}
|
||||
|
||||
|
||||
interface ConflictData {
|
||||
localVersion: {
|
||||
@ -179,7 +176,7 @@ interface ConflictData {
|
||||
data: Record<string, unknown>;
|
||||
timestamp: number;
|
||||
};
|
||||
action: OfflineAction;
|
||||
action: OfflineAction; // Assuming OfflineAction is defined
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@ -192,47 +189,53 @@ const emit = defineEmits<{
|
||||
(e: 'resolve', resolution: { version: 'local' | 'server' | 'merge'; action: OfflineAction; mergedData?: Record<string, unknown> }): void;
|
||||
}>();
|
||||
|
||||
const show = ref(props.modelValue);
|
||||
const show = useVModel(props, 'modelValue', emit);
|
||||
const activeTab = ref('compare');
|
||||
const mergeChoices = ref<Record<string, 'local' | 'server'>>({});
|
||||
const modalContentRef = ref<HTMLElement | null>(null);
|
||||
|
||||
// Watch for changes in modelValue
|
||||
watch(() => props.modelValue, (newValue: boolean) => {
|
||||
show.value = newValue;
|
||||
onClickOutside(modalContentRef, () => {
|
||||
if (show.value) {
|
||||
// Potentially ask for confirmation before closing or just close
|
||||
// closeDialog();
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for changes in show
|
||||
watch(show, (newValue: boolean) => {
|
||||
emit('update:modelValue', newValue);
|
||||
});
|
||||
const closeDialog = () => {
|
||||
show.value = false;
|
||||
};
|
||||
|
||||
// Initialize merge choices when conflict data changes
|
||||
watch(() => props.conflictData, (newData) => {
|
||||
if (newData) {
|
||||
const choices: Record<string, 'local' | 'server'> = {};
|
||||
Object.keys(newData.localVersion.data).forEach(key => {
|
||||
choices[key] = isDifferent(key) ? 'local' : 'local';
|
||||
// Default to local, or server if local is undefined/null but server is not
|
||||
if (isDifferent(key)) {
|
||||
choices[key] = newData.localVersion.data[key] !== undefined ? 'local' : 'server';
|
||||
} else {
|
||||
choices[key] = 'local';
|
||||
}
|
||||
});
|
||||
mergeChoices.value = choices;
|
||||
activeTab.value = 'compare'; // Reset to compare tab
|
||||
}
|
||||
}, { immediate: true });
|
||||
}, { immediate: true, deep: true });
|
||||
|
||||
const formatDate = (timestamp: number): string => {
|
||||
if (timestamp === 0) return 'N/A';
|
||||
return new Date(timestamp).toLocaleString();
|
||||
};
|
||||
|
||||
const formatKey = (key: string): string => {
|
||||
return key
|
||||
.split(/(?=[A-Z])/)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.replace(/^\w/, (c) => c.toUpperCase());
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/^./, (str) => str.toUpperCase());
|
||||
};
|
||||
|
||||
const formatValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return '-';
|
||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
if (typeof value === 'object') return JSON.stringify(value, null, 2); // Pretty print object
|
||||
if (typeof value === 'number' || typeof value === 'string') return String(value);
|
||||
return '[Unsupported Type]';
|
||||
};
|
||||
@ -244,15 +247,13 @@ const isDifferent = (key: string): boolean => {
|
||||
return JSON.stringify(localValue) !== JSON.stringify(serverValue);
|
||||
};
|
||||
|
||||
const resolveConflict = (version: 'local' | 'server' | 'merge'): void => {
|
||||
const resolveConflict = (version: 'local' | 'server'): void => {
|
||||
if (!props.conflictData) return;
|
||||
|
||||
emit('resolve', {
|
||||
version,
|
||||
action: props.conflictData.action
|
||||
action: props.conflictData.action,
|
||||
});
|
||||
|
||||
show.value = false;
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const applyMergedChanges = (): void => {
|
||||
@ -260,30 +261,79 @@ const applyMergedChanges = (): void => {
|
||||
|
||||
const mergedData: Record<string, unknown> = {};
|
||||
Object.entries(mergeChoices.value).forEach(([key, choice]) => {
|
||||
const localValue = props.conflictData?.localVersion.data[key];
|
||||
const serverValue = props.conflictData?.serverVersion.data[key];
|
||||
mergedData[key] = choice === 'local' ? localValue : serverValue;
|
||||
mergedData[key] = choice === 'local'
|
||||
? props.conflictData!.localVersion.data[key]
|
||||
: props.conflictData!.serverVersion.data[key];
|
||||
});
|
||||
|
||||
emit('resolve', {
|
||||
version: 'merge',
|
||||
action: props.conflictData.action,
|
||||
mergedData
|
||||
mergedData,
|
||||
});
|
||||
|
||||
show.value = false;
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.q-card {
|
||||
<style scoped>
|
||||
.text-caption {
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--dark);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.text-caption-strong {
|
||||
font-size: 0.9rem;
|
||||
color: var(--dark);
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.text-positive {
|
||||
color: $positive;
|
||||
font-weight: 500;
|
||||
.text-positive-inline {
|
||||
color: var(--success); /* Assuming --success is greenish */
|
||||
font-weight: bold;
|
||||
background-color: #e6ffed; /* Light green background for highlight */
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.simple-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.list-item-simple {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.list-item-simple:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.merge-choice-item .radio-group-inline {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.merge-choice-item .radio-label {
|
||||
align-items: flex-start; /* Better alignment for multi-line content */
|
||||
}
|
||||
.value-preview {
|
||||
font-style: italic;
|
||||
color: #555;
|
||||
margin-left: 0.5em;
|
||||
display: inline-block;
|
||||
max-width: 200px; /* Adjust as needed */
|
||||
white-space: pre-wrap; /* Show formatted JSON */
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.mb-1 { margin-bottom: 0.5rem; }
|
||||
.mb-2 { margin-bottom: 1rem; }
|
||||
|
||||
.flex-grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
</style>
|
@ -1,47 +1,64 @@
|
||||
<template>
|
||||
<q-dialog v-model="isOpen" persistent>
|
||||
<q-card style="min-width: 350px">
|
||||
<q-card-section class="row items-center">
|
||||
<div class="text-h6">Create New List</div>
|
||||
<q-space />
|
||||
<q-btn icon="close" flat round dense v-close-popup />
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<q-form @submit="onSubmit" class="q-gutter-md">
|
||||
<q-input
|
||||
v-model="listName"
|
||||
label="List Name"
|
||||
:rules="[(val) => !!val || 'Name is required']"
|
||||
outlined
|
||||
/>
|
||||
|
||||
<q-input v-model="description" label="Description" type="textarea" outlined />
|
||||
|
||||
<q-select
|
||||
v-model="selectedGroup"
|
||||
:options="groups"
|
||||
label="Associate with Group (Optional)"
|
||||
outlined
|
||||
clearable
|
||||
/>
|
||||
|
||||
<div class="row justify-end q-mt-md">
|
||||
<q-btn label="Cancel" color="grey" flat v-close-popup />
|
||||
<q-btn label="Create" type="submit" color="primary" class="q-ml-sm" />
|
||||
<div v-if="isOpen" class="modal-backdrop open" @click.self="closeModal">
|
||||
<div class="modal-container" role="dialog" aria-modal="true" aria-labelledby="createListModalTitle">
|
||||
<div class="modal-header">
|
||||
<h3 id="createListModalTitle">Create New List</h3>
|
||||
<button class="close-button" @click="closeModal" aria-label="Close modal">
|
||||
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-close" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="listName" class="form-label">List Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="listName"
|
||||
v-model="listName"
|
||||
class="form-input"
|
||||
required
|
||||
ref="listNameInput"
|
||||
/>
|
||||
<p v-if="formErrors.listName" class="form-error-text">{{ formErrors.listName }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" class="form-label">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="description"
|
||||
class="form-input"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group" v-if="groups && groups.length > 0">
|
||||
<label for="selectedGroup" class="form-label">Associate with Group (Optional)</label>
|
||||
<select id="selectedGroup" v-model="selectedGroupId" class="form-input">
|
||||
<option :value="null">None</option>
|
||||
<option v-for="group in groups" :key="group.value" :value="group.value">
|
||||
{{ group.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-neutral" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary ml-2" :disabled="loading">
|
||||
<span v-if="loading" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { useQuasar } from 'quasar';
|
||||
import { apiClient, API_ENDPOINTS } from 'src/config/api';
|
||||
|
||||
const $q = useQuasar();
|
||||
import { ref, watch, nextTick } from 'vue';
|
||||
import { useVModel, onClickOutside } from '@vueuse/core';
|
||||
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming this path is correct
|
||||
import { useNotificationStore } from '@/stores/notifications';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
@ -53,50 +70,81 @@ const emit = defineEmits<{
|
||||
(e: 'created'): void;
|
||||
}>();
|
||||
|
||||
const isOpen = ref(props.modelValue);
|
||||
const isOpen = useVModel(props, 'modelValue', emit);
|
||||
const listName = ref('');
|
||||
const description = ref('');
|
||||
const selectedGroup = ref<{ label: string; value: number } | null>(null);
|
||||
const selectedGroupId = ref<number | null>(null); // Store only the ID
|
||||
const loading = ref(false);
|
||||
const formErrors = ref<{ listName?: string }>({});
|
||||
const notificationStore = useNotificationStore();
|
||||
|
||||
// Watch for modelValue changes
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
isOpen.value = newVal;
|
||||
},
|
||||
);
|
||||
const listNameInput = ref<HTMLInputElement | null>(null);
|
||||
const modalContainerRef = ref<HTMLElement | null>(null); // For onClickOutside
|
||||
|
||||
// Watch for isOpen changes
|
||||
watch(isOpen, (newVal) => {
|
||||
emit('update:modelValue', newVal);
|
||||
if (newVal) {
|
||||
// Reset form when opening
|
||||
listName.value = '';
|
||||
description.value = '';
|
||||
selectedGroupId.value = null;
|
||||
formErrors.value = {};
|
||||
nextTick(() => {
|
||||
listNameInput.value?.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onClickOutside(modalContainerRef, () => {
|
||||
if (isOpen.value) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
const closeModal = () => {
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
formErrors.value = {};
|
||||
if (!listName.value.trim()) {
|
||||
formErrors.value.listName = 'Name is required';
|
||||
}
|
||||
return Object.keys(formErrors.value).length === 0;
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
await apiClient.post(API_ENDPOINTS.LISTS.BASE, {
|
||||
name: listName.value,
|
||||
description: description.value,
|
||||
group_id: selectedGroup.value?.value,
|
||||
group_id: selectedGroupId.value,
|
||||
});
|
||||
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'List created successfully',
|
||||
});
|
||||
notificationStore.addNotification({ message: 'List created successfully', type: 'success' });
|
||||
|
||||
// Reset form
|
||||
listName.value = '';
|
||||
description.value = '';
|
||||
selectedGroup.value = null;
|
||||
|
||||
// Close modal and emit created event
|
||||
isOpen.value = false;
|
||||
emit('created');
|
||||
closeModal();
|
||||
} catch (error: unknown) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error instanceof Error ? error.message : 'Failed to create list',
|
||||
});
|
||||
const message = error instanceof Error ? error.message : 'Failed to create list';
|
||||
notificationStore.addNotification({ message, type: 'error' });
|
||||
console.error(message, error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-error-text {
|
||||
color: var(--danger);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.ml-2 {
|
||||
margin-left: 0.5rem; /* from Valerie UI utilities */
|
||||
}
|
||||
</style>
|
@ -1,35 +1,74 @@
|
||||
<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>{{ title }}</q-item-label>
|
||||
<q-item-label caption>{{ caption }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<li>
|
||||
<a :href="link" :target="isExternalLink ? '_blank' : undefined" class="list-item-link">
|
||||
<svg v-if="icon" class="icon" aria-hidden="true"><use :xlink:href="`#icon-${icon}`" /></svg>
|
||||
<span class="link-content">
|
||||
<span class="link-title">{{ title }}</span>
|
||||
<span v-if="caption" class="link-caption">{{ caption }}</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
export interface EssentialLinkProps {
|
||||
title: string;
|
||||
caption?: string;
|
||||
link?: string;
|
||||
icon?: string;
|
||||
};
|
||||
}
|
||||
|
||||
withDefaults(defineProps<EssentialLinkProps>(), {
|
||||
const props = withDefaults(defineProps<EssentialLinkProps>(), {
|
||||
caption: '',
|
||||
link: '#',
|
||||
icon: '',
|
||||
});
|
||||
|
||||
const isExternalLink = computed(() => {
|
||||
return props.link?.startsWith('http') || props.link?.startsWith('//');
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Basic styling for EssentialLink as a list item link */
|
||||
.list-item-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
text-decoration: none;
|
||||
color: var(--dark);
|
||||
border: var(--border-width) var(--border-style) transparent; /* For focus indication */
|
||||
transition: background-color var(--transition-speed) var(--transition-ease-out);
|
||||
}
|
||||
|
||||
.list-item-link:hover,
|
||||
.list-item-link:focus-visible {
|
||||
background-color: rgba(0,0,0,0.05);
|
||||
border-color: var(--primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.list-item-link .icon {
|
||||
margin-right: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
}
|
||||
|
||||
.link-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.link-title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.link-caption {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
</style>
|
@ -1,131 +1,142 @@
|
||||
<template>
|
||||
<q-banner
|
||||
<div>
|
||||
<div
|
||||
v-if="!isOnline || hasPendingActions"
|
||||
:class="[
|
||||
'offline-indicator',
|
||||
{ 'offline': !isOnline },
|
||||
{ 'pending': hasPendingActions }
|
||||
]"
|
||||
rounded
|
||||
class="alert offline-indicator"
|
||||
:class="{
|
||||
'alert-error': !isOnline,
|
||||
'alert-warning': isOnline && hasPendingActions
|
||||
}"
|
||||
role="status"
|
||||
>
|
||||
<template v-slot:avatar>
|
||||
<q-icon
|
||||
:name="!isOnline ? 'wifi_off' : 'sync'"
|
||||
:color="!isOnline ? 'negative' : 'warning'"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="!isOnline">
|
||||
<div class="alert-content">
|
||||
<svg class="icon" aria-hidden="true">
|
||||
<use :xlink:href="!isOnline ? '#icon-alert-triangle' : '#icon-info'" />
|
||||
<!-- Placeholder icons, wifi_off and sync are not in Valerie UI default -->
|
||||
</svg>
|
||||
<span v-if="!isOnline">
|
||||
You are currently offline. Changes will be saved locally.
|
||||
</template>
|
||||
<template v-else>
|
||||
</span>
|
||||
<span v-else>
|
||||
Syncing {{ pendingActionCount }} pending {{ pendingActionCount === 1 ? 'change' : 'changes' }}...
|
||||
</template>
|
||||
|
||||
<template v-slot:action>
|
||||
<q-btn
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="hasPendingActions"
|
||||
flat
|
||||
color="primary"
|
||||
label="View Changes"
|
||||
@click="showPendingActions = true"
|
||||
/>
|
||||
</template>
|
||||
</q-banner>
|
||||
class="btn btn-sm btn-neutral"
|
||||
@click="showPendingActionsModal = true"
|
||||
>
|
||||
View Changes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="showPendingActions">
|
||||
<q-card style="min-width: 350px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Pending Changes</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="q-pt-none">
|
||||
<q-list>
|
||||
<q-item v-for="action in pendingActions" :key="action.id">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
{{ getActionLabel(action) }}
|
||||
</q-item-label>
|
||||
<q-item-label caption>
|
||||
{{ new Date(action.timestamp).toLocaleString() }}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Close" color="primary" v-close-popup />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<div v-if="showPendingActionsModal" class="modal-backdrop open" @click.self="showPendingActionsModal = false">
|
||||
<div class="modal-container" ref="pendingActionsModalRef" role="dialog" aria-modal="true" aria-labelledby="pendingActionsTitle">
|
||||
<div class="modal-header">
|
||||
<h3 id="pendingActionsTitle">Pending Changes</h3>
|
||||
<button class="close-button" @click="showPendingActionsModal = false" aria-label="Close">
|
||||
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-close" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ul v-if="pendingActions.length" class="item-list">
|
||||
<li v-for="action in pendingActions" :key="action.id" class="list-item">
|
||||
<div class="list-item-content" style="flex-direction: column; align-items: flex-start;">
|
||||
<span class="item-text">{{ getActionLabel(action) }}</span>
|
||||
<small class="text-caption">{{ new Date(action.timestamp).toLocaleString() }}</small>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else>No pending changes.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" @click="showPendingActionsModal = false">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conflict Resolution Dialog -->
|
||||
<ConflictResolutionDialog
|
||||
v-model="showConflictDialog"
|
||||
:conflict-data="currentConflict"
|
||||
@resolve="handleConflictResolution"
|
||||
v-model="offlineStore.showConflictDialog"
|
||||
:conflict-data="offlineStore.currentConflict"
|
||||
@resolve="offlineStore.handleConflictResolution"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useOfflineStore } from 'src/stores/offline';
|
||||
import type { OfflineAction } from 'src/stores/offline';
|
||||
import ConflictResolutionDialog from './ConflictResolutionDialog.vue';
|
||||
import { useNetwork, onClickOutside } from '@vueuse/core';
|
||||
import { useOfflineStore } from '@/stores/offline'; // Assuming path
|
||||
import type { OfflineAction } from '@/stores/offline'; // Assuming path
|
||||
import ConflictResolutionDialog from '@/components/ConflictResolutionDialog.vue';
|
||||
|
||||
const offlineStore = useOfflineStore();
|
||||
const showPendingActions = ref(false);
|
||||
const showPendingActionsModal = ref(false);
|
||||
const pendingActionsModalRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const { isOnline } = useNetwork(); // VueUse composable for network status
|
||||
|
||||
// Expose parts of the store directly, this pattern is fine with Pinia
|
||||
const {
|
||||
isOnline,
|
||||
pendingActions,
|
||||
hasPendingActions,
|
||||
pendingActionCount,
|
||||
showConflictDialog,
|
||||
currentConflict,
|
||||
handleConflictResolution,
|
||||
// showConflictDialog, // Handled by offlineStore.showConflictDialog
|
||||
// currentConflict, // Handled by offlineStore.currentConflict
|
||||
// handleConflictResolution // Handled by offlineStore.handleConflictResolution
|
||||
} = offlineStore;
|
||||
|
||||
|
||||
onClickOutside(pendingActionsModalRef, () => {
|
||||
showPendingActionsModal.value = false;
|
||||
});
|
||||
|
||||
const getActionLabel = (action: OfflineAction) => {
|
||||
// This is a simplified version of your original getActionLabel
|
||||
// You might need to adjust based on the actual structure of action.data
|
||||
const data = action.payload as { title?: string; name?: string; [key: string]: unknown };
|
||||
const itemTitle = data.title || data.name || (typeof data === 'string' ? data : 'Untitled Item');
|
||||
|
||||
switch (action.type) {
|
||||
case 'add': {
|
||||
const data = action.data as { title?: string };
|
||||
return `Add new item: ${data.title || 'Untitled'}`;
|
||||
}
|
||||
case 'complete': {
|
||||
const data = action.data as { title?: string };
|
||||
return `Complete item: ${data.title || 'Untitled'}`;
|
||||
}
|
||||
case 'update': {
|
||||
const data = action.data as { title?: string };
|
||||
return `Update item: ${data.title || 'Untitled'}`;
|
||||
}
|
||||
case 'delete': {
|
||||
const data = action.data as { title?: string };
|
||||
return `Delete item: ${data.title || 'Untitled'}`;
|
||||
}
|
||||
case 'add':
|
||||
case 'create': // Common alias
|
||||
return `Add: ${itemTitle}`;
|
||||
case 'complete':
|
||||
return `Complete: ${itemTitle}`;
|
||||
case 'update':
|
||||
return `Update: ${itemTitle}`;
|
||||
case 'delete':
|
||||
return `Delete: ${itemTitle}`;
|
||||
default:
|
||||
return 'Unknown action';
|
||||
return `Unknown action: ${action.type} for ${itemTitle}`;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
<style scoped>
|
||||
.offline-indicator {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 1000;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
|
||||
&.offline {
|
||||
background-color: #ffebee;
|
||||
/* Valerie UI .alert already has box-shadow */
|
||||
}
|
||||
|
||||
&.pending {
|
||||
background-color: #fff3e0;
|
||||
/* Styles for text-caption if not globally available enough */
|
||||
.text-caption {
|
||||
font-size: 0.85rem;
|
||||
color: var(--dark);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Simplified list item for pending actions modal */
|
||||
.item-list .list-item .list-item-content {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
.item-list .list-item .item-text {
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
</style>
|
141
fe/src/components/global/NotificationDisplay.vue
Normal file
141
fe/src/components/global/NotificationDisplay.vue
Normal file
@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div class="notification-container">
|
||||
<transition-group name="notification-list" tag="div">
|
||||
<div
|
||||
v-for="notification in store.notifications"
|
||||
:key="notification.id"
|
||||
:class="['notification-item', `notification-${notification.type}`]"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<div class="notification-content">
|
||||
<span class="notification-icon" v-if="getIcon(notification.type)">
|
||||
<!-- Basic SVG Icons - replace with your preferred icon set or library -->
|
||||
<svg v-if="notification.type === 'success'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
|
||||
<svg v-if="notification.type === 'error'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>
|
||||
<svg v-if="notification.type === 'warning'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/></svg>
|
||||
<svg v-if="notification.type === 'info'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
|
||||
</span>
|
||||
<span class="notification-message">{{ notification.message }}</span>
|
||||
</div>
|
||||
<button
|
||||
@click="store.removeNotification(notification.id)"
|
||||
class="notification-close-button"
|
||||
aria-label="Close notification"
|
||||
>
|
||||
× <!-- Simple 'x' close button -->
|
||||
</button>
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useNotificationStore, type Notification } from '@/stores/notifications';
|
||||
|
||||
const store = useNotificationStore();
|
||||
|
||||
const getIcon = (type: Notification['type']) => {
|
||||
// You can extend this or use a more sophisticated icon system
|
||||
const icons = {
|
||||
success: 'check_circle', // Material icon names or SVG paths
|
||||
error: 'error',
|
||||
warning: 'warning',
|
||||
info: 'info',
|
||||
};
|
||||
return icons[type]; // For the SVG example, we just check the type
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.notification-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
width: 320px; // Or max-width
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px; // Spacing between notifications
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
background-color: #fff;
|
||||
color: #333;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-left-width: 5px;
|
||||
border-left-style: solid;
|
||||
overflow: hidden; // For smooth animation
|
||||
|
||||
&.notification-success {
|
||||
border-left-color: var(--positive-color, #4caf50);
|
||||
.notification-icon { color: var(--positive-color, #4caf50); }
|
||||
}
|
||||
&.notification-error {
|
||||
border-left-color: var(--negative-color, #f44336);
|
||||
.notification-icon { color: var(--negative-color, #f44336); }
|
||||
}
|
||||
&.notification-warning {
|
||||
border-left-color: var(--warning-color, #ff9800);
|
||||
.notification-icon { color: var(--warning-color, #ff9800); }
|
||||
}
|
||||
&.notification-info {
|
||||
border-left-color: var(--info-color, #2196f3);
|
||||
.notification-icon { color: var(--info-color, #2196f3); }
|
||||
}
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
margin-right: 10px;
|
||||
display: flex; // To align SVG properly
|
||||
align-items: center;
|
||||
// SVGs inside will inherit the color from the parent rule
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.notification-close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #aaa;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 0 0 10px; // Add some space to the left
|
||||
margin-left: auto; // Pushes it to the right within flex
|
||||
|
||||
&:hover {
|
||||
color: #777;
|
||||
}
|
||||
}
|
||||
|
||||
// Transitions for the list
|
||||
.notification-list-enter-active,
|
||||
.notification-list-leave-active {
|
||||
transition: all 0.4s cubic-bezier(0.68, -0.55, 0.27, 1.55); // A bit bouncy
|
||||
}
|
||||
.notification-list-enter-from,
|
||||
.notification-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%); // Slide in from right
|
||||
}
|
||||
.notification-list-leave-active {
|
||||
position: absolute; // Important for smooth leave transitions in a list
|
||||
width: calc(100% - 32px); // Adjust based on padding if item width is not fixed
|
||||
}
|
||||
</style>
|
@ -1,4 +1,4 @@
|
||||
import { api } from 'boot/axios';
|
||||
import { api } from '@/services/api';
|
||||
import { API_BASE_URL, API_VERSION, API_ENDPOINTS } from './api-config';
|
||||
|
||||
// Helper function to get full API URL
|
||||
|
@ -1 +0,0 @@
|
||||
// app global css in SCSS form
|
@ -1,25 +0,0 @@
|
||||
// Quasar SCSS (& Sass) Variables
|
||||
// --------------------------------------------------
|
||||
// To customize the look and feel of this app, you can override
|
||||
// the Sass/SCSS variables found in Quasar's source Sass/SCSS files.
|
||||
|
||||
// Check documentation for full list of Quasar variables
|
||||
|
||||
// Your own variables (that are declared here) and Quasar's own
|
||||
// ones will be available out of the box in your .vue/.scss/.sass files
|
||||
|
||||
// It's highly recommended to change the default colors
|
||||
// to match your app's branding.
|
||||
// Tip: Use the "Theme Builder" on Quasar's documentation website.
|
||||
|
||||
$primary : #1976D2;
|
||||
$secondary : #26A69A;
|
||||
$accent : #9C27B0;
|
||||
|
||||
$dark : #1D1D1D;
|
||||
$dark-page : #121212;
|
||||
|
||||
$positive : #21BA45;
|
||||
$negative : #C10015;
|
||||
$info : #31CCEC;
|
||||
$warning : #F2C037;
|
7
fe/src/env.d.ts
vendored
7
fe/src/env.d.ts
vendored
@ -1,7 +0,0 @@
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
NODE_ENV: string;
|
||||
VUE_ROUTER_MODE: 'hash' | 'history' | 'abstract' | undefined;
|
||||
VUE_ROUTER_BASE: string | undefined;
|
||||
}
|
||||
}
|
@ -1,11 +1,28 @@
|
||||
// src/layouts/AuthLayout.vue
|
||||
<template>
|
||||
<q-layout view="hHh lpR fFf">
|
||||
<q-page-container>
|
||||
<div class="auth-layout">
|
||||
<main class="auth-page-container">
|
||||
<router-view />
|
||||
</q-page-container>
|
||||
</q-layout>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// No additional setup needed for this layout
|
||||
// No specific logic for AuthLayout
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.auth-layout {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background-color: var(--bg-color-page, #f0f2f5);
|
||||
}
|
||||
|
||||
.auth-page-container {
|
||||
width: 100%;
|
||||
max-width: 450px; // Max width for login/signup forms
|
||||
padding: 2rem;
|
||||
}
|
||||
</style>
|
@ -1,93 +1,189 @@
|
||||
<template>
|
||||
<q-layout view="hHh lpR fFf">
|
||||
<!-- Header -->
|
||||
<q-header elevated class="bg-primary text-white">
|
||||
<q-toolbar>
|
||||
<q-toolbar-title> Mooo </q-toolbar-title>
|
||||
<div class="main-layout">
|
||||
<header class="app-header">
|
||||
<div class="toolbar-title">Mooo</div>
|
||||
<div class="user-menu" v-if="authStore.isAuthenticated">
|
||||
<button @click="toggleUserMenu" class="user-menu-button">
|
||||
<!-- Placeholder for user icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#FFFFFF"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
|
||||
</button>
|
||||
<div v-if="userMenuOpen" class="dropdown-menu" ref="userMenuDropdown">
|
||||
<a href="#" @click.prevent="handleLogout">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<q-btn
|
||||
v-if="authStore.isAuthenticated"
|
||||
flat
|
||||
round
|
||||
dense
|
||||
icon="account_circle"
|
||||
aria-label="User Profile"
|
||||
>
|
||||
<q-menu>
|
||||
<q-list style="min-width: 150px">
|
||||
<q-item clickable v-close-popup @click="handleLogout">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="logout" />
|
||||
</q-item-section>
|
||||
<q-item-section>Logout</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</q-toolbar>
|
||||
</q-header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<q-page-container>
|
||||
<main class="page-container">
|
||||
<router-view />
|
||||
</q-page-container>
|
||||
</main>
|
||||
|
||||
<!-- Offline Indicator -->
|
||||
<OfflineIndicator />
|
||||
|
||||
<!-- Bottom Navigation -->
|
||||
<q-footer elevated class="bg-white text-primary">
|
||||
<q-tabs
|
||||
v-model="activeTab"
|
||||
class="text-primary"
|
||||
active-color="primary"
|
||||
indicator-color="primary"
|
||||
align="justify"
|
||||
narrow-indicator
|
||||
>
|
||||
<q-route-tab name="lists" icon="list" label="Lists" to="/lists" />
|
||||
<q-route-tab name="groups" icon="group" label="Groups" to="/groups" />
|
||||
<q-route-tab name="account" icon="person" label="Account" to="/account" />
|
||||
</q-tabs>
|
||||
</q-footer>
|
||||
</q-layout>
|
||||
<footer class="app-footer">
|
||||
<nav class="tabs">
|
||||
<router-link to="/lists" class="tab-item" active-class="active">Lists</router-link>
|
||||
<router-link to="/groups" class="tab-item" active-class="active">Groups</router-link>
|
||||
<router-link to="/account" class="tab-item" active-class="active">Account</router-link>
|
||||
</nav>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useQuasar } from 'quasar';
|
||||
import { useAuthStore } from 'stores/auth';
|
||||
import OfflineIndicator from 'components/OfflineIndicator.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import OfflineIndicator from '@/components/OfflineIndicator.vue';
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
import { useNotificationStore } from '@/stores/notifications';
|
||||
|
||||
const router = useRouter();
|
||||
const $q = useQuasar();
|
||||
const authStore = useAuthStore();
|
||||
const activeTab = ref('lists');
|
||||
const notificationStore = useNotificationStore();
|
||||
|
||||
const handleLogout = () => {
|
||||
const userMenuOpen = ref(false);
|
||||
const userMenuDropdown = ref<HTMLElement | null>(null);
|
||||
|
||||
const toggleUserMenu = () => {
|
||||
userMenuOpen.value = !userMenuOpen.value;
|
||||
};
|
||||
|
||||
onClickOutside(userMenuDropdown, () => {
|
||||
userMenuOpen.value = false;
|
||||
}, { ignore: ['.user-menu-button'] });
|
||||
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
authStore.logout();
|
||||
$q.notify({
|
||||
color: 'positive',
|
||||
authStore.logout(); // Pinia action
|
||||
notificationStore.addNotification({
|
||||
type: 'success',
|
||||
message: 'Logged out successfully',
|
||||
position: 'top',
|
||||
});
|
||||
void router.push('/login');
|
||||
await router.push('/auth/login'); // Adjusted path
|
||||
} catch (error: unknown) {
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
notificationStore.addNotification({
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : 'Logout failed',
|
||||
position: 'top',
|
||||
});
|
||||
}
|
||||
userMenuOpen.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.q-footer {
|
||||
.q-tabs {
|
||||
height: 56px;
|
||||
<style lang="scss" scoped>
|
||||
.main-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
padding: 0 1rem;
|
||||
height: var(--header-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-menu-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:hover {
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 5px);
|
||||
background-color: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
min-width: 150px;
|
||||
z-index: 101;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
padding: 0.5rem 1rem;
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.page-container {
|
||||
flex-grow: 1;
|
||||
padding: 1rem; // Add some default padding
|
||||
padding-bottom: calc(var(--footer-height) + 1rem); // Space for fixed footer
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
background-color: white;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
height: var(--footer-height);
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-color); // Or a specific inactive tab color
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem; // Example size
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
|
||||
|
||||
// Icon would go here if you add them
|
||||
// Example: svg or <i> for icon fonts
|
||||
|
||||
&.active {
|
||||
color: var(--primary-color);
|
||||
border-bottom-color: var(--primary-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
}
|
||||
</style>
|
41
fe/src/main.ts
Normal file
41
fe/src/main.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
// import { createI18n } from 'vue-i18n';
|
||||
// import messages from '@/i18n'; // Import from absolute path
|
||||
|
||||
// Global styles
|
||||
import './assets/main.scss';
|
||||
|
||||
// API client (from your axios boot file)
|
||||
import { api, globalAxios } from '@/services/api'; // Renamed from boot/axios to services/api
|
||||
|
||||
// Vue I18n setup (from your i18n boot file)
|
||||
// export type MessageLanguages = keyof typeof messages;
|
||||
// export type MessageSchema = (typeof messages)['en-US'];
|
||||
|
||||
// declare module 'vue-i18n' {
|
||||
// export interface DefineLocaleMessage extends MessageSchema {}
|
||||
// // eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
// export interface DefineDateTimeFormat {}
|
||||
// // eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
// export interface DefineNumberFormat {}
|
||||
// }
|
||||
// const i18n = createI18n<{ message: MessageSchema }>({
|
||||
// locale: 'en-US',
|
||||
// fallbackLocale: 'en-US',
|
||||
// messages,
|
||||
// });
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
// app.use(i18n);
|
||||
|
||||
// Make API instance globally available (optional, prefer provide/inject or store)
|
||||
app.config.globalProperties.$api = api;
|
||||
app.config.globalProperties.$axios = globalAxios; // The original axios instance if needed
|
||||
|
||||
app.mount('#app');
|
@ -1,160 +1,130 @@
|
||||
<template>
|
||||
<q-page padding>
|
||||
<h1 class="text-h4 q-mb-md">Account Settings</h1>
|
||||
<main class="container page-padding">
|
||||
<h1 class="mb-3">Account Settings</h1>
|
||||
|
||||
<div v-if="loading" class="text-center">
|
||||
<q-spinner-dots color="primary" size="2em" />
|
||||
<div class="spinner-dots" role="status"><span/><span/><span/></div>
|
||||
<p>Loading profile...</p>
|
||||
</div>
|
||||
|
||||
<q-banner v-else-if="error" inline-actions class="text-white bg-red q-mb-md">
|
||||
<template v-slot:avatar>
|
||||
<q-icon name="warning" />
|
||||
</template>
|
||||
<div v-else-if="error" class="alert alert-error mb-3" role="alert">
|
||||
<div class="alert-content">
|
||||
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-alert-triangle" /></svg>
|
||||
{{ error }}
|
||||
<template v-slot:action>
|
||||
<q-btn flat color="white" label="Retry" @click="fetchProfile" />
|
||||
</template>
|
||||
</q-banner>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-danger" @click="fetchProfile">Retry</button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<q-form @submit="onSubmit" class="q-gutter-md">
|
||||
<form v-else @submit.prevent="onSubmitProfile">
|
||||
<!-- Profile Section -->
|
||||
<q-card class="q-mb-md">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Profile Information</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<q-input
|
||||
v-model="profile.name"
|
||||
label="Name"
|
||||
:rules="[(val) => !!val || 'Name is required']"
|
||||
outlined
|
||||
/>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h3>Profile Information</h3>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<q-input
|
||||
v-model="profile.email"
|
||||
label="Email"
|
||||
type="email"
|
||||
:rules="[(val) => !!val || 'Email is required']"
|
||||
outlined
|
||||
readonly
|
||||
/>
|
||||
<div class="card-body">
|
||||
<div class="flex flex-wrap" style="gap: 1rem;">
|
||||
<div class="form-group flex-grow">
|
||||
<label for="profileName" class="form-label">Name</label>
|
||||
<input type="text" id="profileName" v-model="profile.name" class="form-input" required />
|
||||
</div>
|
||||
<div class="form-group flex-grow">
|
||||
<label for="profileEmail" class="form-label">Email</label>
|
||||
<input type="email" id="profileEmail" v-model="profile.email" class="form-input" required readonly />
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn
|
||||
type="submit"
|
||||
color="primary"
|
||||
label="Save Changes"
|
||||
:loading="saving"
|
||||
/>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
<span v-if="saving" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Password Section -->
|
||||
<q-card class="q-mb-md">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Change Password</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<q-input
|
||||
v-model="password.current"
|
||||
label="Current Password"
|
||||
type="password"
|
||||
:rules="[(val) => !!val || 'Current password is required']"
|
||||
outlined
|
||||
/>
|
||||
<form @submit.prevent="onChangePassword">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h3>Change Password</h3>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<q-input
|
||||
v-model="password.new"
|
||||
label="New Password"
|
||||
type="password"
|
||||
:rules="[(val) => !!val || 'New password is required']"
|
||||
outlined
|
||||
/>
|
||||
<div class="card-body">
|
||||
<div class="flex flex-wrap" style="gap: 1rem;">
|
||||
<div class="form-group flex-grow">
|
||||
<label for="currentPassword" class="form-label">Current Password</label>
|
||||
<input type="password" id="currentPassword" v-model="password.current" class="form-input" required />
|
||||
</div>
|
||||
<div class="form-group flex-grow">
|
||||
<label for="newPassword" class="form-label">New Password</label>
|
||||
<input type="password" id="newPassword" v-model="password.newPassword" class="form-input" required />
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn
|
||||
color="primary"
|
||||
label="Change Password"
|
||||
:loading="changingPassword"
|
||||
@click="onChangePassword"
|
||||
/>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<button type="submit" class="btn btn-primary" :disabled="changingPassword">
|
||||
<span v-if="changingPassword" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
|
||||
Change Password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Notifications Section -->
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="text-h6">Notification Preferences</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<q-list>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Email Notifications</q-item-label>
|
||||
<q-item-label caption>Receive email notifications for important updates</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-toggle v-model="preferences.emailNotifications" @update:model-value="onPreferenceChange" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>List Updates</q-item-label>
|
||||
<q-item-label caption>Get notified when lists are updated</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-toggle v-model="preferences.listUpdates" @update:model-value="onPreferenceChange" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Group Activities</q-item-label>
|
||||
<q-item-label caption>Receive notifications for group activities</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-toggle v-model="preferences.groupActivities" @update:model-value="onPreferenceChange" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-form>
|
||||
</template>
|
||||
</q-page>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Notification Preferences</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="item-list preference-list">
|
||||
<li class="preference-item">
|
||||
<div class="preference-label">
|
||||
<span>Email Notifications</span>
|
||||
<small>Receive email notifications for important updates</small>
|
||||
</div>
|
||||
<label class="switch-container">
|
||||
<input type="checkbox" v-model="preferences.emailNotifications" @change="onPreferenceChange" />
|
||||
<span class="switch" aria-hidden="true"></span>
|
||||
</label>
|
||||
</li>
|
||||
<li class="preference-item">
|
||||
<div class="preference-label">
|
||||
<span>List Updates</span>
|
||||
<small>Get notified when lists are updated</small>
|
||||
</div>
|
||||
<label class="switch-container">
|
||||
<input type="checkbox" v-model="preferences.listUpdates" @change="onPreferenceChange" />
|
||||
<span class="switch" aria-hidden="true"></span>
|
||||
</label>
|
||||
</li>
|
||||
<li class="preference-item">
|
||||
<div class="preference-label">
|
||||
<span>Group Activities</span>
|
||||
<small>Receive notifications for group activities</small>
|
||||
</div>
|
||||
<label class="switch-container">
|
||||
<input type="checkbox" v-model="preferences.groupActivities" @change="onPreferenceChange" />
|
||||
<span class="switch" aria-hidden="true"></span>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useQuasar } from 'quasar';
|
||||
import { apiClient, API_ENDPOINTS } from 'src/config/api';
|
||||
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path
|
||||
import { useNotificationStore } from '@/stores/notifications';
|
||||
|
||||
interface Profile {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface Password {
|
||||
interface PasswordForm {
|
||||
current: string;
|
||||
new: string;
|
||||
newPassword: string; // Renamed from 'new' to avoid conflict
|
||||
}
|
||||
|
||||
interface Preferences {
|
||||
@ -163,22 +133,14 @@ interface Preferences {
|
||||
groupActivities: boolean;
|
||||
}
|
||||
|
||||
const $q = useQuasar();
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const changingPassword = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const profile = ref<Profile>({
|
||||
name: '',
|
||||
email: '',
|
||||
});
|
||||
|
||||
const password = ref<Password>({
|
||||
current: '',
|
||||
new: '',
|
||||
});
|
||||
|
||||
const notificationStore = useNotificationStore();
|
||||
const profile = ref<Profile>({ name: '', email: '' });
|
||||
const password = ref<PasswordForm>({ current: '', newPassword: '' });
|
||||
const preferences = ref<Preferences>({
|
||||
emailNotifications: true,
|
||||
listUpdates: true,
|
||||
@ -191,74 +153,110 @@ const fetchProfile = async () => {
|
||||
try {
|
||||
const response = await apiClient.get(API_ENDPOINTS.USERS.PROFILE);
|
||||
profile.value = response.data;
|
||||
// Assume preferences are also fetched or part of profile
|
||||
// preferences.value = response.data.preferences || preferences.value;
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load profile';
|
||||
error.value = message;
|
||||
console.error('Failed to fetch profile:', err);
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load profile';
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.value,
|
||||
});
|
||||
notificationStore.addNotification({ message, type: 'error' });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
const onSubmitProfile = async () => {
|
||||
saving.value = true;
|
||||
try {
|
||||
await apiClient.put(API_ENDPOINTS.USERS.UPDATE_PROFILE, profile.value);
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Profile updated successfully',
|
||||
});
|
||||
notificationStore.addNotification({ message: 'Profile updated successfully', type: 'success' });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update profile';
|
||||
console.error('Failed to update profile:', err);
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: err instanceof Error ? err.message : 'Failed to update profile',
|
||||
});
|
||||
notificationStore.addNotification({ message, type: 'error' });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onChangePassword = async () => {
|
||||
if (!password.value.current || !password.value.newPassword) {
|
||||
notificationStore.addNotification({ message: 'Please fill in both current and new password fields.', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (password.value.newPassword.length < 8) {
|
||||
notificationStore.addNotification({ message: 'New password must be at least 8 characters long.', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
changingPassword.value = true;
|
||||
try {
|
||||
await apiClient.put(API_ENDPOINTS.USERS.PASSWORD, password.value);
|
||||
password.value = { current: '', new: '' };
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Password changed successfully',
|
||||
// API endpoint expects 'new' not 'newPassword'
|
||||
await apiClient.put(API_ENDPOINTS.USERS.PASSWORD, {
|
||||
current: password.value.current,
|
||||
new: password.value.newPassword
|
||||
});
|
||||
password.value = { current: '', newPassword: '' };
|
||||
notificationStore.addNotification({ message: 'Password changed successfully', type: 'success' });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to change password';
|
||||
console.error('Failed to change password:', err);
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: err instanceof Error ? err.message : 'Failed to change password',
|
||||
});
|
||||
notificationStore.addNotification({ message, type: 'error' });
|
||||
} finally {
|
||||
changingPassword.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onPreferenceChange = async () => {
|
||||
// This will be called for each toggle change.
|
||||
// Consider debouncing or providing a "Save Preferences" button if API calls are expensive.
|
||||
try {
|
||||
await apiClient.put(API_ENDPOINTS.USERS.PREFERENCES, preferences.value);
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Preferences updated successfully',
|
||||
});
|
||||
notificationStore.addNotification({ message: 'Preferences updated successfully', type: 'success' });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update preferences';
|
||||
console.error('Failed to update preferences:', err);
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: err instanceof Error ? err.message : 'Failed to update preferences',
|
||||
});
|
||||
notificationStore.addNotification({ message, type: 'error' });
|
||||
// Optionally revert the toggle if the API call fails
|
||||
// await fetchProfile(); // Or manage state more granularly
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void fetchProfile();
|
||||
fetchProfile();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-padding {
|
||||
padding: 1rem; /* Or use var(--padding-page) if defined in Valerie UI */
|
||||
}
|
||||
.mb-3 { margin-bottom: 1.5rem; } /* From Valerie UI */
|
||||
.flex-grow { flex-grow: 1; }
|
||||
|
||||
.preference-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.preference-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #eee; /* Softer border for list items */
|
||||
}
|
||||
.preference-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.preference-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
.preference-label small {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
</style>
|
@ -1,27 +1,49 @@
|
||||
<template>
|
||||
<div class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center">
|
||||
<div class="fullscreen-error text-center">
|
||||
<div>
|
||||
<div style="font-size: 30vh">
|
||||
404
|
||||
</div>
|
||||
|
||||
<div class="text-h2" style="opacity:.4">
|
||||
Oops. Nothing here...
|
||||
</div>
|
||||
|
||||
<q-btn
|
||||
class="q-mt-xl"
|
||||
color="white"
|
||||
text-color="blue"
|
||||
unelevated
|
||||
to="/"
|
||||
label="Go Home"
|
||||
no-caps
|
||||
/>
|
||||
<div class="error-code">404</div>
|
||||
<div class="error-message">Oops. Nothing here...</div>
|
||||
<router-link to="/" class="btn btn-primary mt-3">Go Home</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
//
|
||||
// No script logic needed for this simple page
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fullscreen-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh; /* Fallback for browsers that don't support dvh */
|
||||
min-height: 100dvh;
|
||||
background-color: var(--secondary-accent); /* Light Blue */
|
||||
color: var(--dark);
|
||||
padding: 2rem;
|
||||
font-family: "Patrick Hand", cursive;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: clamp(15vh, 25vw, 30vh); /* Responsive font size */
|
||||
font-weight: bold;
|
||||
color: var(--primary);
|
||||
line-height: 1;
|
||||
text-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: clamp(1.5rem, 4vw, 2.5rem);
|
||||
opacity: 0.8;
|
||||
margin-top: -1rem; /* Adjust based on font size */
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
/* Ensure primary button styles are applied if not already by global .btn */
|
||||
background-color: var(--primary);
|
||||
color: var(--dark);
|
||||
}
|
||||
.mt-3 { margin-top: 1.5rem; }
|
||||
</style>
|
@ -1,79 +1,110 @@
|
||||
<template>
|
||||
<q-page padding>
|
||||
<div v-if="group">
|
||||
<h4 class="q-mt-none q-mb-sm">Group: {{ group.name }}</h4>
|
||||
<main class="container page-padding">
|
||||
<div v-if="loading" class="text-center">
|
||||
<div class="spinner-dots" role="status"><span/><span/><span/></div>
|
||||
<p>Loading group details...</p>
|
||||
</div>
|
||||
<div v-else-if="error" class="alert alert-error" role="alert">
|
||||
<div class="alert-content">
|
||||
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-alert-triangle" /></svg>
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="group">
|
||||
<h1 class="mb-3">Group: {{ group.name }}</h1>
|
||||
|
||||
<!-- Invite Code Generation -->
|
||||
<div class="q-mt-lg">
|
||||
<h5>Invite Members</h5>
|
||||
<q-btn
|
||||
label="Generate Invite Code"
|
||||
color="secondary"
|
||||
@click="void generateInviteCode"
|
||||
:loading="generatingInvite"
|
||||
<!-- Invite Members Section -->
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h3>Invite Members</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
@click="generateInviteCode"
|
||||
:disabled="generatingInvite"
|
||||
>
|
||||
<span v-if="generatingInvite" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
|
||||
Generate Invite Code
|
||||
</button>
|
||||
<div v-if="inviteCode" class="form-group mt-2">
|
||||
<label for="inviteCodeInput" class="form-label">Invite Code:</label>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="inviteCodeInput"
|
||||
type="text"
|
||||
:value="inviteCode"
|
||||
class="form-input flex-grow"
|
||||
readonly
|
||||
/>
|
||||
<div v-if="inviteCode" class="q-mt-md">
|
||||
<q-input readonly :model-value="inviteCode" label="Invite Code">
|
||||
<template v-slot:append>
|
||||
<q-btn round dense flat icon="content_copy" @click="copyInviteCode" />
|
||||
</template>
|
||||
</q-input>
|
||||
<q-banner v-if="copySuccess" class="bg-green-2 text-green-9 q-mt-sm" dense>
|
||||
Invite code copied to clipboard!
|
||||
</q-banner>
|
||||
<button class="btn btn-neutral btn-icon-only ml-1" @click="copyInviteCodeHandler" aria-label="Copy invite code">
|
||||
<svg class="icon"><use xlink:href="#icon-clipboard"></use></svg> <!-- Assuming #icon-clipboard is 'content_copy' -->
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="copySuccess" class="form-success-text mt-1">Invite code copied to clipboard!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="loading"><q-spinner-dots size="2em" /> Loading group details...</div>
|
||||
<div v-else>
|
||||
<p>Group not found or an error occurred.</p>
|
||||
|
||||
<!-- Placeholder for lists related to this group -->
|
||||
<div class="mt-4">
|
||||
<h2>Lists in this Group</h2>
|
||||
<ListsPage :group-id="groupId" />
|
||||
</div>
|
||||
</q-page>
|
||||
|
||||
</div>
|
||||
<div v-else class="alert alert-info" role="status">
|
||||
<div class="alert-content">Group not found or an error occurred.</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { apiClient, API_ENDPOINTS } from 'src/config/api';
|
||||
import { copyToClipboard, useQuasar } from 'quasar';
|
||||
// import { useRoute } from 'vue-router';
|
||||
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import ListsPage from './ListsPage.vue'; // Import ListsPage
|
||||
import { useNotificationStore } from '@/stores/notifications';
|
||||
|
||||
interface Group {
|
||||
id: string;
|
||||
id: string | number; // API might return number
|
||||
name: string;
|
||||
// other properties if needed
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
const props = defineProps<{
|
||||
id: string; // From router param, always string
|
||||
}>();
|
||||
|
||||
const route = useRoute();
|
||||
const $q = useQuasar();
|
||||
// const route = useRoute();
|
||||
// const $q = useQuasar(); // Not used anymore
|
||||
|
||||
const notificationStore = useNotificationStore();
|
||||
const group = ref<Group | null>(null);
|
||||
const loading = ref(false);
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const inviteCode = ref<string | null>(null);
|
||||
const generatingInvite = ref(false);
|
||||
const copySuccess = ref(false);
|
||||
|
||||
const groupId = computed(() => props.id || (route.params.id as string));
|
||||
// groupId is directly from props.id now, which comes from the route path param
|
||||
const groupId = computed(() => props.id);
|
||||
|
||||
const { copy, copied, isSupported: clipboardIsSupported } = useClipboard({ source: inviteCode });
|
||||
|
||||
const fetchGroupDetails = async () => {
|
||||
if (!groupId.value) return;
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BY_ID(groupId.value));
|
||||
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BY_ID(String(groupId.value)));
|
||||
group.value = response.data;
|
||||
} catch (error: unknown) {
|
||||
console.error('Error fetching group details:', error);
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
message: error instanceof Error ? error.message : 'Failed to fetch group details.',
|
||||
icon: 'report_problem',
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to fetch group details.';
|
||||
error.value = message;
|
||||
console.error('Error fetching group details:', err);
|
||||
notificationStore.addNotification({ message, type: 'error' });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@ -83,46 +114,58 @@ const generateInviteCode = async () => {
|
||||
if (!groupId.value) return;
|
||||
generatingInvite.value = true;
|
||||
inviteCode.value = null;
|
||||
copySuccess.value = false;
|
||||
try {
|
||||
const response = await apiClient.post(API_ENDPOINTS.INVITES.BASE, {
|
||||
group_id: groupId.value,
|
||||
group_id: groupId.value, // Ensure this matches API expectation (string or number)
|
||||
});
|
||||
inviteCode.value = response.data.invite_code;
|
||||
$q.notify({
|
||||
color: 'positive',
|
||||
message: 'Invite code generated successfully!',
|
||||
icon: 'check_circle',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('Error generating invite code:', error);
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
message: error instanceof Error ? error.message : 'Failed to generate invite code.',
|
||||
icon: 'report_problem',
|
||||
});
|
||||
notificationStore.addNotification({ message: 'Invite code generated successfully!', type: 'success' });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to generate invite code.';
|
||||
console.error('Error generating invite code:', err);
|
||||
notificationStore.addNotification({ message, type: 'error' });
|
||||
} finally {
|
||||
generatingInvite.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const copyInviteCode = () => {
|
||||
if (inviteCode.value) {
|
||||
copyToClipboard(inviteCode.value)
|
||||
.then(() => {
|
||||
const copyInviteCodeHandler = async () => {
|
||||
if (!clipboardIsSupported.value || !inviteCode.value) {
|
||||
notificationStore.addNotification({ message: 'Clipboard not supported or no code to copy.', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
await copy(inviteCode.value);
|
||||
if (copied.value) {
|
||||
copySuccess.value = true;
|
||||
setTimeout(() => (copySuccess.value = false), 2000);
|
||||
})
|
||||
.catch(() => {
|
||||
console.error('Failed to copy invite code');
|
||||
});
|
||||
// Optionally, notify success via store if preferred over inline message
|
||||
// notificationStore.addNotification({ message: 'Invite code copied!', type: 'info' });
|
||||
} else {
|
||||
notificationStore.addNotification({ message: 'Failed to copy invite code.', type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void fetchGroupDetails();
|
||||
fetchGroupDetails();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Add any page-specific styles here */
|
||||
.page-padding {
|
||||
padding: 1rem;
|
||||
}
|
||||
.mt-1 { margin-top: 0.5rem; }
|
||||
.mt-2 { margin-top: 1rem; }
|
||||
.mt-3 { margin-top: 1.5rem; }
|
||||
.mt-4 { margin-top: 2rem; }
|
||||
.mb-3 { margin-bottom: 1.5rem; }
|
||||
.ml-1 { margin-left: 0.25rem; } /* Adjusted from Valerie UI for tighter fit */
|
||||
|
||||
.form-success-text {
|
||||
color: var(--success); /* Or a darker green for text */
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
.flex-grow { flex-grow: 1; }
|
||||
</style>
|
@ -1,129 +1,156 @@
|
||||
<template>
|
||||
<q-page padding>
|
||||
<div class="row justify-between items-center q-mb-md">
|
||||
<h4 class="q-mt-none q-mb-sm">Your Groups</h4>
|
||||
<q-btn label="Create Group" color="primary" @click="openCreateGroupDialog" />
|
||||
<main class="container page-padding">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h1>Your Groups</h1>
|
||||
<button class="btn btn-primary" @click="openCreateGroupDialog">
|
||||
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-plus" /></svg>
|
||||
Create Group
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Join Group Section -->
|
||||
<q-expansion-item
|
||||
icon="group_add"
|
||||
label="Join a Group with Invite Code"
|
||||
class="q-mb-md"
|
||||
header-class="bg-grey-2"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-input
|
||||
<!-- Join Group Section (using details/summary for expansion) -->
|
||||
<details class="card mb-3">
|
||||
<summary class="card-header flex items-center cursor-pointer" style="display: flex; justify-content: space-between;">
|
||||
<h3>
|
||||
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-user" /></svg> <!-- Placeholder icon -->
|
||||
Join a Group with Invite Code
|
||||
</h3>
|
||||
<span class="expand-icon" aria-hidden="true">▼</span> <!-- Basic expand indicator -->
|
||||
</summary>
|
||||
<div class="card-body">
|
||||
<form @submit.prevent="handleJoinGroup" class="flex items-center" style="gap: 0.5rem;">
|
||||
<div class="form-group flex-grow" style="margin-bottom: 0;">
|
||||
<label for="joinInviteCodeInput" class="sr-only">Enter Invite Code</label>
|
||||
<input
|
||||
type="text"
|
||||
id="joinInviteCodeInput"
|
||||
v-model="inviteCodeToJoin"
|
||||
label="Enter Invite Code"
|
||||
dense
|
||||
outlined
|
||||
class="q-mb-sm"
|
||||
:rules="[(val) => !!val || 'Invite code is required']"
|
||||
ref="joinInviteCodeInput"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-btn
|
||||
label="Join"
|
||||
color="secondary"
|
||||
@click="handleJoinGroup"
|
||||
:loading="joiningGroup"
|
||||
dense
|
||||
flat
|
||||
class="form-input"
|
||||
placeholder="Enter Invite Code"
|
||||
required
|
||||
ref="joinInviteCodeInputRef"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-secondary" :disabled="joiningGroup">
|
||||
<span v-if="joiningGroup" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
|
||||
Join
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="joinGroupFormError" class="form-error-text mt-1">{{ joinGroupFormError }}</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<q-list bordered separator>
|
||||
<q-item
|
||||
<div v-if="loading" class="text-center">
|
||||
<div class="spinner-dots" role="status"><span/><span/><span/></div>
|
||||
<p>Loading groups...</p>
|
||||
</div>
|
||||
<div v-else-if="fetchError" class="alert alert-error" role="alert">
|
||||
<div class="alert-content">
|
||||
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-alert-triangle" /></svg>
|
||||
{{ fetchError }}
|
||||
</div>
|
||||
</div>
|
||||
<ul v-else-if="groups.length" class="item-list">
|
||||
<li
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
clickable
|
||||
v-ripple
|
||||
class="list-item interactive-list-item"
|
||||
@click="selectGroup(group)"
|
||||
@keydown.enter="selectGroup(group)"
|
||||
tabindex="0"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ group.name }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-if="!groups.length && !loading">
|
||||
<q-item-section>
|
||||
<q-item-label caption>You are not a member of any groups yet.</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<div class="list-item-content">
|
||||
<span class="item-text">{{ group.name }}</span>
|
||||
<!-- Could add more details here if needed -->
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="card empty-state-card">
|
||||
<svg class="icon icon-lg" aria-hidden="true"><use xlink:href="#icon-clipboard" /></svg>
|
||||
<h3>No Groups Yet!</h3>
|
||||
<p>You are not a member of any groups yet. Create one or join using an invite code.</p>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="showCreateGroupDialog">
|
||||
<q-card style="min-width: 350px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Create New Group</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="q-pt-none">
|
||||
<q-input
|
||||
dense
|
||||
<!-- Create Group Dialog -->
|
||||
<div v-if="showCreateGroupDialog" class="modal-backdrop open" @click.self="closeCreateGroupDialog">
|
||||
<div class="modal-container" ref="createGroupModalRef" role="dialog" aria-modal="true" aria-labelledby="createGroupTitle">
|
||||
<div class="modal-header">
|
||||
<h3 id="createGroupTitle">Create New Group</h3>
|
||||
<button class="close-button" @click="closeCreateGroupDialog" aria-label="Close">
|
||||
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-close" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<form @submit.prevent="handleCreateGroup">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="newGroupNameInput" class="form-label">Group Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="newGroupNameInput"
|
||||
v-model="newGroupName"
|
||||
autofocus
|
||||
@keyup.enter="handleCreateGroup"
|
||||
label="Group Name"
|
||||
:rules="[(val) => !!val || 'Group name is required']"
|
||||
ref="newGroupNameInput"
|
||||
class="form-input"
|
||||
required
|
||||
ref="newGroupNameInputRef"
|
||||
/>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right" class="text-primary">
|
||||
<q-btn flat label="Cancel" v-close-popup />
|
||||
<q-btn flat label="Create" @click="handleCreateGroup" :loading="creatingGroup" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</q-page>
|
||||
<p v-if="createGroupFormError" class="form-error-text">{{ createGroupFormError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-neutral" @click="closeCreateGroupDialog">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary ml-2" :disabled="creatingGroup">
|
||||
<span v-if="creatingGroup" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, onMounted, nextTick } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { apiClient, API_ENDPOINTS } from 'src/config/api';
|
||||
import { useQuasar, QInput } from 'quasar';
|
||||
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
import { useNotificationStore } from '@/stores/notifications';
|
||||
|
||||
interface Group {
|
||||
id: string;
|
||||
id: string | number;
|
||||
name: string;
|
||||
// Add other relevant group properties here
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
const $q = useQuasar();
|
||||
|
||||
const notificationStore = useNotificationStore();
|
||||
const groups = ref<Group[]>([]);
|
||||
const loading = ref(true);
|
||||
const fetchError = ref<string | null>(null);
|
||||
|
||||
const showCreateGroupDialog = ref(false);
|
||||
const newGroupName = ref('');
|
||||
const creatingGroup = ref(false);
|
||||
const newGroupNameInput = ref<QInput | null>(null);
|
||||
const newGroupNameInputRef = ref<HTMLInputElement | null>(null);
|
||||
const createGroupModalRef = ref<HTMLElement | null>(null);
|
||||
const createGroupFormError = ref<string | null>(null);
|
||||
|
||||
const inviteCodeToJoin = ref('');
|
||||
const joiningGroup = ref(false);
|
||||
const joinInviteCodeInput = ref<QInput | null>(null);
|
||||
const joinInviteCodeInputRef = ref<HTMLInputElement | null>(null);
|
||||
const joinGroupFormError = ref<string | null>(null);
|
||||
|
||||
const fetchGroups = async () => {
|
||||
loading.value = true;
|
||||
fetchError.value = null;
|
||||
try {
|
||||
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
|
||||
groups.value = Array.isArray(response.data) ? response.data : [];
|
||||
} catch (error: unknown) {
|
||||
console.error('Error fetching groups:', error);
|
||||
const message = error instanceof Error ? error.message : 'Failed to load groups. Please try again.';
|
||||
fetchError.value = message;
|
||||
groups.value = [];
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
position: 'top',
|
||||
message: 'Failed to load groups. Please try again.',
|
||||
icon: 'report_problem',
|
||||
});
|
||||
console.error('Error fetching groups:', error);
|
||||
notificationStore.addNotification({ message, type: 'error' });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@ -131,103 +158,126 @@ const fetchGroups = async () => {
|
||||
|
||||
const openCreateGroupDialog = () => {
|
||||
newGroupName.value = '';
|
||||
createGroupFormError.value = null;
|
||||
showCreateGroupDialog.value = true;
|
||||
nextTick(() => {
|
||||
newGroupNameInputRef.value?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const closeCreateGroupDialog = () => {
|
||||
showCreateGroupDialog.value = false;
|
||||
};
|
||||
|
||||
onClickOutside(createGroupModalRef, closeCreateGroupDialog);
|
||||
|
||||
const handleCreateGroup = async () => {
|
||||
if (!newGroupName.value || newGroupName.value.trim() === '') {
|
||||
void newGroupNameInput.value?.validate();
|
||||
if (!newGroupName.value.trim()) {
|
||||
createGroupFormError.value = 'Group name is required';
|
||||
newGroupNameInputRef.value?.focus();
|
||||
return;
|
||||
}
|
||||
createGroupFormError.value = null;
|
||||
creatingGroup.value = true;
|
||||
try {
|
||||
const response = await apiClient.post(API_ENDPOINTS.GROUPS.BASE, {
|
||||
name: newGroupName.value,
|
||||
});
|
||||
const newGroup = response.data;
|
||||
|
||||
if (newGroup && typeof newGroup.id === 'string' && typeof newGroup.name === 'string') {
|
||||
const newGroup = response.data as Group;
|
||||
if (newGroup && newGroup.id && newGroup.name) {
|
||||
groups.value.push(newGroup);
|
||||
showCreateGroupDialog.value = false;
|
||||
$q.notify({
|
||||
color: 'positive',
|
||||
position: 'top',
|
||||
message: `Group '${newGroup.name}' created successfully.`,
|
||||
icon: 'check_circle',
|
||||
});
|
||||
closeCreateGroupDialog();
|
||||
notificationStore.addNotification({ message: `Group '${newGroup.name}' created successfully.`, type: 'success' });
|
||||
} else {
|
||||
console.error('Invalid group data received from API after creation:', response.data);
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
position: 'top',
|
||||
message: 'Failed to create group: Invalid data received from server.',
|
||||
icon: 'report_problem',
|
||||
});
|
||||
throw new Error('Invalid data received from server.');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create group. Please try again.';
|
||||
createGroupFormError.value = message;
|
||||
console.error('Error creating group:', error);
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
position: 'top',
|
||||
message: 'Failed to create group. Please try again.',
|
||||
icon: 'report_problem',
|
||||
});
|
||||
notificationStore.addNotification({ message, type: 'error' });
|
||||
} finally {
|
||||
creatingGroup.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleJoinGroup = async () => {
|
||||
if (!inviteCodeToJoin.value || inviteCodeToJoin.value.trim() === '') {
|
||||
void joinInviteCodeInput.value?.validate();
|
||||
if (!inviteCodeToJoin.value.trim()) {
|
||||
joinGroupFormError.value = 'Invite code is required';
|
||||
joinInviteCodeInputRef.value?.focus();
|
||||
return;
|
||||
}
|
||||
joinGroupFormError.value = null;
|
||||
joiningGroup.value = true;
|
||||
try {
|
||||
const response = await apiClient.post(API_ENDPOINTS.INVITES.ACCEPT(inviteCodeToJoin.value));
|
||||
const joinedGroup = response.data;
|
||||
|
||||
if (joinedGroup && typeof joinedGroup.id === 'string' && typeof joinedGroup.name === 'string') {
|
||||
const joinedGroup = response.data as Group; // Adjust based on actual API response for joined group
|
||||
if (joinedGroup && joinedGroup.id && joinedGroup.name) {
|
||||
// Check if group already in list to prevent duplicates if API returns the group info
|
||||
if (!groups.value.find(g => g.id === joinedGroup.id)) {
|
||||
groups.value.push(joinedGroup);
|
||||
}
|
||||
inviteCodeToJoin.value = '';
|
||||
$q.notify({
|
||||
color: 'positive',
|
||||
position: 'top',
|
||||
message: `Successfully joined group '${joinedGroup.name}'.`,
|
||||
icon: 'check_circle',
|
||||
});
|
||||
notificationStore.addNotification({ message: `Successfully joined group '${joinedGroup.name}'.`, type: 'success' });
|
||||
} else {
|
||||
console.error('Invalid group data received from API after joining:', response.data);
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
position: 'top',
|
||||
message: 'Failed to join group: Invalid data received from server.',
|
||||
icon: 'report_problem',
|
||||
});
|
||||
// If API returns only success message, re-fetch groups
|
||||
await fetchGroups(); // Refresh the list of groups
|
||||
inviteCodeToJoin.value = '';
|
||||
notificationStore.addNotification({ message: `Successfully joined group.`, type: 'success' });
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to join group. Please check the invite code and try again.';
|
||||
joinGroupFormError.value = message;
|
||||
console.error('Error joining group:', error);
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
position: 'top',
|
||||
message: 'Failed to join group. Please check the invite code and try again.',
|
||||
icon: 'report_problem',
|
||||
});
|
||||
notificationStore.addNotification({ message, type: 'error' });
|
||||
} finally {
|
||||
joiningGroup.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const selectGroup = (group: Group) => {
|
||||
console.log('Selected group:', group);
|
||||
void router.push(`/groups/${group.id}`);
|
||||
router.push(`/groups/${group.id}`);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void fetchGroups();
|
||||
fetchGroups();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Add any page-specific styles here */
|
||||
.page-padding { padding: 1rem; }
|
||||
.mb-3 { margin-bottom: 1.5rem; }
|
||||
.mt-1 { margin-top: 0.5rem; }
|
||||
.ml-2 { margin-left: 0.5rem; }
|
||||
|
||||
.interactive-list-item {
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-speed) var(--transition-ease-out);
|
||||
}
|
||||
.interactive-list-item:hover,
|
||||
.interactive-list-item:focus-visible {
|
||||
background-color: rgba(0,0,0,0.03);
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: -3px; /* Adjust to be inside the border */
|
||||
}
|
||||
|
||||
.form-error-text {
|
||||
color: var(--danger);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.flex-grow { flex-grow: 1; }
|
||||
|
||||
details > summary {
|
||||
list-style: none; /* Hide default marker */
|
||||
}
|
||||
details > summary::-webkit-details-marker {
|
||||
display: none; /* Hide default marker for Chrome */
|
||||
}
|
||||
.expand-icon {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
details[open] .expand-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.cursor-pointer { cursor: pointer; }
|
||||
</style>
|
@ -1,43 +1,49 @@
|
||||
<template>
|
||||
<q-page class="row items-center justify-evenly">
|
||||
<example-component
|
||||
title="Example component"
|
||||
active
|
||||
:todos="todos"
|
||||
:meta="meta"
|
||||
></example-component>
|
||||
</q-page>
|
||||
<main class="container page-padding text-center">
|
||||
<h1>Welcome to Valerie UI App</h1>
|
||||
<p class="mb-3">This is the main index page.</p>
|
||||
|
||||
<!-- The ExampleComponent is not provided, so this section is a placeholder -->
|
||||
<div v-if="todos.length" class="card">
|
||||
<div class="card-header">
|
||||
<h3>Sample Todos (from IndexPage data)</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="item-list">
|
||||
<li v-for="todo in todos" :key="todo.id" class="list-item">
|
||||
<div class="list-item-content">
|
||||
<span class="item-text">{{ todo.id }}: {{ todo.content }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-2">Total count from meta: {{ meta.totalCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else>No todos to display.</p>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import type { Todo, Meta } from 'components/models';
|
||||
import ExampleComponent from 'components/ExampleComponent.vue';
|
||||
import type { Todo, Meta } from '@/components/models'; // Adjusted path if models.ts is in the same directory
|
||||
// import ExampleComponent from 'components/ExampleComponent.vue'; // This component is not provided for conversion
|
||||
|
||||
const todos = ref<Todo[]>([
|
||||
{
|
||||
id: 1,
|
||||
content: 'ct1'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
content: 'ct2'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
content: 'ct3'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
content: 'ct4'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
content: 'ct5'
|
||||
}
|
||||
{ id: 1, content: 'ct1' },
|
||||
{ id: 2, content: 'ct2' },
|
||||
{ id: 3, content: 'ct3' },
|
||||
{ id: 4, content: 'ct4' },
|
||||
{ id: 5, content: 'ct5' },
|
||||
]);
|
||||
|
||||
const meta = ref<Meta>({
|
||||
totalCount: 1200
|
||||
totalCount: 1200,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-padding { padding: 1rem; }
|
||||
.mb-3 { margin-bottom: 1.5rem; }
|
||||
.mt-2 { margin-top: 1rem; }
|
||||
.text-center { text-align: center; }
|
||||
</style>
|
File diff suppressed because it is too large
Load Diff
@ -1,98 +1,91 @@
|
||||
<template>
|
||||
<q-page padding>
|
||||
<h1 class="text-h4 q-mb-md">{{ pageTitle }}</h1>
|
||||
<main class="container page-padding">
|
||||
<h1 class="mb-3">{{ pageTitle }}</h1>
|
||||
|
||||
<div v-if="loading" class="text-center">
|
||||
<q-spinner-dots color="primary" size="2em" />
|
||||
<div class="spinner-dots" role="status"><span/><span/><span/></div>
|
||||
<p>Loading lists...</p>
|
||||
</div>
|
||||
|
||||
<q-banner v-else-if="error" inline-actions class="text-white bg-red q-mb-md">
|
||||
<template v-slot:avatar>
|
||||
<q-icon name="warning" />
|
||||
</template>
|
||||
<div v-else-if="error" class="alert alert-error mb-3" role="alert">
|
||||
<div class="alert-content">
|
||||
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-alert-triangle" /></svg>
|
||||
{{ error }}
|
||||
<template v-slot:action>
|
||||
<q-btn flat color="white" label="Retry" @click="fetchLists" />
|
||||
</template>
|
||||
</q-banner>
|
||||
|
||||
<div v-else-if="filteredLists.length === 0">
|
||||
<p>{{ noListsMessage }}</p>
|
||||
<q-btn
|
||||
color="primary"
|
||||
icon="add"
|
||||
label="Create New List"
|
||||
@click="showCreateModal = true"
|
||||
class="q-mt-md"
|
||||
/>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-danger" @click="fetchListsAndGroups">Retry</button>
|
||||
</div>
|
||||
|
||||
<q-list v-else bordered separator>
|
||||
<q-item
|
||||
<div v-else-if="filteredLists.length === 0" class="card empty-state-card">
|
||||
<svg class="icon icon-lg" aria-hidden="true"><use xlink:href="#icon-clipboard" /></svg>
|
||||
<h3>{{ noListsMessage }}</h3>
|
||||
<p v-if="!currentGroupId">Create a personal list or join a group to see shared lists.</p>
|
||||
<p v-else>This group doesn't have any lists yet.</p>
|
||||
<button class="btn btn-primary mt-2" @click="showCreateModal = true">
|
||||
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-plus" /></svg>
|
||||
Create New List
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul v-else class="item-list">
|
||||
<li
|
||||
v-for="list in filteredLists"
|
||||
:key="list.id"
|
||||
clickable
|
||||
v-ripple
|
||||
:to="`/lists/${list.id}`"
|
||||
class="list-item interactive-list-item"
|
||||
tabindex="0"
|
||||
@click="navigateToList(list.id)"
|
||||
@keydown.enter="navigateToList(list.id)"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ list.name }}</q-item-label>
|
||||
<q-item-label caption>{{ list.description || 'No description' }}</q-item-label>
|
||||
<q-item-label caption v-if="!list.group_id && !props.groupId">
|
||||
<q-icon name="person" /> Personal List
|
||||
</q-item-label>
|
||||
<q-item-label caption v-if="list.group_id && !props.groupId">
|
||||
<q-icon name="group" /> Group List (ID: {{ list.group_id }})
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side top>
|
||||
<q-badge
|
||||
:color="list.is_complete ? 'green' : 'orange'"
|
||||
:label="list.is_complete ? 'Complete' : 'Active'"
|
||||
/>
|
||||
<q-item-label caption class="q-mt-xs">
|
||||
<div class="list-item-content">
|
||||
<div class="list-item-main" style="flex-direction: column; align-items: flex-start;">
|
||||
<span class="item-text" style="font-size: 1.1rem; font-weight: bold;">{{ list.name }}</span>
|
||||
<small class="item-caption">{{ list.description || 'No description' }}</small>
|
||||
<small v-if="!list.group_id && !props.groupId" class="item-caption icon-caption">
|
||||
<svg class="icon icon-sm"><use xlink:href="#icon-user"/></svg> Personal List
|
||||
</small>
|
||||
<small v-if="list.group_id && !props.groupId" class="item-caption icon-caption">
|
||||
<svg class="icon icon-sm"><use xlink:href="#icon-user"/></svg> <!-- Placeholder, group icon not in Valerie -->
|
||||
Group List ({{ getGroupName(list.group_id) || `ID: ${list.group_id}`}})
|
||||
</small>
|
||||
</div>
|
||||
<div class="list-item-details" style="flex-direction: column; align-items: flex-end;">
|
||||
<span class="item-badge" :class="list.is_complete ? 'badge-settled' : 'badge-pending'">
|
||||
{{ list.is_complete ? 'Complete' : 'Active' }}
|
||||
</span>
|
||||
<small class="item-caption mt-1">
|
||||
Updated: {{ new Date(list.updated_at).toLocaleDateString() }}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<q-page-sticky position="bottom-right" :offset="[18, 18]">
|
||||
<q-btn
|
||||
fab
|
||||
color="primary"
|
||||
icon="add"
|
||||
<div class="page-sticky-bottom-right">
|
||||
<button
|
||||
class="btn btn-primary btn-icon-only"
|
||||
style="width: 56px; height: 56px; border-radius: 50%; padding: 0;"
|
||||
@click="showCreateModal = true"
|
||||
:label="currentGroupId ? 'Create Group List' : 'Create List'"
|
||||
/>
|
||||
</q-page-sticky>
|
||||
:aria-label="currentGroupId ? 'Create Group List' : 'Create List'"
|
||||
data-tooltip="Create New List"
|
||||
>
|
||||
<svg class="icon icon-lg" style="margin-right:0;"><use xlink:href="#icon-plus" /></svg>
|
||||
</button>
|
||||
<!-- Basic Tooltip (requires JS from Valerie UI example to function on hover/focus) -->
|
||||
<!-- <span class="tooltip-text" role="tooltip">{{ currentGroupId ? 'Create Group List' : 'Create List' }}</span> -->
|
||||
</div>
|
||||
|
||||
<CreateListModal
|
||||
v-model="showCreateModal"
|
||||
:groups="availableGroups"
|
||||
@created="fetchLists"
|
||||
:groups="availableGroupsForModal"
|
||||
@created="onListCreated"
|
||||
/>
|
||||
</q-page>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { apiClient, API_ENDPOINTS } from 'src/config/api';
|
||||
import CreateListModal from 'components/CreateListModal.vue';
|
||||
import {
|
||||
QSpinnerDots,
|
||||
QBanner,
|
||||
QIcon,
|
||||
QList,
|
||||
QItem,
|
||||
QItemSection,
|
||||
QItemLabel,
|
||||
QBadge,
|
||||
QBtn,
|
||||
QPageSticky,
|
||||
} from 'quasar'; // Explicitly import Quasar components
|
||||
import { ref, onMounted, computed, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { apiClient, API_ENDPOINTS } from '@/config/api';
|
||||
import CreateListModal from '@/components/CreateListModal.vue'; // Adjusted path
|
||||
|
||||
interface List {
|
||||
id: number;
|
||||
@ -112,46 +105,57 @@ interface Group {
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
groupId?: number | string; // Can be passed as prop, or we might use route.params
|
||||
groupId?: number | string; // Prop for when ListsPage is embedded (e.g. in GroupDetailPage)
|
||||
}>();
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const lists = ref<List[]>([]);
|
||||
const availableGroups = ref<{ label: string; value: number }[]>([]);
|
||||
const groupName = ref<string | null>(null);
|
||||
const allFetchedGroups = ref<Group[]>([]); // Store all groups user has access to for display
|
||||
const currentViewedGroup = ref<Group | null>(null); // For the title if on a specific group's list page
|
||||
|
||||
const showCreateModal = ref(false);
|
||||
|
||||
const currentGroupId = computed(() => {
|
||||
if (props.groupId) {
|
||||
return typeof props.groupId === 'string' ? parseInt(props.groupId, 10) : props.groupId;
|
||||
const currentGroupId = computed<number | null>(() => {
|
||||
const idFromProp = props.groupId;
|
||||
const idFromRoute = route.params.groupId;
|
||||
if (idFromProp) {
|
||||
return typeof idFromProp === 'string' ? parseInt(idFromProp, 10) : idFromProp;
|
||||
}
|
||||
if (route.params.groupId) {
|
||||
return parseInt(route.params.groupId as string, 10);
|
||||
if (idFromRoute) {
|
||||
return parseInt(idFromRoute as string, 10);
|
||||
}
|
||||
return null; // No specific group selected, show personal lists or all accessible
|
||||
return null;
|
||||
});
|
||||
|
||||
const fetchGroupName = async () => {
|
||||
if (!currentGroupId.value) return;
|
||||
|
||||
const fetchCurrentViewGroupName = async () => {
|
||||
if (!currentGroupId.value) {
|
||||
currentViewedGroup.value = null;
|
||||
return;
|
||||
}
|
||||
// Try to find in already fetched groups first
|
||||
const found = allFetchedGroups.value.find(g => g.id === currentGroupId.value);
|
||||
if (found) {
|
||||
currentViewedGroup.value = found;
|
||||
return;
|
||||
}
|
||||
// If not found, fetch it specifically (might happen if navigating directly)
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
API_ENDPOINTS.GROUPS.BY_ID(String(currentGroupId.value))
|
||||
);
|
||||
groupName.value = (response.data as Group).name;
|
||||
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BY_ID(String(currentGroupId.value)));
|
||||
currentViewedGroup.value = response.data as Group;
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch group name:', err);
|
||||
groupName.value = null;
|
||||
console.error(`Failed to fetch group name for ID ${currentGroupId.value}:`, err);
|
||||
currentViewedGroup.value = null; // Set to null if fetch fails
|
||||
}
|
||||
};
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
if (currentGroupId.value) {
|
||||
return groupName.value
|
||||
? `Lists for ${groupName.value}`
|
||||
return currentViewedGroup.value
|
||||
? `Lists for ${currentViewedGroup.value.name}`
|
||||
: `Lists for Group ${currentGroupId.value}`;
|
||||
}
|
||||
return 'All My Lists';
|
||||
@ -161,18 +165,16 @@ const noListsMessage = computed(() => {
|
||||
if (currentGroupId.value) {
|
||||
return 'No lists found for this group.';
|
||||
}
|
||||
return 'You have no lists yet. Create a personal list or join a group to see shared lists.';
|
||||
return 'You have no lists yet.';
|
||||
});
|
||||
|
||||
const fetchGroups = async () => {
|
||||
const fetchAllAccessibleGroups = async () => {
|
||||
try {
|
||||
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
|
||||
availableGroups.value = (response.data as Group[]).map((group) => ({
|
||||
label: group.name,
|
||||
value: group.id,
|
||||
}));
|
||||
allFetchedGroups.value = (response.data as Group[]);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch groups:', err);
|
||||
console.error('Failed to fetch groups for modal:', err);
|
||||
// Not critical for page load, modal might not show groups
|
||||
}
|
||||
};
|
||||
|
||||
@ -180,38 +182,112 @@ const fetchLists = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
// If currentGroupId is set, fetch lists for that group. Otherwise, fetch all user's lists.
|
||||
const endpoint = currentGroupId.value
|
||||
? API_ENDPOINTS.GROUPS.LISTS(String(currentGroupId.value))
|
||||
: API_ENDPOINTS.LISTS.BASE;
|
||||
|
||||
const response = await apiClient.get(endpoint);
|
||||
lists.value = response.data as List[];
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to fetch lists:', err);
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'An unexpected error occurred while fetching lists.';
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch lists.';
|
||||
console.error(error.value, err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void fetchLists();
|
||||
void fetchGroups();
|
||||
void fetchGroupName();
|
||||
const fetchListsAndGroups = async () => {
|
||||
loading.value = true;
|
||||
await Promise.all([
|
||||
fetchLists(),
|
||||
fetchAllAccessibleGroups()
|
||||
]);
|
||||
await fetchCurrentViewGroupName(); // Depends on allFetchedGroups
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
|
||||
const availableGroupsForModal = computed(() => {
|
||||
return allFetchedGroups.value.map(group => ({
|
||||
label: group.name,
|
||||
value: group.id,
|
||||
}));
|
||||
});
|
||||
|
||||
const filteredLists = computed(() => {
|
||||
if (currentGroupId.value) {
|
||||
return lists.value.filter((list) => list.group_id === currentGroupId.value);
|
||||
const getGroupName = (groupId?: number | null): string | undefined => {
|
||||
if (!groupId) return undefined;
|
||||
return allFetchedGroups.value.find(g => g.id === groupId)?.name;
|
||||
}
|
||||
// Show all accessible lists when no groupId is specified
|
||||
return lists.value;
|
||||
|
||||
const onListCreated = () => {
|
||||
fetchLists(); // Refresh lists after one is created
|
||||
};
|
||||
|
||||
const navigateToList = (listId: number) => {
|
||||
router.push(`/lists/${listId}`);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchListsAndGroups();
|
||||
});
|
||||
|
||||
// Watch for changes in groupId (e.g., if used as a component and prop changes)
|
||||
watch(currentGroupId, () => {
|
||||
fetchListsAndGroups();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.q-item__label--caption .q-icon {
|
||||
margin-right: 4px;
|
||||
.page-padding { padding: 1rem; }
|
||||
.mb-3 { margin-bottom: 1.5rem; }
|
||||
.mt-1 { margin-top: 0.5rem; }
|
||||
.mt-2 { margin-top: 1rem; }
|
||||
|
||||
.interactive-list-item {
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-speed) var(--transition-ease-out);
|
||||
}
|
||||
.interactive-list-item:hover,
|
||||
.interactive-list-item:focus-visible {
|
||||
background-color: rgba(0,0,0,0.03);
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
.item-caption {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.icon-caption .icon {
|
||||
vertical-align: -0.1em; /* Align icon better with text */
|
||||
}
|
||||
|
||||
.page-sticky-bottom-right {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 999; /* Below modals */
|
||||
}
|
||||
.page-sticky-bottom-right .btn {
|
||||
box-shadow: var(--shadow-lg); /* Make it pop more */
|
||||
}
|
||||
|
||||
/* Ensure list item content uses full width for proper layout */
|
||||
.list-item-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
align-items: flex-start; /* Align items to top if they wrap */
|
||||
}
|
||||
.list-item-main {
|
||||
flex-grow: 1;
|
||||
margin-right: 1rem; /* Space before details */
|
||||
}
|
||||
.list-item-details {
|
||||
flex-shrink: 0; /* Prevent badges from shrinking */
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
@ -1,97 +1,109 @@
|
||||
<template>
|
||||
<q-page class="flex flex-center">
|
||||
<q-card class="login-card">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Login</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<q-form @submit="onSubmit" class="q-gutter-md">
|
||||
<q-input
|
||||
v-model="email"
|
||||
label="Email"
|
||||
<main class="flex items-center justify-center page-container">
|
||||
<div class="card login-card">
|
||||
<div class="card-header">
|
||||
<h3>Login</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form @submit.prevent="onSubmit" class="form-layout">
|
||||
<div class="form-group mb-2">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
:rules="[(val) => !!val || 'Email is required', isValidEmail]"
|
||||
id="email"
|
||||
v-model="email"
|
||||
class="form-input"
|
||||
required
|
||||
autocomplete="email"
|
||||
/>
|
||||
<p v-if="formErrors.email" class="form-error-text">{{ formErrors.email }}</p>
|
||||
</div>
|
||||
|
||||
<q-input
|
||||
<div class="form-group mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<div class="input-with-icon-append">
|
||||
<input
|
||||
:type="isPwdVisible ? 'text' : 'password'"
|
||||
id="password"
|
||||
v-model="password"
|
||||
label="Password"
|
||||
:type="isPwd ? 'password' : 'text'"
|
||||
:rules="[(val) => !!val || 'Password is required']"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
:name="isPwd ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="isPwd = !isPwd"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<div>
|
||||
<q-btn
|
||||
label="Login"
|
||||
type="submit"
|
||||
color="primary"
|
||||
class="full-width"
|
||||
:loading="loading"
|
||||
class="form-input"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<button type="button" @click="isPwdVisible = !isPwdVisible" class="icon-append-btn" aria-label="Toggle password visibility">
|
||||
<svg class="icon icon-sm"><use :xlink:href="isPwdVisible ? '#icon-settings' : '#icon-settings'"></use></svg> <!-- Placeholder for visibility icons -->
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="formErrors.password" class="form-error-text">{{ formErrors.password }}</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center q-mt-sm">
|
||||
<router-link to="/signup" class="text-primary"
|
||||
>Don't have an account? Sign up</router-link
|
||||
>
|
||||
<p v-if="formErrors.general" class="alert alert-error form-error-text">{{ formErrors.general }}</p>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-full mt-2" :disabled="loading">
|
||||
<span v-if="loading" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
|
||||
Login
|
||||
</button>
|
||||
|
||||
<div class="text-center mt-2">
|
||||
<router-link to="/signup" class="link-styled">Don't have an account? Sign up</router-link>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useQuasar } from 'quasar';
|
||||
import { useAuthStore } from 'stores/auth';
|
||||
import { useAuthStore } from '@/stores/auth'; // Assuming path
|
||||
import { useNotificationStore } from '@/stores/notifications';
|
||||
|
||||
const $q = useQuasar();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
const notificationStore = useNotificationStore();
|
||||
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
const isPwd = ref(true);
|
||||
const isPwdVisible = ref(false);
|
||||
const loading = ref(false);
|
||||
const formErrors = ref<{ email?: string; password?: string; general?: string }>({});
|
||||
|
||||
const isValidEmail = (val: string) => {
|
||||
const emailPattern =
|
||||
/^(?=[a-zA-Z0-9@._%+-]{6,254}$)[a-zA-Z0-9._%+-]{1,64}@(?:[a-zA-Z0-9-]{1,63}\.){1,8}[a-zA-Z]{2,63}$/;
|
||||
return emailPattern.test(val) || 'Invalid email';
|
||||
const isValidEmail = (val: string): boolean => {
|
||||
const emailPattern = /^(?=[a-zA-Z0-9@._%+-]{6,254}$)[a-zA-Z0-9._%+-]{1,64}@(?:[a-zA-Z0-9-]{1,63}\.){1,8}[a-zA-Z]{2,63}$/;
|
||||
return emailPattern.test(val);
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
formErrors.value = {};
|
||||
if (!email.value.trim()) {
|
||||
formErrors.value.email = 'Email is required';
|
||||
} else if (!isValidEmail(email.value)) {
|
||||
formErrors.value.email = 'Invalid email format';
|
||||
}
|
||||
if (!password.value) {
|
||||
formErrors.value.password = 'Password is required';
|
||||
}
|
||||
return Object.keys(formErrors.value).length === 0;
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
formErrors.value.general = undefined; // Clear previous general errors
|
||||
try {
|
||||
await authStore.login(email.value, password.value);
|
||||
|
||||
$q.notify({
|
||||
color: 'positive',
|
||||
message: 'Login successful',
|
||||
position: 'top',
|
||||
});
|
||||
|
||||
// Redirect to the originally requested page or home
|
||||
notificationStore.addNotification({ message: 'Login successful', type: 'success' });
|
||||
const redirectPath = (route.query.redirect as string) || '/';
|
||||
await router.push(redirectPath);
|
||||
router.push(redirectPath);
|
||||
} catch (error: unknown) {
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
message: error instanceof Error ? error.message : 'Login failed',
|
||||
position: 'top',
|
||||
});
|
||||
const message = error instanceof Error ? error.message : 'Login failed. Please check your credentials.';
|
||||
formErrors.value.general = message;
|
||||
console.error(message, error);
|
||||
notificationStore.addNotification({ message, type: 'error' });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@ -99,9 +111,62 @@ const onSubmit = async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
min-height: 100vh; /* dvh for dynamic viewport height */
|
||||
min-height: 100dvh;
|
||||
padding: 1rem;
|
||||
}
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 20px;
|
||||
}
|
||||
/* form-layout, w-full, mt-2, text-center styles were removed as utility classes from Valerie UI are used directly or are available. */
|
||||
|
||||
.link-styled {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: border-color var(--transition-speed) var(--transition-ease-out);
|
||||
}
|
||||
.link-styled:hover, .link-styled:focus {
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
.form-error-text {
|
||||
color: var(--danger);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.alert.form-error-text { /* For general error message */
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.input-with-icon-append {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
.input-with-icon-append .form-input {
|
||||
padding-right: 3rem; /* Space for the button */
|
||||
}
|
||||
.icon-append-btn {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3rem; /* Width of the button */
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: var(--border); /* Separator line */
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--dark);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.icon-append-btn:hover, .icon-append-btn:focus {
|
||||
opacity: 1;
|
||||
background-color: rgba(0,0,0,0.03);
|
||||
}
|
||||
.icon-append-btn .icon { margin: 0; } /* Remove default icon margin */
|
||||
</style>
|
@ -1,118 +1,135 @@
|
||||
<template>
|
||||
<q-page class="flex flex-center">
|
||||
<q-card class="signup-card">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Sign Up</div>
|
||||
</q-card-section>
|
||||
<main class="flex items-center justify-center page-container">
|
||||
<div class="card signup-card">
|
||||
<div class="card-header">
|
||||
<h3>Sign Up</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form @submit.prevent="onSubmit" class="form-layout">
|
||||
<div class="form-group mb-2">
|
||||
<label for="name" class="form-label">Full Name</label>
|
||||
<input type="text" id="name" v-model="name" class="form-input" required autocomplete="name"/>
|
||||
<p v-if="formErrors.name" class="form-error-text">{{ formErrors.name }}</p>
|
||||
</div>
|
||||
|
||||
<q-card-section>
|
||||
<q-form @submit="onSubmit" class="q-gutter-md">
|
||||
<q-input
|
||||
v-model="name"
|
||||
label="Full Name"
|
||||
:rules="[(val) => !!val || 'Name is required']"
|
||||
/>
|
||||
<div class="form-group mb-2">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" id="email" v-model="email" class="form-input" required autocomplete="email"/>
|
||||
<p v-if="formErrors.email" class="form-error-text">{{ formErrors.email }}</p>
|
||||
</div>
|
||||
|
||||
<q-input
|
||||
v-model="email"
|
||||
label="Email"
|
||||
type="email"
|
||||
:rules="[(val) => !!val || 'Email is required', isValidEmail]"
|
||||
/>
|
||||
|
||||
<q-input
|
||||
<div class="form-group mb-2">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<div class="input-with-icon-append">
|
||||
<input
|
||||
:type="isPwdVisible ? 'text' : 'password'"
|
||||
id="password"
|
||||
v-model="password"
|
||||
label="Password"
|
||||
:type="isPwd ? 'password' : 'text'"
|
||||
:rules="[
|
||||
(val) => !!val || 'Password is required',
|
||||
(val) => val.length >= 8 || 'Password must be at least 8 characters',
|
||||
]"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
:name="isPwd ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="isPwd = !isPwd"
|
||||
class="form-input"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
<button type="button" @click="isPwdVisible = !isPwdVisible" class="icon-append-btn" aria-label="Toggle password visibility">
|
||||
<svg class="icon icon-sm"><use :xlink:href="isPwdVisible ? '#icon-settings' : '#icon-settings'"></use></svg> <!-- Placeholder for visibility icons -->
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="formErrors.password" class="form-error-text">{{ formErrors.password }}</p>
|
||||
</div>
|
||||
|
||||
<q-input
|
||||
<div class="form-group mb-3">
|
||||
<label for="confirmPassword" class="form-label">Confirm Password</label>
|
||||
<input
|
||||
:type="isPwdVisible ? 'text' : 'password'"
|
||||
id="confirmPassword"
|
||||
v-model="confirmPassword"
|
||||
label="Confirm Password"
|
||||
:type="isPwd ? 'password' : 'text'"
|
||||
:rules="[
|
||||
(val) => !!val || 'Please confirm your password',
|
||||
(val) => val === password || 'Passwords do not match',
|
||||
]"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<q-btn
|
||||
label="Sign Up"
|
||||
type="submit"
|
||||
color="primary"
|
||||
class="full-width"
|
||||
:loading="loading"
|
||||
class="form-input"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<p v-if="formErrors.confirmPassword" class="form-error-text">{{ formErrors.confirmPassword }}</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center q-mt-sm">
|
||||
<router-link to="/login" class="text-primary"
|
||||
>Already have an account? Login</router-link
|
||||
>
|
||||
<p v-if="formErrors.general" class="alert alert-error form-error-text">{{ formErrors.general }}</p>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-full mt-2" :disabled="loading">
|
||||
<span v-if="loading" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
|
||||
Sign Up
|
||||
</button>
|
||||
|
||||
<div class="text-center mt-2">
|
||||
<router-link to="auth/login" class="link-styled">Already have an account? Login</router-link>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useQuasar } from 'quasar';
|
||||
import { useAuthStore } from 'stores/auth';
|
||||
import { useAuthStore } from 'stores/auth'; // Assuming path
|
||||
import { useNotificationStore } from '@/stores/notifications';
|
||||
|
||||
const $q = useQuasar();
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const notificationStore = useNotificationStore();
|
||||
|
||||
const name = ref('');
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
const confirmPassword = ref('');
|
||||
const isPwd = ref(true);
|
||||
const isPwdVisible = ref(false);
|
||||
const loading = ref(false);
|
||||
const formErrors = ref<{ name?: string; email?: string; password?: string; confirmPassword?: string; general?: string }>({});
|
||||
|
||||
const isValidEmail = (val: string) => {
|
||||
const emailPattern =
|
||||
/^(?=[a-zA-Z0-9@._%+-]{6,254}$)[a-zA-Z0-9._%+-]{1,64}@(?:[a-zA-Z0-9-]{1,63}\.){1,8}[a-zA-Z]{2,63}$/;
|
||||
return emailPattern.test(val) || 'Invalid email';
|
||||
const isValidEmail = (val: string): boolean => {
|
||||
const emailPattern = /^(?=[a-zA-Z0-9@._%+-]{6,254}$)[a-zA-Z0-9._%+-]{1,64}@(?:[a-zA-Z0-9-]{1,63}\.){1,8}[a-zA-Z]{2,63}$/;
|
||||
return emailPattern.test(val);
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
formErrors.value = {};
|
||||
if (!name.value.trim()) {
|
||||
formErrors.value.name = 'Name is required';
|
||||
}
|
||||
if (!email.value.trim()) {
|
||||
formErrors.value.email = 'Email is required';
|
||||
} else if (!isValidEmail(email.value)) {
|
||||
formErrors.value.email = 'Invalid email format';
|
||||
}
|
||||
if (!password.value) {
|
||||
formErrors.value.password = 'Password is required';
|
||||
} else if (password.value.length < 8) {
|
||||
formErrors.value.password = 'Password must be at least 8 characters';
|
||||
}
|
||||
if (!confirmPassword.value) {
|
||||
formErrors.value.confirmPassword = 'Please confirm your password';
|
||||
} else if (password.value !== confirmPassword.value) {
|
||||
formErrors.value.confirmPassword = 'Passwords do not match';
|
||||
}
|
||||
return Object.keys(formErrors.value).length === 0;
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
formErrors.value.general = undefined;
|
||||
try {
|
||||
await authStore.signup({
|
||||
name: name.value,
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
});
|
||||
|
||||
$q.notify({
|
||||
color: 'positive',
|
||||
message: 'Account created successfully',
|
||||
position: 'top',
|
||||
});
|
||||
await router.push('/login');
|
||||
notificationStore.addNotification({ message: 'Account created successfully. Please login.', type: 'success' });
|
||||
router.push('auth/login');
|
||||
} catch (error: unknown) {
|
||||
$q.notify({
|
||||
color: 'negative',
|
||||
message: error instanceof Error ? error.message : 'Signup failed',
|
||||
position: 'top',
|
||||
});
|
||||
const message = error instanceof Error ? error.message : 'Signup failed. Please try again.';
|
||||
formErrors.value.general = message;
|
||||
console.error(message, error);
|
||||
notificationStore.addNotification({ message, type: 'error' });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@ -120,9 +137,63 @@ const onSubmit = async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Using styles from LoginPage.vue where applicable */
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
padding: 1rem;
|
||||
}
|
||||
.signup-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 20px;
|
||||
}
|
||||
/* form-layout, w-full, mt-2, text-center styles were removed as utility classes from Valerie UI are used directly or are available. */
|
||||
|
||||
.link-styled {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: border-color var(--transition-speed) var(--transition-ease-out);
|
||||
}
|
||||
.link-styled:hover, .link-styled:focus {
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
.form-error-text {
|
||||
color: var(--danger);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.alert.form-error-text { /* For general error message */
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.input-with-icon-append {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
.input-with-icon-append .form-input {
|
||||
padding-right: 3rem;
|
||||
}
|
||||
.icon-append-btn {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: var(--border);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--dark);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.icon-append-btn:hover, .icon-append-btn:focus {
|
||||
opacity: 1;
|
||||
background-color: rgba(0,0,0,0.03);
|
||||
}
|
||||
.icon-append-btn .icon { margin: 0; }
|
||||
</style>
|
@ -1,61 +1,32 @@
|
||||
import { defineRouter } from '#q-app/wrappers';
|
||||
import {
|
||||
createMemoryHistory,
|
||||
createRouter,
|
||||
createWebHashHistory,
|
||||
createWebHistory,
|
||||
} from 'vue-router';
|
||||
// src/router/index.ts
|
||||
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router';
|
||||
import routes from './routes';
|
||||
import { useAuthStore } from 'stores/auth';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
const history = import.meta.env.VITE_ROUTER_MODE === 'history'
|
||||
? createWebHistory(import.meta.env.BASE_URL)
|
||||
: createWebHashHistory(import.meta.env.BASE_URL);
|
||||
|
||||
export default defineRouter(function (/* { store, ssrContext } */) {
|
||||
const createHistory = process.env.SERVER
|
||||
? createMemoryHistory
|
||||
: process.env.VUE_ROUTER_MODE === 'history'
|
||||
? createWebHistory
|
||||
: createWebHashHistory;
|
||||
|
||||
const Router = createRouter({
|
||||
scrollBehavior: () => ({ left: 0, top: 0 }),
|
||||
const router = createRouter({
|
||||
history,
|
||||
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(process.env.VUE_ROUTER_BASE),
|
||||
scrollBehavior: () => ({ left: 0, top: 0 }),
|
||||
});
|
||||
|
||||
// Navigation guard to check authentication
|
||||
Router.beforeEach((to, from, next) => {
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// Auth guard logic
|
||||
const authStore = useAuthStore();
|
||||
const isAuthenticated = authStore.isAuthenticated;
|
||||
|
||||
// Define public routes that don't require authentication
|
||||
const publicRoutes = ['/login', '/signup'];
|
||||
|
||||
// Check if the route requires authentication
|
||||
const publicRoutes = ['/auth/login', '/auth/signup']; // Fixed public routes paths
|
||||
const requiresAuth = !publicRoutes.includes(to.path);
|
||||
|
||||
if (requiresAuth && !isAuthenticated) {
|
||||
// Redirect to login if trying to access protected route without authentication
|
||||
next({ path: '/login', query: { redirect: to.fullPath } });
|
||||
next({ path: '/auth/login', query: { redirect: to.fullPath } }); // Fixed login path with leading slash
|
||||
} else if (!requiresAuth && isAuthenticated) {
|
||||
// Redirect to home if trying to access login/signup while authenticated
|
||||
next({ path: '/' });
|
||||
} else {
|
||||
// Proceed with navigation
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
return Router;
|
||||
});
|
||||
export default router;
|
@ -1,50 +1,48 @@
|
||||
// src/router/routes.ts
|
||||
// Adapt paths to new component locations
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('layouts/MainLayout.vue'),
|
||||
component: () => import('../layouts/MainLayout.vue'), // Use .. alias
|
||||
children: [
|
||||
{ path: '', redirect: '/lists' },
|
||||
{ path: 'lists', name: 'PersonalLists', component: () => import('pages/ListsPage.vue') },
|
||||
{ path: 'lists', name: 'PersonalLists', component: () => import('../pages/ListsPage.vue') },
|
||||
{
|
||||
path: 'lists/:id',
|
||||
name: 'ListDetail',
|
||||
component: () => import('pages/ListDetailPage.vue'),
|
||||
component: () => import('../pages/ListDetailPage.vue'),
|
||||
props: true,
|
||||
},
|
||||
{ path: 'groups', name: 'GroupsList', component: () => import('pages/GroupsPage.vue') },
|
||||
{ path: 'groups', name: 'GroupsList', component: () => import('../pages/GroupsPage.vue') },
|
||||
{
|
||||
path: 'groups/:id',
|
||||
name: 'GroupDetail',
|
||||
component: () => import('pages/GroupDetailPage.vue'),
|
||||
component: () => import('../pages/GroupDetailPage.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: 'groups/:groupId/lists',
|
||||
name: 'GroupLists',
|
||||
component: () => import('pages/ListsPage.vue'),
|
||||
component: () => import('../pages/ListsPage.vue'), // Reusing ListsPage
|
||||
props: true,
|
||||
},
|
||||
{ path: 'account', name: 'Account', component: () => import('pages/AccountPage.vue') },
|
||||
{ path: 'account', name: 'Account', component: () => import('../pages/AccountPage.vue') },
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('layouts/AuthLayout.vue'),
|
||||
path: '/auth', // Group auth routes under a common path for clarity (optional)
|
||||
component: () => import('../layouts/AuthLayout.vue'),
|
||||
children: [
|
||||
{ path: 'login', component: () => import('pages/LoginPage.vue') },
|
||||
{ path: 'signup', component: () => import('pages/SignupPage.vue') },
|
||||
{ path: 'login', name: 'Login', component: () => import('../pages/LoginPage.vue') },
|
||||
{ path: 'signup', name: 'Signup', component: () => import('../pages/SignupPage.vue') },
|
||||
],
|
||||
},
|
||||
|
||||
// Always leave this as last one,
|
||||
// but you can also remove it
|
||||
{
|
||||
path: '/:catchAll(.*)*',
|
||||
component: () => import('pages/ErrorNotFound.vue'),
|
||||
},
|
||||
// {
|
||||
// path: '/:catchAll(.*)*', name: '404',
|
||||
// component: () => import('../pages/ErrorNotFound.vue'),
|
||||
// },
|
||||
];
|
||||
|
||||
export default routes;
|
91
fe/src/services/api.ts
Normal file
91
fe/src/services/api.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import axios from 'axios';
|
||||
import { API_BASE_URL } from '@/config/api-config'; // api-config.ts can be moved to src/config/
|
||||
|
||||
// Create axios instance
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL, // API_BASE_URL should come from env or config
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token'); // Or use useStorage from VueUse
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error); // Simpler error handling
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
try {
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
if (!refreshToken) {
|
||||
// No refresh token, logout or redirect
|
||||
// This logic might be better handled in the auth store or router
|
||||
console.error('No refresh token, redirecting to login');
|
||||
// Consider emitting an event or calling an auth store action
|
||||
// window.location.href = '/auth/login'; // Avoid direct manipulation if possible
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const response = await api.post('/api/v1/auth/refresh-token', { // Ensure this path is correct
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
const { access_token: newAccessToken, refresh_token: newRefreshToken } = response.data;
|
||||
localStorage.setItem('token', newAccessToken);
|
||||
if (newRefreshToken) localStorage.setItem('refreshToken', newRefreshToken); // Backend might not always send a new one
|
||||
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
console.error('Refresh token failed:', refreshError);
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('refreshToken');
|
||||
// window.location.href = '/auth/login'; // Avoid direct manipulation
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Export the original axios too if some parts of your app used it directly
|
||||
const globalAxios = axios;
|
||||
|
||||
export { api, globalAxios };
|
||||
|
||||
// Re-export apiClient for convenience, using the new api instance
|
||||
// from src/config/api.ts
|
||||
import { API_VERSION, API_ENDPOINTS } from '@/config/api-config';
|
||||
|
||||
export const getApiUrl = (endpoint: string): string => {
|
||||
// Assuming API_BASE_URL already includes http://localhost:8000
|
||||
// and endpoint starts with /
|
||||
// The original `getApiUrl` added /api/v1, ensure this is correct for your setup
|
||||
return `${API_BASE_URL}/api/${API_VERSION}${endpoint}`;
|
||||
};
|
||||
|
||||
export const apiClient = {
|
||||
get: (endpoint: string, config = {}) => api.get(getApiUrl(endpoint), config),
|
||||
post: (endpoint: string, data = {}, config = {}) => api.post(getApiUrl(endpoint), data, config),
|
||||
put: (endpoint: string, data = {}, config = {}) => api.put(getApiUrl(endpoint), data, config),
|
||||
patch: (endpoint: string, data = {}, config = {}) => api.patch(getApiUrl(endpoint), data, config),
|
||||
delete: (endpoint: string, config = {}) => api.delete(getApiUrl(endpoint), config),
|
||||
};
|
||||
|
||||
export { API_ENDPOINTS }; // Also re-export for convenience
|
@ -1,6 +1,8 @@
|
||||
import { API_ENDPOINTS } from '@/config/api-config';
|
||||
import { apiClient } from '@/services/api';
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import { apiClient, API_ENDPOINTS } from 'src/config/api';
|
||||
|
||||
|
||||
interface AuthState {
|
||||
accessToken: string | null;
|
||||
|
@ -1,32 +0,0 @@
|
||||
import { defineStore } from '#q-app/wrappers'
|
||||
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
|
||||
})
|
52
fe/src/stores/notifications.ts
Normal file
52
fe/src/stores/notifications.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
duration?: number; // in milliseconds
|
||||
manualClose?: boolean; // If true, won't auto-close
|
||||
}
|
||||
|
||||
export const useNotificationStore = defineStore('notifications', () => {
|
||||
const notifications = ref<Notification[]>([]);
|
||||
const defaultDuration = 5000; // 5 seconds
|
||||
|
||||
// Helper to generate unique IDs
|
||||
const generateId = () => `notif_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
const addNotification = (notificationDetails: Omit<Notification, 'id'>) => {
|
||||
const id = generateId();
|
||||
const newNotification: Notification = {
|
||||
id,
|
||||
...notificationDetails,
|
||||
};
|
||||
|
||||
notifications.value.unshift(newNotification); // Add to the beginning for newest on top
|
||||
|
||||
if (!newNotification.manualClose) {
|
||||
setTimeout(() => {
|
||||
removeNotification(id);
|
||||
}, newNotification.duration || defaultDuration);
|
||||
}
|
||||
};
|
||||
|
||||
const removeNotification = (id: string) => {
|
||||
const index = notifications.value.findIndex(n => n.id === id);
|
||||
if (index !== -1) {
|
||||
notifications.value.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
const clearAllNotifications = () => {
|
||||
notifications.value = [];
|
||||
};
|
||||
|
||||
return {
|
||||
notifications,
|
||||
addNotification,
|
||||
removeNotification,
|
||||
clearAllNotifications,
|
||||
};
|
||||
});
|
@ -1,64 +1,40 @@
|
||||
// src/stores/offline.ts (Example modification)
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useQuasar } from 'quasar';
|
||||
import { LocalStorage } from 'quasar';
|
||||
// import { LocalStorage } from 'quasar'; // REMOVE
|
||||
import { useStorage } from '@vueuse/core'; // VueUse alternative
|
||||
import { useNotificationStore } from '@/stores/notifications'; // Your custom notification store
|
||||
|
||||
export interface OfflineAction {
|
||||
id: string;
|
||||
type: 'add' | 'complete' | 'update' | 'delete';
|
||||
itemId?: string;
|
||||
data: unknown;
|
||||
timestamp: number;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
export interface ConflictResolution {
|
||||
version: 'local' | 'server' | 'merge';
|
||||
action: OfflineAction;
|
||||
}
|
||||
|
||||
export interface ConflictData {
|
||||
localVersion: {
|
||||
data: Record<string, unknown>;
|
||||
timestamp: number;
|
||||
};
|
||||
serverVersion: {
|
||||
data: Record<string, unknown>;
|
||||
timestamp: number;
|
||||
};
|
||||
action: OfflineAction;
|
||||
}
|
||||
// ... (interfaces remain the same)
|
||||
|
||||
export const useOfflineStore = defineStore('offline', () => {
|
||||
const $q = useQuasar();
|
||||
// const $q = useQuasar(); // REMOVE
|
||||
const notificationStore = useNotificationStore();
|
||||
const isOnline = ref(navigator.onLine);
|
||||
const pendingActions = ref<OfflineAction[]>([]);
|
||||
interface OfflineAction {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
type: string;
|
||||
// Add other necessary fields
|
||||
}
|
||||
|
||||
interface ConflictData {
|
||||
localVersion: unknown; // Replace with proper type
|
||||
serverVersion: unknown; // Replace with proper type
|
||||
action: OfflineAction;
|
||||
}
|
||||
|
||||
// Use useStorage for reactive localStorage
|
||||
const pendingActions = useStorage<OfflineAction[]>('offline-actions', []);
|
||||
|
||||
const isProcessingQueue = ref(false);
|
||||
const showConflictDialog = ref(false);
|
||||
const showConflictDialog = ref(false); // You'll need to implement this dialog
|
||||
const currentConflict = ref<ConflictData | null>(null);
|
||||
|
||||
// Initialize from IndexedDB
|
||||
const init = () => {
|
||||
try {
|
||||
const stored = LocalStorage.getItem('offline-actions');
|
||||
if (stored) {
|
||||
pendingActions.value = JSON.parse(stored as string);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load offline actions:', error);
|
||||
}
|
||||
};
|
||||
// init is now handled by useStorage automatically loading the value
|
||||
|
||||
// Save to IndexedDB
|
||||
const saveToStorage = () => {
|
||||
try {
|
||||
LocalStorage.set('offline-actions', JSON.stringify(pendingActions.value));
|
||||
} catch (error) {
|
||||
console.error('Failed to save offline actions:', error);
|
||||
}
|
||||
};
|
||||
// saveToStorage is also handled by useStorage automatically saving on change
|
||||
|
||||
// Add a new offline action
|
||||
const addAction = (action: Omit<OfflineAction, 'id' | 'timestamp'>) => {
|
||||
const newAction: OfflineAction = {
|
||||
...action,
|
||||
@ -66,91 +42,89 @@ export const useOfflineStore = defineStore('offline', () => {
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
pendingActions.value.push(newAction);
|
||||
saveToStorage();
|
||||
// useStorage handles saving
|
||||
};
|
||||
|
||||
// Process the queue when online
|
||||
const processQueue = async () => {
|
||||
if (isProcessingQueue.value || !isOnline.value) return;
|
||||
|
||||
isProcessingQueue.value = true;
|
||||
const actions = [...pendingActions.value];
|
||||
const actionsToProcess = [...pendingActions.value]; // Create a copy to iterate
|
||||
|
||||
for (const action of actions) {
|
||||
for (const action of actionsToProcess) {
|
||||
try {
|
||||
await processAction(action);
|
||||
await processAction(action); // processAction needs to use your actual API client
|
||||
pendingActions.value = pendingActions.value.filter(a => a.id !== action.id);
|
||||
saveToStorage();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('409')) {
|
||||
$q.notify({
|
||||
if (error instanceof Error && 'response' in error && typeof error.response === 'object' && error.response && 'status' in error.response && error.response.status === 409) {
|
||||
notificationStore.addNotification({
|
||||
type: 'warning',
|
||||
message: 'Item was modified by someone else while you were offline. Please review.',
|
||||
actions: [
|
||||
{
|
||||
label: 'Review',
|
||||
color: 'white',
|
||||
handler: () => {
|
||||
// TODO: Implement conflict resolution UI
|
||||
}
|
||||
}
|
||||
]
|
||||
message: 'Item was modified by someone else. Please review.',
|
||||
// actions: [ ... ] // Custom actions for notifications would be more complex
|
||||
});
|
||||
// Here you would trigger the conflict resolution dialog
|
||||
// For example, find the item and its server version, then:
|
||||
// currentConflict.value = { localVersion: ..., serverVersion: ..., action };
|
||||
// showConflictDialog.value = true;
|
||||
// The loop should probably pause or handle this conflict before continuing
|
||||
console.warn('Conflict detected for action:', action.id, error);
|
||||
// Break or decide how to handle queue processing on conflict
|
||||
break;
|
||||
} else {
|
||||
console.error('Failed to process offline action:', error);
|
||||
console.error('Failed to process offline action:', action.id, error);
|
||||
notificationStore.addNotification({
|
||||
type: 'error',
|
||||
message: `Failed to sync action: ${action.type}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isProcessingQueue.value = false;
|
||||
};
|
||||
|
||||
// Process a single action
|
||||
// processAction needs to be implemented with your actual API calls
|
||||
const processAction = async (action: OfflineAction) => {
|
||||
// TODO: Implement actual API calls
|
||||
switch (action.type) {
|
||||
case 'add':
|
||||
// await api.addItem(action.data);
|
||||
break;
|
||||
case 'complete':
|
||||
// await api.completeItem(action.itemId, action.data);
|
||||
break;
|
||||
case 'update':
|
||||
// await api.updateItem(action.itemId, action.data);
|
||||
break;
|
||||
case 'delete':
|
||||
// await api.deleteItem(action.itemId);
|
||||
break;
|
||||
}
|
||||
console.log('Processing action (TODO: Implement API call):', action);
|
||||
// Example:
|
||||
// import { apiClient } from '@/services/api';
|
||||
// import { API_ENDPOINTS } from '@/config/api-config';
|
||||
// switch (action.type) {
|
||||
// case 'add':
|
||||
// // Assuming action.data is { listId: string, itemData: { name: string, quantity?: string } }
|
||||
// // await apiClient.post(API_ENDPOINTS.LISTS.ITEMS(action.data.listId), action.data.itemData);
|
||||
// break;
|
||||
// // ... other cases
|
||||
// }
|
||||
// Simulate async work
|
||||
return new Promise(resolve => setTimeout(resolve, 500));
|
||||
};
|
||||
|
||||
// Listen for online/offline status changes
|
||||
|
||||
const setupNetworkListeners = () => {
|
||||
window.addEventListener('online', () => {
|
||||
(async () => {
|
||||
isOnline.value = true;
|
||||
await processQueue();
|
||||
})().catch(error => {
|
||||
console.error('Error processing queue:', error);
|
||||
processQueue().catch(err => console.error("Error processing queue on online event:", err));
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
isOnline.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
// Computed properties
|
||||
setupNetworkListeners(); // Call this once
|
||||
|
||||
const hasPendingActions = computed(() => pendingActions.value.length > 0);
|
||||
const pendingActionCount = computed(() => pendingActions.value.length);
|
||||
|
||||
// Initialize
|
||||
init();
|
||||
setupNetworkListeners();
|
||||
|
||||
const handleConflictResolution = (resolution: ConflictResolution) => {
|
||||
// Implement the logic to handle the conflict resolution
|
||||
console.log('Conflict resolution:', resolution);
|
||||
const handleConflictResolution = (resolution: { version: 'local' | 'server' | 'merge'; action: OfflineAction; mergedData?: Record<string, unknown> }) => {
|
||||
console.log('Conflict resolution chosen:', resolution);
|
||||
// TODO: Implement logic to apply the chosen resolution
|
||||
// This might involve making another API call with the resolved data
|
||||
// or updating local state and then trying to sync again.
|
||||
// After resolving, remove the action from pending or mark as resolved.
|
||||
// For now, just remove it as an example:
|
||||
pendingActions.value = pendingActions.value.filter(a => a.id !== resolution.action.id);
|
||||
showConflictDialog.value = false;
|
||||
currentConflict.value = null;
|
||||
processQueue().catch(err => console.error("Error processing queue after conflict resolution:", err)); // Try processing queue again
|
||||
};
|
||||
|
||||
return {
|
||||
|
@ -1,8 +1,8 @@
|
||||
/*
|
||||
* This file (which will be your service worker)
|
||||
* is picked up by the build system ONLY if
|
||||
* quasar.config file > pwa > workboxMode is set to "InjectManifest"
|
||||
*/
|
||||
// src/sw.ts
|
||||
// Make sure this file is in src/ as configured in vite.config.ts (srcDir & filename)
|
||||
|
||||
// Ensure globalThis.skipWaiting and clientsClaim are available in the SW scope
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope &
|
||||
typeof globalThis & { skipWaiting: () => Promise<void> };
|
||||
@ -25,13 +25,13 @@ self.skipWaiting().catch((error) => {
|
||||
clientsClaim();
|
||||
|
||||
// Use with precache injection
|
||||
precacheAndRoute(self.__WB_MANIFEST);
|
||||
// vite-plugin-pwa will populate self.__WB_MANIFEST
|
||||
precacheAndRoute(self.__WB_MANIFEST || []); // Provide a fallback empty array
|
||||
|
||||
cleanupOutdatedCaches();
|
||||
|
||||
// Cache app shell and static assets with Cache First strategy
|
||||
registerRoute(
|
||||
// Match static assets
|
||||
({ request }) =>
|
||||
request.destination === 'style' ||
|
||||
request.destination === 'script' ||
|
||||
@ -53,8 +53,7 @@ registerRoute(
|
||||
|
||||
// Cache API calls with Network First strategy
|
||||
registerRoute(
|
||||
// Match API calls
|
||||
({ url }) => url.pathname.startsWith('/api/'),
|
||||
({ url }) => url.pathname.startsWith('/api/'), // Make sure this matches your actual API path structure
|
||||
new NetworkFirst({
|
||||
cacheName: 'api-cache',
|
||||
plugins: [
|
||||
@ -71,10 +70,14 @@ registerRoute(
|
||||
|
||||
// Non-SSR fallbacks to index.html
|
||||
// Production SSR fallbacks to offline.html (except for dev)
|
||||
if (process.env.MODE !== 'ssr' || process.env.PROD) {
|
||||
// Ensure PWA_FALLBACK_HTML and PWA_SERVICE_WORKER_REGEX are defined in vite.config.ts
|
||||
declare const PWA_FALLBACK_HTML: string;
|
||||
declare const PWA_SERVICE_WORKER_REGEX: RegExp;
|
||||
|
||||
if (import.meta.env.MODE !== 'ssr' || import.meta.env.PROD) {
|
||||
registerRoute(
|
||||
new NavigationRoute(createHandlerBoundToURL(process.env.PWA_FALLBACK_HTML), {
|
||||
denylist: [new RegExp(process.env.PWA_SERVICE_WORKER_REGEX), /workbox-(.)*\.js$/],
|
||||
new NavigationRoute(createHandlerBoundToURL(PWA_FALLBACK_HTML), {
|
||||
denylist: [PWA_SERVICE_WORKER_REGEX, /workbox-(.)*\.js$/],
|
||||
}),
|
||||
);
|
||||
}
|
12
fe/tsconfig.app.json
Normal file
12
fe/tsconfig.app.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
@ -1,3 +1,14 @@
|
||||
{
|
||||
"extends": "./.quasar/tsconfig.json"
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.vitest.json"
|
||||
}
|
||||
]
|
||||
}
|
19
fe/tsconfig.node.json
Normal file
19
fe/tsconfig.node.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"nightwatch.conf.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
11
fe/tsconfig.vitest.json
Normal file
11
fe/tsconfig.vitest.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"include": ["src/**/__tests__/*", "env.d.ts"],
|
||||
"exclude": [],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
|
||||
|
||||
"lib": [],
|
||||
"types": ["node", "jsdom"]
|
||||
}
|
||||
}
|
64
fe/vite.config.ts
Normal file
64
fe/vite.config.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { VitePWA, VitePWAOptions } from 'vite-plugin-pwa';
|
||||
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite';
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const pwaOptions: Partial<VitePWAOptions> = {
|
||||
registerType: 'autoUpdate',
|
||||
strategies: 'injectManifest', // Crucial for custom service worker
|
||||
srcDir: 'src', // Directory where sw.ts is located
|
||||
filename: 'sw.ts', // Your custom service worker filename
|
||||
devOptions: {
|
||||
enabled: true, // Enable PWA in development
|
||||
type: 'module',
|
||||
},
|
||||
manifest: {
|
||||
name: 'mitlist',
|
||||
short_name: 'mitlist',
|
||||
description: 'mitlist pwa',
|
||||
theme_color: '#027be3',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait',
|
||||
icons: [
|
||||
{ src: 'icons/icon-128x128.png', sizes: '128x128', type: 'image/png' },
|
||||
{ src: 'icons/icon-192x192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: 'icons/icon-256x256.png', sizes: '256x256', type: 'image/png' },
|
||||
{ src: 'icons/icon-384x384.png', sizes: '384x384', type: 'image/png' },
|
||||
{ src: 'icons/icon-512x512.png', sizes: '512x512', type: 'image/png' },
|
||||
],
|
||||
},
|
||||
injectManifest: {
|
||||
// Options for workbox.injectManifest
|
||||
// Ensure your custom service worker (sw.ts) correctly handles __WB_MANIFEST
|
||||
},
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
VitePWA(pwaOptions),
|
||||
VueI18nPlugin({
|
||||
include: [path.resolve(path.dirname(fileURLToPath(import.meta.url)), './src/i18n/**')],
|
||||
strictMessage: false, // To avoid warnings for missing Quasar translations initially
|
||||
runtimeOnly: false, // If you use <i18n> component or complex messages
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
// Define env variables similar to Quasar's process.env
|
||||
define: {
|
||||
'process.env.PWA_FALLBACK_HTML': JSON.stringify('/index.html'), // Adjust if you have a specific offline.html
|
||||
'process.env.PWA_SERVICE_WORKER_REGEX': JSON.stringify(/^(sw|workbox)-.*\.js$/),
|
||||
'process.env.MODE': JSON.stringify(process.env.NODE_ENV),
|
||||
'process.env.PROD': JSON.stringify(process.env.NODE_ENV === 'production'),
|
||||
},
|
||||
server: {
|
||||
open: true,
|
||||
},
|
||||
});
|
14
fe/vitest.config.ts
Normal file
14
fe/vitest.config.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
|
||||
import viteConfig from './vite.config'
|
||||
|
||||
export default mergeConfig(
|
||||
viteConfig,
|
||||
defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||
root: fileURLToPath(new URL('./', import.meta.url)),
|
||||
},
|
||||
}),
|
||||
)
|
13219
repomix-output.xml
Normal file
13219
repomix-output.xml
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user