migrate to vue+vueuse+valerieui bc quasar customisation is sad

This commit is contained in:
mohamad 2025-05-13 19:23:15 +02:00
parent 9230d1f626
commit 227a3d6186
71 changed files with 20302 additions and 6019 deletions

View File

@ -58,9 +58,8 @@ Organic Bananas
API_DOCS_URL: str = "/api/docs" API_DOCS_URL: str = "/api/docs"
API_REDOC_URL: str = "/api/redoc" API_REDOC_URL: str = "/api/redoc"
CORS_ORIGINS: list[str] = [ CORS_ORIGINS: list[str] = [
"http://localhost:5174", "http://localhost:5173",
"http://localhost:8000", "http://localhost:8000",
"http://localhost:9000",
# Add your deployed frontend URL here later # Add your deployed frontend URL here later
# "https://your-frontend-domain.com", # "https://your-frontend-domain.com",
] ]

View File

@ -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 charset = utf-8
indent_size = 2 indent_size = 2
indent_style = space indent_style = space
end_of_line = lf
insert_final_newline = true insert_final_newline = true
trim_trailing_whitespace = true trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

1
fe/.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
* text=auto eol=lf

44
fe/.gitignore vendored
View File

@ -1,33 +1,33 @@
.DS_Store # Logs
.thumbs.db logs
node_modules *.log
# Quasar core related directories
.quasar
/dist
/quasar.config.*.temporary.compiled*
# Cordova related directories and files
/src-cordova/node_modules
/src-cordova/platforms
/src-cordova/plugins
/src-cordova/www
# Capacitor related directories and files
/src-capacitor/www
/src-capacitor/node_modules
# Log files
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.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 # Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea .idea
*.suo *.suo
*.ntvs* *.ntvs*
*.njsproj *.njsproj
*.sln *.sln
*.sw?
# local .env files *.tsbuildinfo
.env.local*
test-results/
playwright-report/

View File

@ -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

View File

@ -1,5 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/prettierrc", "$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true, "singleQuote": true,
"printWidth": 100 "printWidth": 100
} }

View File

@ -1,15 +1,11 @@
{ {
"recommendations": [ "recommendations": [
"Vue.volar",
"vitest.explorer",
"ms-playwright.playwright",
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",
"esbenp.prettier-vscode", "EditorConfig.EditorConfig",
"editorconfig.editorconfig", "oxc.oxc-vscode",
"vue.volar", "esbenp.prettier-vscode"
"wayou.vscode-todo-highlight"
],
"unwantedRecommendations": [
"octref.vetur",
"hookyqr.beautify",
"dbaeumer.jshint",
"ms-vscode.vscode-typescript-tslint-plugin"
] ]
} }

View File

@ -1,16 +1,13 @@
{ {
"editor.bracketPairColorization.enabled": true, "explorer.fileNesting.enabled": true,
"editor.guides.bracketPairs": 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.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.codeActionsOnSave": [ }
"source.fixAll.eslint"
],
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"vue"
],
"typescript.tsdk": "node_modules/typescript/lib"
}

View File

@ -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 ## Recommended IDE Setup
```bash
yarn [VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
# or
## 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 npm install
``` ```
### Start the app in development mode (hot-code reloading, error reporting, etc.) ### Compile and Hot-Reload for Development
```bash
quasar dev ```sh
npm run dev
``` ```
### Type-Check, Compile and Minify for Production
### Lint the files ```sh
```bash npm run build
yarn lint ```
# or
### 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 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).

View 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
View File

@ -0,0 +1,4 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": ["./**/*"]
}

8
fe/e2e/vue.spec.ts Normal file
View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -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
View 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,
)

View File

@ -1,21 +1,32 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<title><%= productName %></title> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <!-- Or your favicon -->
<meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="<%= productDescription %>"> <meta name="description" content="mitlist pwa">
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<meta name="msapplication-tap-highlight" content="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<% } %>"> <!-- PWA manifest and theme color will be injected by vite-plugin-pwa -->
<title>mitlist</title>
<link rel="icon" type="image/png" sizes="128x128" href="icons/favicon-128x128.png">
<link rel="icon" type="image/png" sizes="96x96" href="icons/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png">
<link rel="icon" type="image/ico" href="favicon.ico">
</head> </head>
<body> <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> </body>
</html> </html>

6417
fe/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,55 +1,62 @@
{ {
"name": "mitlist", "name": "fe",
"version": "0.0.1", "version": "0.0.0",
"description": "mitlist pwa",
"productName": "mitlist",
"author": "Mohamad <Mohamad.elsena@edvring.de>",
"type": "module",
"private": true, "private": true,
"type": "module",
"scripts": { "scripts": {
"lint": "eslint -c ./eslint.config.js \"./src*/**/*.{ts,js,cjs,mjs,vue}\"", "dev": "vite",
"format": "prettier --write \"**/*.{js,ts,vue,scss,html,md,json}\" --ignore-path .gitignore", "build": "run-p type-check \"build-only {@}\" --",
"test": "echo \"No test specified\" && exit 0", "preview": "vite preview",
"dev": "quasar dev", "test:unit": "vitest",
"build": "quasar build", "test:e2e": "playwright test",
"postinstall": "quasar prepare" "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": { "dependencies": {
"@quasar/extras": "^1.16.4", "@vueuse/core": "^13.1.0",
"axios": "^1.2.1", "axios": "^1.9.0",
"pinia": "^3.0.1", "pinia": "^3.0.2",
"quasar": "^2.16.0", "vue": "^3.5.13",
"register-service-worker": "^1.7.2", "vue-i18n": "^12.0.0-alpha.2",
"vue": "^3.4.18", "vue-router": "^4.5.1"
"vue-i18n": "^11.0.0",
"vue-router": "^4.0.12"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.14.0", "@intlify/unplugin-vue-i18n": "^6.0.8",
"@intlify/unplugin-vue-i18n": "^4.0.0", "@playwright/test": "^1.51.1",
"@quasar/app-vite": "^2.1.0", "@tsconfig/node22": "^22.0.1",
"@types/node": "^20.5.9", "@types/jsdom": "^21.1.7",
"@vue/eslint-config-prettier": "^10.1.0", "@types/node": "^22.15.17",
"@vue/eslint-config-typescript": "^14.4.0", "@vitejs/plugin-vue": "^5.2.3",
"autoprefixer": "^10.4.2", "@vitest/eslint-plugin": "^1.1.39",
"eslint": "^9.14.0", "@vue/eslint-config-prettier": "^10.2.0",
"eslint-plugin-vue": "^9.30.0", "@vue/eslint-config-typescript": "^14.5.0",
"globals": "^15.12.0", "@vue/test-utils": "^2.4.6",
"prettier": "^3.3.3", "@vue/tsconfig": "^0.7.0",
"typescript": "~5.5.3", "eslint": "^9.26.0",
"vite-plugin-checker": "^0.9.0", "eslint-plugin-oxlint": "^0.16.0",
"vue-tsc": "^2.0.29", "eslint-plugin-playwright": "^2.2.0",
"workbox-build": "^7.3.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-cacheable-response": "^7.3.0",
"workbox-core": "^7.3.0", "workbox-core": "^7.3.0",
"workbox-expiration": "^7.3.0", "workbox-expiration": "^7.3.0",
"workbox-precaching": "^7.3.0", "workbox-precaching": "^7.3.0",
"workbox-routing": "^7.3.0", "workbox-routing": "^7.3.0",
"workbox-strategies": "^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
View 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,
},
})

View File

@ -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

View File

@ -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: [],
},
};
});

View File

@ -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"
}
]
}

View File

@ -1,7 +0,0 @@
declare namespace NodeJS {
interface ProcessEnv {
SERVICE_WORKER_FILE: string;
PWA_FALLBACK_HTML: string;
PWA_SERVICE_WORKER_REGEX: string;
}
}

View File

@ -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)
},
});

View File

@ -1,7 +0,0 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"lib": ["WebWorker", "ESNext"]
},
"include": ["*.ts", "*.d.ts"]
}

View File

@ -1,7 +1,29 @@
<template> <template>
<router-view /> <router-view />
<NotificationDisplay /> <!-- For custom notifications -->
</template> </template>
<script setup lang="ts"> <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> </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
View 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
View 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

View File

@ -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

View 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; }
}

View 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;
}

View File

@ -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 };

View File

@ -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);
});

View File

@ -1,174 +1,171 @@
<template> <template>
<q-dialog v-model="show" persistent> <div v-if="show" class="modal-backdrop open" @click.self="closeDialog">
<q-card style="min-width: 600px"> <div class="modal-container" style="min-width: 600px" ref="modalContentRef" role="dialog" aria-modal="true" aria-labelledby="conflictDialogTitle">
<q-card-section> <div class="modal-header">
<div class="text-h6">Conflict Resolution</div> <h3 id="conflictDialogTitle">Conflict Resolution</h3>
<div class="text-subtitle2 q-mt-sm"> </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. This item was modified while you were offline. Please review the changes and choose how to resolve the conflict.
</div> </p>
</q-card-section>
<q-card-section class="q-pt-none"> <div class="tabs">
<q-tabs <ul class="tab-list" role="tablist" aria-label="Conflict Resolution Options">
v-model="activeTab" <li
class="text-primary" class="tab-item"
active-color="primary" role="tab"
indicator-color="primary" :aria-selected="activeTab === 'compare'"
align="justify" :tabindex="activeTab === 'compare' ? 0 : -1"
narrow-indicator @click="activeTab = 'compare'"
> @keydown.enter.space="activeTab = 'compare'"
<q-tab name="compare" label="Compare Versions" /> >
<q-tab name="merge" label="Merge Changes" /> Compare Versions
</q-tabs> </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> <div v-if="activeTab === 'compare'" class="tab-content" role="tabpanel" aria-labelledby="tab-compare">
<!-- Compare Versions Tab --> <div class="flex" style="gap: 1rem;">
<q-tab-panel name="compare">
<div class="row q-col-gutter-md">
<!-- Local Version --> <!-- Local Version -->
<div class="col-6"> <div class="card flex-grow" style="width: 50%;">
<q-card flat bordered> <div class="card-header">
<q-card-section> <h4>Your Version</h4>
<div class="text-subtitle1">Your Version</div> </div>
<div class="text-caption"> <div class="card-body">
Last modified: {{ formatDate(conflictData?.localVersion.timestamp ?? 0) }} <p class="text-caption mb-1">
</div> Last modified: {{ formatDate(conflictData?.localVersion.timestamp ?? 0) }}
</q-card-section> </p>
<q-card-section class="q-pt-none"> <ul class="item-list simple-list">
<q-list> <li v-for="(value, key) in conflictData?.localVersion.data" :key="key" class="list-item-simple">
<q-item v-for="(value, key) in conflictData?.localVersion.data" :key="key"> <strong class="text-caption-strong">{{ formatKey(key) }}</strong>
<q-item-section> <span :class="{ 'text-positive-inline': isDifferent(key as string) }">{{ formatValue(value) }}</span>
<q-item-label class="text-caption text-grey"> </li>
{{ formatKey(key) }} </ul>
</q-item-label> </div>
<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> </div>
<!-- Server Version --> <!-- Server Version -->
<div class="col-6"> <div class="card flex-grow" style="width: 50%;">
<q-card flat bordered> <div class="card-header">
<q-card-section> <h4>Server Version</h4>
<div class="text-subtitle1">Server Version</div> </div>
<div class="text-caption"> <div class="card-body">
Last modified: {{ formatDate(conflictData?.serverVersion.timestamp ?? 0) }} <p class="text-caption mb-1">
</div> Last modified: {{ formatDate(conflictData?.serverVersion.timestamp ?? 0) }}
</q-card-section> </p>
<q-card-section class="q-pt-none"> <ul class="item-list simple-list">
<q-list> <li v-for="(value, key) in conflictData?.serverVersion.data" :key="key" class="list-item-simple">
<q-item v-for="(value, key) in conflictData?.serverVersion.data" :key="key"> <strong class="text-caption-strong">{{ formatKey(key) }}</strong>
<q-item-section> <span :class="{ 'text-positive-inline': isDifferent(key as string) }">{{ formatValue(value) }}</span>
<q-item-label class="text-caption text-grey"> </li>
{{ formatKey(key) }} </ul>
</q-item-label> </div>
<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> </div>
</div> </div>
</q-tab-panel> </div>
<!-- Merge Changes Tab --> <div v-if="activeTab === 'merge'" class="tab-content" role="tabpanel" aria-labelledby="tab-merge">
<q-tab-panel name="merge"> <div class="card">
<q-card flat bordered> <div class="card-header">
<q-card-section> <h4>Merge Changes</h4>
<div class="text-subtitle1">Merge Changes</div> </div>
<div class="text-caption"> <div class="card-body">
Select which version to keep for each field <p class="text-caption mb-2">Select which version to keep for each field.</p>
</div> <ul class="item-list simple-list">
</q-card-section> <li v-for="(localValue, key) in conflictData?.localVersion.data" :key="key" class="list-item-simple merge-choice-item">
<q-card-section class="q-pt-none"> <strong class="text-caption-strong">{{ formatKey(key) }}</strong>
<q-list> <div class="flex" style="gap: 1rem; margin-top: 0.5rem;">
<q-item v-for="(value, key) in conflictData?.localVersion.data" :key="key"> <div class="radio-group-inline">
<q-item-section> <label class="radio-label">
<q-item-label class="text-caption text-grey"> <input type="radio" :name="String(key)" v-model="mergeChoices[key]" value="local" />
{{ formatKey(key) }} <span class="checkmark radio-mark"></span>
</q-item-label> Your Version: <span class="value-preview">{{ formatValue(localValue) }}</span>
<div class="row q-col-gutter-sm q-mt-xs"> </label>
<div class="col">
<q-radio
v-model="mergeChoices[key]"
val="local"
label="Your Version"
/>
<div class="text-caption">
{{ formatValue(value) }}
</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> </div>
</q-item-section> <div class="radio-group-inline">
</q-item> <label class="radio-label">
</q-list> <input type="radio" :name="String(key)" v-model="mergeChoices[key]" value="server" />
</q-card-section> <span class="checkmark radio-mark"></span>
</q-card> Server Version: <span class="value-preview">{{ formatValue(conflictData?.serverVersion.data[key]) }}</span>
</q-tab-panel> </label>
</q-tab-panels> </div>
</q-card-section> </div>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<q-card-actions align="right"> <div class="modal-footer">
<q-btn <button
v-if="activeTab === 'compare'" v-if="activeTab === 'compare'"
flat type="button"
label="Keep Local Version" class="btn btn-neutral"
color="primary"
@click="resolveConflict('local')" @click="resolveConflict('local')"
/> >
<q-btn Keep Local Version
</button>
<button
v-if="activeTab === 'compare'" v-if="activeTab === 'compare'"
flat type="button"
label="Keep Server Version" class="btn btn-neutral ml-2"
color="primary"
@click="resolveConflict('server')" @click="resolveConflict('server')"
/> >
<q-btn Keep Server Version
</button>
<button
v-if="activeTab === 'compare'" v-if="activeTab === 'compare'"
flat type="button"
label="Merge Changes" class="btn btn-primary ml-2"
color="primary"
@click="activeTab = 'merge'" @click="activeTab = 'merge'"
/> >
<q-btn Merge Changes
</button>
<button
v-if="activeTab === 'merge'" v-if="activeTab === 'merge'"
flat type="button"
label="Apply Merged Changes" class="btn btn-primary ml-2"
color="primary"
@click="applyMergedChanges" @click="applyMergedChanges"
/> >
<q-btn Apply Merged Changes
flat </button>
label="Cancel" <button
color="negative" type="button"
@click="show = false" class="btn btn-danger ml-2"
/> @click="closeDialog"
</q-card-actions> >
</q-card> Cancel
</q-dialog> </button>
</div>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'; 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 { interface ConflictData {
localVersion: { localVersion: {
@ -179,7 +176,7 @@ interface ConflictData {
data: Record<string, unknown>; data: Record<string, unknown>;
timestamp: number; timestamp: number;
}; };
action: OfflineAction; action: OfflineAction; // Assuming OfflineAction is defined
} }
const props = defineProps<{ const props = defineProps<{
@ -192,47 +189,53 @@ const emit = defineEmits<{
(e: 'resolve', resolution: { version: 'local' | 'server' | 'merge'; action: OfflineAction; mergedData?: Record<string, unknown> }): void; (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 activeTab = ref('compare');
const mergeChoices = ref<Record<string, 'local' | 'server'>>({}); const mergeChoices = ref<Record<string, 'local' | 'server'>>({});
const modalContentRef = ref<HTMLElement | null>(null);
// Watch for changes in modelValue onClickOutside(modalContentRef, () => {
watch(() => props.modelValue, (newValue: boolean) => { if (show.value) {
show.value = newValue; // Potentially ask for confirmation before closing or just close
// closeDialog();
}
}); });
// Watch for changes in show const closeDialog = () => {
watch(show, (newValue: boolean) => { show.value = false;
emit('update:modelValue', newValue); };
});
// Initialize merge choices when conflict data changes
watch(() => props.conflictData, (newData) => { watch(() => props.conflictData, (newData) => {
if (newData) { if (newData) {
const choices: Record<string, 'local' | 'server'> = {}; const choices: Record<string, 'local' | 'server'> = {};
Object.keys(newData.localVersion.data).forEach(key => { 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; mergeChoices.value = choices;
activeTab.value = 'compare'; // Reset to compare tab
} }
}, { immediate: true }); }, { immediate: true, deep: true });
const formatDate = (timestamp: number): string => { const formatDate = (timestamp: number): string => {
if (timestamp === 0) return 'N/A';
return new Date(timestamp).toLocaleString(); return new Date(timestamp).toLocaleString();
}; };
const formatKey = (key: string): string => { const formatKey = (key: string): string => {
return key return key
.split(/(?=[A-Z])/) .replace(/([A-Z])/g, ' $1')
.join(' ') .replace(/^./, (str) => str.toUpperCase());
.toLowerCase()
.replace(/^\w/, (c) => c.toUpperCase());
}; };
const formatValue = (value: unknown): string => { const formatValue = (value: unknown): string => {
if (value === null || value === undefined) return '-'; if (value === null || value === undefined) return '-';
if (typeof value === 'boolean') return value ? 'Yes' : 'No'; 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); if (typeof value === 'number' || typeof value === 'string') return String(value);
return '[Unsupported Type]'; return '[Unsupported Type]';
}; };
@ -244,15 +247,13 @@ const isDifferent = (key: string): boolean => {
return JSON.stringify(localValue) !== JSON.stringify(serverValue); return JSON.stringify(localValue) !== JSON.stringify(serverValue);
}; };
const resolveConflict = (version: 'local' | 'server' | 'merge'): void => { const resolveConflict = (version: 'local' | 'server'): void => {
if (!props.conflictData) return; if (!props.conflictData) return;
emit('resolve', { emit('resolve', {
version, version,
action: props.conflictData.action action: props.conflictData.action,
}); });
closeDialog();
show.value = false;
}; };
const applyMergedChanges = (): void => { const applyMergedChanges = (): void => {
@ -260,30 +261,79 @@ const applyMergedChanges = (): void => {
const mergedData: Record<string, unknown> = {}; const mergedData: Record<string, unknown> = {};
Object.entries(mergeChoices.value).forEach(([key, choice]) => { Object.entries(mergeChoices.value).forEach(([key, choice]) => {
const localValue = props.conflictData?.localVersion.data[key]; mergedData[key] = choice === 'local'
const serverValue = props.conflictData?.serverVersion.data[key]; ? props.conflictData!.localVersion.data[key]
mergedData[key] = choice === 'local' ? localValue : serverValue; : props.conflictData!.serverVersion.data[key];
}); });
emit('resolve', { emit('resolve', {
version: 'merge', version: 'merge',
action: props.conflictData.action, action: props.conflictData.action,
mergedData mergedData,
}); });
closeDialog();
show.value = false;
}; };
</script> </script>
<style lang="scss" scoped> <style scoped>
.q-card { .text-caption {
.text-caption { font-size: 0.85rem;
font-size: 0.8rem; 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 { .text-positive-inline {
color: $positive; color: var(--success); /* Assuming --success is greenish */
font-weight: 500; 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> </style>

View File

@ -1,47 +1,64 @@
<template> <template>
<q-dialog v-model="isOpen" persistent> <div v-if="isOpen" class="modal-backdrop open" @click.self="closeModal">
<q-card style="min-width: 350px"> <div class="modal-container" role="dialog" aria-modal="true" aria-labelledby="createListModalTitle">
<q-card-section class="row items-center"> <div class="modal-header">
<div class="text-h6">Create New List</div> <h3 id="createListModalTitle">Create New List</h3>
<q-space /> <button class="close-button" @click="closeModal" aria-label="Close modal">
<q-btn icon="close" flat round dense v-close-popup /> <svg class="icon" aria-hidden="true"><use xlink:href="#icon-close" /></svg>
</q-card-section> </button>
</div>
<q-card-section> <form @submit.prevent="onSubmit">
<q-form @submit="onSubmit" class="q-gutter-md"> <div class="modal-body">
<q-input <div class="form-group">
v-model="listName" <label for="listName" class="form-label">List Name</label>
label="List Name" <input
:rules="[(val) => !!val || 'Name is required']" type="text"
outlined id="listName"
/> v-model="listName"
class="form-input"
<q-input v-model="description" label="Description" type="textarea" outlined /> required
ref="listNameInput"
<q-select />
v-model="selectedGroup" <p v-if="formErrors.listName" class="form-error-text">{{ formErrors.listName }}</p>
: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> </div>
</q-form>
</q-card-section> <div class="form-group">
</q-card> <label for="description" class="form-label">Description</label>
</q-dialog> <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>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'; import { ref, watch, nextTick } from 'vue';
import { useQuasar } from 'quasar'; import { useVModel, onClickOutside } from '@vueuse/core';
import { apiClient, API_ENDPOINTS } from 'src/config/api'; import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming this path is correct
import { useNotificationStore } from '@/stores/notifications';
const $q = useQuasar();
const props = defineProps<{ const props = defineProps<{
modelValue: boolean; modelValue: boolean;
@ -53,50 +70,81 @@ const emit = defineEmits<{
(e: 'created'): void; (e: 'created'): void;
}>(); }>();
const isOpen = ref(props.modelValue); const isOpen = useVModel(props, 'modelValue', emit);
const listName = ref(''); const listName = ref('');
const description = 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 const listNameInput = ref<HTMLInputElement | null>(null);
watch( const modalContainerRef = ref<HTMLElement | null>(null); // For onClickOutside
() => props.modelValue,
(newVal) => {
isOpen.value = newVal;
},
);
// Watch for isOpen changes
watch(isOpen, (newVal) => { 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 () => { const onSubmit = async () => {
if (!validateForm()) {
return;
}
loading.value = true;
try { try {
await apiClient.post(API_ENDPOINTS.LISTS.BASE, { await apiClient.post(API_ENDPOINTS.LISTS.BASE, {
name: listName.value, name: listName.value,
description: description.value, description: description.value,
group_id: selectedGroup.value?.value, group_id: selectedGroupId.value,
}); });
$q.notify({ notificationStore.addNotification({ message: 'List created successfully', type: 'success' });
type: 'positive',
message: 'List created successfully',
});
// Reset form
listName.value = '';
description.value = '';
selectedGroup.value = null;
// Close modal and emit created event
isOpen.value = false;
emit('created'); emit('created');
closeModal();
} catch (error: unknown) { } catch (error: unknown) {
$q.notify({ const message = error instanceof Error ? error.message : 'Failed to create list';
type: 'negative', notificationStore.addNotification({ message, type: 'error' });
message: error instanceof Error ? error.message : 'Failed to create list', console.error(message, error);
}); } finally {
loading.value = false;
} }
}; };
</script> </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>

View File

@ -1,35 +1,74 @@
<template> <template>
<q-item <li>
clickable <a :href="link" :target="isExternalLink ? '_blank' : undefined" class="list-item-link">
tag="a" <svg v-if="icon" class="icon" aria-hidden="true"><use :xlink:href="`#icon-${icon}`" /></svg>
target="_blank" <span class="link-content">
:href="link" <span class="link-title">{{ title }}</span>
> <span v-if="caption" class="link-caption">{{ caption }}</span>
<q-item-section </span>
v-if="icon" </a>
avatar </li>
>
<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>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
export interface EssentialLinkProps { export interface EssentialLinkProps {
title: string; title: string;
caption?: string; caption?: string;
link?: string; link?: string;
icon?: string; icon?: string;
}; }
withDefaults(defineProps<EssentialLinkProps>(), { const props = withDefaults(defineProps<EssentialLinkProps>(), {
caption: '', caption: '',
link: '#', link: '#',
icon: '', icon: '',
}); });
const isExternalLink = computed(() => {
return props.link?.startsWith('http') || props.link?.startsWith('//');
});
</script> </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>

View File

@ -1,131 +1,142 @@
<template> <template>
<q-banner <div>
v-if="!isOnline || hasPendingActions" <div
:class="[ v-if="!isOnline || hasPendingActions"
'offline-indicator', class="alert offline-indicator"
{ 'offline': !isOnline }, :class="{
{ 'pending': hasPendingActions } 'alert-error': !isOnline,
]" 'alert-warning': isOnline && hasPendingActions
rounded }"
> role="status"
<template v-slot:avatar> >
<q-icon <div class="alert-content">
:name="!isOnline ? 'wifi_off' : 'sync'" <svg class="icon" aria-hidden="true">
:color="!isOnline ? 'negative' : 'warning'" <use :xlink:href="!isOnline ? '#icon-alert-triangle' : '#icon-info'" />
/> <!-- Placeholder icons, wifi_off and sync are not in Valerie UI default -->
</template> </svg>
<span v-if="!isOnline">
<template v-if="!isOnline"> You are currently offline. Changes will be saved locally.
You are currently offline. Changes will be saved locally. </span>
</template> <span v-else>
<template v-else> Syncing {{ pendingActionCount }} pending {{ pendingActionCount === 1 ? 'change' : 'changes' }}...
Syncing {{ pendingActionCount }} pending {{ pendingActionCount === 1 ? 'change' : 'changes' }}... </span>
</template> </div>
<button
<template v-slot:action>
<q-btn
v-if="hasPendingActions" v-if="hasPendingActions"
flat class="btn btn-sm btn-neutral"
color="primary" @click="showPendingActionsModal = true"
label="View Changes" >
@click="showPendingActions = true" View Changes
/> </button>
</template> </div>
</q-banner>
<q-dialog v-model="showPendingActions"> <div v-if="showPendingActionsModal" class="modal-backdrop open" @click.self="showPendingActionsModal = false">
<q-card style="min-width: 350px"> <div class="modal-container" ref="pendingActionsModalRef" role="dialog" aria-modal="true" aria-labelledby="pendingActionsTitle">
<q-card-section> <div class="modal-header">
<div class="text-h6">Pending Changes</div> <h3 id="pendingActionsTitle">Pending Changes</h3>
</q-card-section> <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>
<q-card-section class="q-pt-none"> <!-- Conflict Resolution Dialog -->
<q-list> <ConflictResolutionDialog
<q-item v-for="action in pendingActions" :key="action.id"> v-model="offlineStore.showConflictDialog"
<q-item-section> :conflict-data="offlineStore.currentConflict"
<q-item-label> @resolve="offlineStore.handleConflictResolution"
{{ getActionLabel(action) }} />
</q-item-label> </div>
<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>
<!-- Conflict Resolution Dialog -->
<ConflictResolutionDialog
v-model="showConflictDialog"
:conflict-data="currentConflict"
@resolve="handleConflictResolution"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useOfflineStore } from 'src/stores/offline'; import { useNetwork, onClickOutside } from '@vueuse/core';
import type { OfflineAction } from 'src/stores/offline'; import { useOfflineStore } from '@/stores/offline'; // Assuming path
import ConflictResolutionDialog from './ConflictResolutionDialog.vue'; import type { OfflineAction } from '@/stores/offline'; // Assuming path
import ConflictResolutionDialog from '@/components/ConflictResolutionDialog.vue';
const offlineStore = useOfflineStore(); 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 { const {
isOnline,
pendingActions, pendingActions,
hasPendingActions, hasPendingActions,
pendingActionCount, pendingActionCount,
showConflictDialog, // showConflictDialog, // Handled by offlineStore.showConflictDialog
currentConflict, // currentConflict, // Handled by offlineStore.currentConflict
handleConflictResolution, // handleConflictResolution // Handled by offlineStore.handleConflictResolution
} = offlineStore; } = offlineStore;
onClickOutside(pendingActionsModalRef, () => {
showPendingActionsModal.value = false;
});
const getActionLabel = (action: OfflineAction) => { 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) { switch (action.type) {
case 'add': { case 'add':
const data = action.data as { title?: string }; case 'create': // Common alias
return `Add new item: ${data.title || 'Untitled'}`; return `Add: ${itemTitle}`;
} case 'complete':
case 'complete': { return `Complete: ${itemTitle}`;
const data = action.data as { title?: string }; case 'update':
return `Complete item: ${data.title || 'Untitled'}`; return `Update: ${itemTitle}`;
} case 'delete':
case 'update': { return `Delete: ${itemTitle}`;
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'}`;
}
default: default:
return 'Unknown action'; return `Unknown action: ${action.type} for ${itemTitle}`;
} }
}; };
</script> </script>
<style lang="scss" scoped> <style scoped>
.offline-indicator { .offline-indicator {
position: fixed; position: fixed;
bottom: 16px; bottom: 1rem;
right: 16px; right: 1rem;
z-index: 1000; z-index: 1000;
max-width: 400px; max-width: 400px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); /* Valerie UI .alert already has box-shadow */
}
&.offline { /* Styles for text-caption if not globally available enough */
background-color: #ffebee; .text-caption {
} font-size: 0.85rem;
color: var(--dark);
opacity: 0.7;
}
&.pending { /* Simplified list item for pending actions modal */
background-color: #fff3e0; .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> </style>

View 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>

View File

@ -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'; import { API_BASE_URL, API_VERSION, API_ENDPOINTS } from './api-config';
// Helper function to get full API URL // Helper function to get full API URL

View File

@ -1 +0,0 @@
// app global css in SCSS form

View File

@ -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
View File

@ -1,7 +0,0 @@
declare namespace NodeJS {
interface ProcessEnv {
NODE_ENV: string;
VUE_ROUTER_MODE: 'hash' | 'history' | 'abstract' | undefined;
VUE_ROUTER_BASE: string | undefined;
}
}

View File

@ -1,11 +1,28 @@
// src/layouts/AuthLayout.vue
<template> <template>
<q-layout view="hHh lpR fFf"> <div class="auth-layout">
<q-page-container> <main class="auth-page-container">
<router-view /> <router-view />
</q-page-container> </main>
</q-layout> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// No additional setup needed for this layout // No specific logic for AuthLayout
</script> </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>

View File

@ -1,93 +1,189 @@
<template> <template>
<q-layout view="hHh lpR fFf"> <div class="main-layout">
<!-- Header --> <header class="app-header">
<q-header elevated class="bg-primary text-white"> <div class="toolbar-title">Mooo</div>
<q-toolbar> <div class="user-menu" v-if="authStore.isAuthenticated">
<q-toolbar-title> Mooo </q-toolbar-title> <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 <main class="page-container">
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>
<router-view /> <router-view />
</q-page-container> </main>
<!-- Offline Indicator -->
<OfflineIndicator /> <OfflineIndicator />
<!-- Bottom Navigation --> <footer class="app-footer">
<q-footer elevated class="bg-white text-primary"> <nav class="tabs">
<q-tabs <router-link to="/lists" class="tab-item" active-class="active">Lists</router-link>
v-model="activeTab" <router-link to="/groups" class="tab-item" active-class="active">Groups</router-link>
class="text-primary" <router-link to="/account" class="tab-item" active-class="active">Account</router-link>
active-color="primary" </nav>
indicator-color="primary" </footer>
align="justify" </div>
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>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useQuasar } from 'quasar'; import { useAuthStore } from '@/stores/auth';
import { useAuthStore } from 'stores/auth'; import OfflineIndicator from '@/components/OfflineIndicator.vue';
import OfflineIndicator from 'components/OfflineIndicator.vue'; import { onClickOutside } from '@vueuse/core';
import { useNotificationStore } from '@/stores/notifications';
const router = useRouter(); const router = useRouter();
const $q = useQuasar();
const authStore = useAuthStore(); 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 { try {
authStore.logout(); authStore.logout(); // Pinia action
$q.notify({ notificationStore.addNotification({
color: 'positive', type: 'success',
message: 'Logged out successfully', message: 'Logged out successfully',
position: 'top',
}); });
void router.push('/login'); await router.push('/auth/login'); // Adjusted path
} catch (error: unknown) { } catch (error: unknown) {
$q.notify({ notificationStore.addNotification({
color: 'negative', type: 'error',
message: error instanceof Error ? error.message : 'Logout failed', message: error instanceof Error ? error.message : 'Logout failed',
position: 'top',
}); });
} }
userMenuOpen.value = false;
}; };
</script> </script>
<style lang="scss"> <style lang="scss" scoped>
.q-footer { .main-layout {
.q-tabs { display: flex;
height: 56px; 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);
} }
} }
</style>
.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
View 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');

View File

@ -1,160 +1,130 @@
<template> <template>
<q-page padding> <main class="container page-padding">
<h1 class="text-h4 q-mb-md">Account Settings</h1> <h1 class="mb-3">Account Settings</h1>
<div v-if="loading" class="text-center"> <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> <p>Loading profile...</p>
</div> </div>
<q-banner v-else-if="error" inline-actions class="text-white bg-red q-mb-md"> <div v-else-if="error" class="alert alert-error mb-3" role="alert">
<template v-slot:avatar> <div class="alert-content">
<q-icon name="warning" /> <svg class="icon" aria-hidden="true"><use xlink:href="#icon-alert-triangle" /></svg>
</template> {{ error }}
{{ error }} </div>
<template v-slot:action> <button type="button" class="btn btn-sm btn-danger" @click="fetchProfile">Retry</button>
<q-btn flat color="white" label="Retry" @click="fetchProfile" /> </div>
</template>
</q-banner>
<template v-else> <form v-else @submit.prevent="onSubmitProfile">
<q-form @submit="onSubmit" class="q-gutter-md"> <!-- Profile Section -->
<!-- Profile Section --> <div class="card mb-3">
<q-card class="q-mb-md"> <div class="card-header">
<q-card-section> <h3>Profile Information</h3>
<div class="text-h6">Profile Information</div> </div>
</q-card-section> <div class="card-body">
<div class="flex flex-wrap" style="gap: 1rem;">
<q-card-section> <div class="form-group flex-grow">
<div class="row q-col-gutter-md"> <label for="profileName" class="form-label">Name</label>
<div class="col-12 col-md-6"> <input type="text" id="profileName" v-model="profile.name" class="form-input" required />
<q-input
v-model="profile.name"
label="Name"
:rules="[(val) => !!val || 'Name is required']"
outlined
/>
</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>
</div> </div>
</q-card-section> <div class="form-group flex-grow">
<label for="profileEmail" class="form-label">Email</label>
<q-card-actions align="right"> <input type="email" id="profileEmail" v-model="profile.email" class="form-input" required readonly />
<q-btn
type="submit"
color="primary"
label="Save Changes"
:loading="saving"
/>
</q-card-actions>
</q-card>
<!-- 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
/>
</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>
</div> </div>
</q-card-section> </div>
</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>
<q-card-actions align="right"> <!-- Password Section -->
<q-btn <form @submit.prevent="onChangePassword">
color="primary" <div class="card mb-3">
label="Change Password" <div class="card-header">
:loading="changingPassword" <h3>Change Password</h3>
@click="onChangePassword" </div>
/> <div class="card-body">
</q-card-actions> <div class="flex flex-wrap" style="gap: 1rem;">
</q-card> <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>
</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 --> <!-- Notifications Section -->
<q-card> <div class="card">
<q-card-section> <div class="card-header">
<div class="text-h6">Notification Preferences</div> <h3>Notification Preferences</h3>
</q-card-section> </div>
<div class="card-body">
<q-card-section> <ul class="item-list preference-list">
<q-list> <li class="preference-item">
<q-item tag="label" v-ripple> <div class="preference-label">
<q-item-section> <span>Email Notifications</span>
<q-item-label>Email Notifications</q-item-label> <small>Receive email notifications for important updates</small>
<q-item-label caption>Receive email notifications for important updates</q-item-label> </div>
</q-item-section> <label class="switch-container">
<q-item-section side> <input type="checkbox" v-model="preferences.emailNotifications" @change="onPreferenceChange" />
<q-toggle v-model="preferences.emailNotifications" @update:model-value="onPreferenceChange" /> <span class="switch" aria-hidden="true"></span>
</q-item-section> </label>
</q-item> </li>
<li class="preference-item">
<q-item tag="label" v-ripple> <div class="preference-label">
<q-item-section> <span>List Updates</span>
<q-item-label>List Updates</q-item-label> <small>Get notified when lists are updated</small>
<q-item-label caption>Get notified when lists are updated</q-item-label> </div>
</q-item-section> <label class="switch-container">
<q-item-section side> <input type="checkbox" v-model="preferences.listUpdates" @change="onPreferenceChange" />
<q-toggle v-model="preferences.listUpdates" @update:model-value="onPreferenceChange" /> <span class="switch" aria-hidden="true"></span>
</q-item-section> </label>
</q-item> </li>
<li class="preference-item">
<q-item tag="label" v-ripple> <div class="preference-label">
<q-item-section> <span>Group Activities</span>
<q-item-label>Group Activities</q-item-label> <small>Receive notifications for group activities</small>
<q-item-label caption>Receive notifications for group activities</q-item-label> </div>
</q-item-section> <label class="switch-container">
<q-item-section side> <input type="checkbox" v-model="preferences.groupActivities" @change="onPreferenceChange" />
<q-toggle v-model="preferences.groupActivities" @update:model-value="onPreferenceChange" /> <span class="switch" aria-hidden="true"></span>
</q-item-section> </label>
</q-item> </li>
</q-list> </ul>
</q-card-section> </div>
</q-card> </div>
</q-form> </main>
</template>
</q-page>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { useQuasar } from 'quasar'; import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path
import { apiClient, API_ENDPOINTS } from 'src/config/api'; import { useNotificationStore } from '@/stores/notifications';
interface Profile { interface Profile {
name: string; name: string;
email: string; email: string;
} }
interface Password { interface PasswordForm {
current: string; current: string;
new: string; newPassword: string; // Renamed from 'new' to avoid conflict
} }
interface Preferences { interface Preferences {
@ -163,22 +133,14 @@ interface Preferences {
groupActivities: boolean; groupActivities: boolean;
} }
const $q = useQuasar();
const loading = ref(true); const loading = ref(true);
const saving = ref(false); const saving = ref(false);
const changingPassword = ref(false); const changingPassword = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const profile = ref<Profile>({ const notificationStore = useNotificationStore();
name: '', const profile = ref<Profile>({ name: '', email: '' });
email: '', const password = ref<PasswordForm>({ current: '', newPassword: '' });
});
const password = ref<Password>({
current: '',
new: '',
});
const preferences = ref<Preferences>({ const preferences = ref<Preferences>({
emailNotifications: true, emailNotifications: true,
listUpdates: true, listUpdates: true,
@ -191,74 +153,110 @@ const fetchProfile = async () => {
try { try {
const response = await apiClient.get(API_ENDPOINTS.USERS.PROFILE); const response = await apiClient.get(API_ENDPOINTS.USERS.PROFILE);
profile.value = response.data; profile.value = response.data;
// Assume preferences are also fetched or part of profile
// preferences.value = response.data.preferences || preferences.value;
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to load profile';
error.value = message;
console.error('Failed to fetch profile:', err); console.error('Failed to fetch profile:', err);
error.value = err instanceof Error ? err.message : 'Failed to load profile'; notificationStore.addNotification({ message, type: 'error' });
$q.notify({
type: 'negative',
message: error.value,
});
} finally { } finally {
loading.value = false; loading.value = false;
} }
}; };
const onSubmit = async () => { const onSubmitProfile = async () => {
saving.value = true; saving.value = true;
try { try {
await apiClient.put(API_ENDPOINTS.USERS.UPDATE_PROFILE, profile.value); await apiClient.put(API_ENDPOINTS.USERS.UPDATE_PROFILE, profile.value);
$q.notify({ notificationStore.addNotification({ message: 'Profile updated successfully', type: 'success' });
type: 'positive',
message: 'Profile updated successfully',
});
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to update profile';
console.error('Failed to update profile:', err); console.error('Failed to update profile:', err);
$q.notify({ notificationStore.addNotification({ message, type: 'error' });
type: 'negative',
message: err instanceof Error ? err.message : 'Failed to update profile',
});
} finally { } finally {
saving.value = false; saving.value = false;
} }
}; };
const onChangePassword = async () => { 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; changingPassword.value = true;
try { try {
await apiClient.put(API_ENDPOINTS.USERS.PASSWORD, password.value); // API endpoint expects 'new' not 'newPassword'
password.value = { current: '', new: '' }; await apiClient.put(API_ENDPOINTS.USERS.PASSWORD, {
$q.notify({ current: password.value.current,
type: 'positive', new: password.value.newPassword
message: 'Password changed successfully',
}); });
password.value = { current: '', newPassword: '' };
notificationStore.addNotification({ message: 'Password changed successfully', type: 'success' });
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to change password';
console.error('Failed to change password:', err); console.error('Failed to change password:', err);
$q.notify({ notificationStore.addNotification({ message, type: 'error' });
type: 'negative',
message: err instanceof Error ? err.message : 'Failed to change password',
});
} finally { } finally {
changingPassword.value = false; changingPassword.value = false;
} }
}; };
const onPreferenceChange = async () => { 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 { try {
await apiClient.put(API_ENDPOINTS.USERS.PREFERENCES, preferences.value); await apiClient.put(API_ENDPOINTS.USERS.PREFERENCES, preferences.value);
$q.notify({ notificationStore.addNotification({ message: 'Preferences updated successfully', type: 'success' });
type: 'positive',
message: 'Preferences updated successfully',
});
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to update preferences';
console.error('Failed to update preferences:', err); console.error('Failed to update preferences:', err);
$q.notify({ notificationStore.addNotification({ message, type: 'error' });
type: 'negative', // Optionally revert the toggle if the API call fails
message: err instanceof Error ? err.message : 'Failed to update preferences', // await fetchProfile(); // Or manage state more granularly
});
} }
}; };
onMounted(() => { onMounted(() => {
void fetchProfile(); fetchProfile();
}); });
</script> </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>

View File

@ -1,27 +1,49 @@
<template> <template>
<div class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center"> <div class="fullscreen-error text-center">
<div> <div>
<div style="font-size: 30vh"> <div class="error-code">404</div>
404 <div class="error-message">Oops. Nothing here...</div>
</div> <router-link to="/" class="btn btn-primary mt-3">Go Home</router-link>
<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> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// // No script logic needed for this simple page
</script> </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>

View File

@ -1,79 +1,110 @@
<template> <template>
<q-page padding> <main class="container page-padding">
<div v-if="group"> <div v-if="loading" class="text-center">
<h4 class="q-mt-none q-mb-sm">Group: {{ group.name }}</h4> <div class="spinner-dots" role="status"><span/><span/><span/></div>
<p>Loading group details...</p>
<!-- Invite Code Generation --> </div>
<div class="q-mt-lg"> <div v-else-if="error" class="alert alert-error" role="alert">
<h5>Invite Members</h5> <div class="alert-content">
<q-btn <svg class="icon" aria-hidden="true"><use xlink:href="#icon-alert-triangle" /></svg>
label="Generate Invite Code" {{ error }}
color="secondary"
@click="void generateInviteCode"
:loading="generatingInvite"
/>
<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>
</div>
</div> </div>
</div> </div>
<div v-else-if="loading"><q-spinner-dots size="2em" /> Loading group details...</div> <div v-else-if="group">
<div v-else> <h1 class="mb-3">Group: {{ group.name }}</h1>
<p>Group not found or an error occurred.</p>
<!-- 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
/>
<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>
<!-- Placeholder for lists related to this group -->
<div class="mt-4">
<h2>Lists in this Group</h2>
<ListsPage :group-id="groupId" />
</div>
</div> </div>
</q-page> <div v-else class="alert alert-info" role="status">
<div class="alert-content">Group not found or an error occurred.</div>
</div>
</main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue'; import { ref, onMounted, computed } from 'vue';
import { useRoute } from 'vue-router'; // import { useRoute } from 'vue-router';
import { apiClient, API_ENDPOINTS } from 'src/config/api'; import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path
import { copyToClipboard, useQuasar } from 'quasar'; import { useClipboard } from '@vueuse/core';
import ListsPage from './ListsPage.vue'; // Import ListsPage
import { useNotificationStore } from '@/stores/notifications';
interface Group { interface Group {
id: string; id: string | number; // API might return number
name: string; name: string;
// other properties if needed // other properties if needed
} }
const props = defineProps({ const props = defineProps<{
id: { id: string; // From router param, always string
type: String, }>();
required: true,
},
});
const route = useRoute(); // const route = useRoute();
const $q = useQuasar(); // const $q = useQuasar(); // Not used anymore
const notificationStore = useNotificationStore();
const group = ref<Group | null>(null); 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 inviteCode = ref<string | null>(null);
const generatingInvite = ref(false); const generatingInvite = ref(false);
const copySuccess = 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 () => { const fetchGroupDetails = async () => {
if (!groupId.value) return; if (!groupId.value) return;
loading.value = true; loading.value = true;
error.value = null;
try { 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; group.value = response.data;
} catch (error: unknown) { } catch (err: unknown) {
console.error('Error fetching group details:', error); const message = err instanceof Error ? err.message : 'Failed to fetch group details.';
$q.notify({ error.value = message;
color: 'negative', console.error('Error fetching group details:', err);
message: error instanceof Error ? error.message : 'Failed to fetch group details.', notificationStore.addNotification({ message, type: 'error' });
icon: 'report_problem',
});
} finally { } finally {
loading.value = false; loading.value = false;
} }
@ -83,46 +114,58 @@ const generateInviteCode = async () => {
if (!groupId.value) return; if (!groupId.value) return;
generatingInvite.value = true; generatingInvite.value = true;
inviteCode.value = null; inviteCode.value = null;
copySuccess.value = false;
try { try {
const response = await apiClient.post(API_ENDPOINTS.INVITES.BASE, { 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; inviteCode.value = response.data.invite_code;
$q.notify({ notificationStore.addNotification({ message: 'Invite code generated successfully!', type: 'success' });
color: 'positive', } catch (err: unknown) {
message: 'Invite code generated successfully!', const message = err instanceof Error ? err.message : 'Failed to generate invite code.';
icon: 'check_circle', console.error('Error generating invite code:', err);
}); notificationStore.addNotification({ message, type: 'error' });
} 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',
});
} finally { } finally {
generatingInvite.value = false; generatingInvite.value = false;
} }
}; };
const copyInviteCode = () => { const copyInviteCodeHandler = async () => {
if (inviteCode.value) { if (!clipboardIsSupported.value || !inviteCode.value) {
copyToClipboard(inviteCode.value) notificationStore.addNotification({ message: 'Clipboard not supported or no code to copy.', type: 'warning' });
.then(() => { return;
copySuccess.value = true; }
setTimeout(() => (copySuccess.value = false), 2000); await copy(inviteCode.value);
}) if (copied.value) {
.catch(() => { copySuccess.value = true;
console.error('Failed to copy invite code'); setTimeout(() => (copySuccess.value = false), 2000);
}); // 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(() => { onMounted(() => {
void fetchGroupDetails(); fetchGroupDetails();
}); });
</script> </script>
<style scoped> <style scoped>
/* Add any page-specific styles here */ .page-padding {
</style> 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>

View File

@ -1,129 +1,156 @@
<template> <template>
<q-page padding> <main class="container page-padding">
<div class="row justify-between items-center q-mb-md"> <div class="flex justify-between items-center mb-3">
<h4 class="q-mt-none q-mb-sm">Your Groups</h4> <h1>Your Groups</h1>
<q-btn label="Create Group" color="primary" @click="openCreateGroupDialog" /> <button class="btn btn-primary" @click="openCreateGroupDialog">
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-plus" /></svg>
Create Group
</button>
</div> </div>
<!-- Join Group Section --> <!-- Join Group Section (using details/summary for expansion) -->
<q-expansion-item <details class="card mb-3">
icon="group_add" <summary class="card-header flex items-center cursor-pointer" style="display: flex; justify-content: space-between;">
label="Join a Group with Invite Code" <h3>
class="q-mb-md" <svg class="icon" aria-hidden="true"><use xlink:href="#icon-user" /></svg> <!-- Placeholder icon -->
header-class="bg-grey-2" Join a Group with Invite Code
> </h3>
<q-card> <span class="expand-icon" aria-hidden="true"></span> <!-- Basic expand indicator -->
<q-card-section> </summary>
<q-input <div class="card-body">
v-model="inviteCodeToJoin" <form @submit.prevent="handleJoinGroup" class="flex items-center" style="gap: 0.5rem;">
label="Enter Invite Code" <div class="form-group flex-grow" style="margin-bottom: 0;">
dense <label for="joinInviteCodeInput" class="sr-only">Enter Invite Code</label>
outlined <input
class="q-mb-sm" type="text"
:rules="[(val) => !!val || 'Invite code is required']" id="joinInviteCodeInput"
ref="joinInviteCodeInput" v-model="inviteCodeToJoin"
> class="form-input"
<template v-slot:append> placeholder="Enter Invite Code"
<q-btn required
label="Join" ref="joinInviteCodeInputRef"
color="secondary" />
@click="handleJoinGroup" </div>
:loading="joiningGroup" <button type="submit" class="btn btn-secondary" :disabled="joiningGroup">
dense <span v-if="joiningGroup" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
flat Join
/> </button>
</template> </form>
</q-input> <p v-if="joinGroupFormError" class="form-error-text mt-1">{{ joinGroupFormError }}</p>
</q-card-section> </div>
</q-card> </details>
</q-expansion-item>
<q-list bordered separator> <div v-if="loading" class="text-center">
<q-item <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" v-for="group in groups"
:key="group.id" :key="group.id"
clickable class="list-item interactive-list-item"
v-ripple
@click="selectGroup(group)" @click="selectGroup(group)"
@keydown.enter="selectGroup(group)"
tabindex="0"
> >
<q-item-section> <div class="list-item-content">
<q-item-label>{{ group.name }}</q-item-label> <span class="item-text">{{ group.name }}</span>
</q-item-section> <!-- Could add more details here if needed -->
</q-item> </div>
<q-item v-if="!groups.length && !loading"> </li>
<q-item-section> </ul>
<q-item-label caption>You are not a member of any groups yet.</q-item-label> <div v-else class="card empty-state-card">
</q-item-section> <svg class="icon icon-lg" aria-hidden="true"><use xlink:href="#icon-clipboard" /></svg>
</q-item> <h3>No Groups Yet!</h3>
</q-list> <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"> <!-- Create Group Dialog -->
<q-card style="min-width: 350px"> <div v-if="showCreateGroupDialog" class="modal-backdrop open" @click.self="closeCreateGroupDialog">
<q-card-section> <div class="modal-container" ref="createGroupModalRef" role="dialog" aria-modal="true" aria-labelledby="createGroupTitle">
<div class="text-h6">Create New Group</div> <div class="modal-header">
</q-card-section> <h3 id="createGroupTitle">Create New Group</h3>
<button class="close-button" @click="closeCreateGroupDialog" aria-label="Close">
<q-card-section class="q-pt-none"> <svg class="icon" aria-hidden="true"><use xlink:href="#icon-close" /></svg>
<q-input </button>
dense </div>
v-model="newGroupName" <form @submit.prevent="handleCreateGroup">
autofocus <div class="modal-body">
@keyup.enter="handleCreateGroup" <div class="form-group">
label="Group Name" <label for="newGroupNameInput" class="form-label">Group Name</label>
:rules="[(val) => !!val || 'Group name is required']" <input
ref="newGroupNameInput" type="text"
/> id="newGroupNameInput"
</q-card-section> v-model="newGroupName"
class="form-input"
<q-card-actions align="right" class="text-primary"> required
<q-btn flat label="Cancel" v-close-popup /> ref="newGroupNameInputRef"
<q-btn flat label="Create" @click="handleCreateGroup" :loading="creatingGroup" /> />
</q-card-actions> <p v-if="createGroupFormError" class="form-error-text">{{ createGroupFormError }}</p>
</q-card> </div>
</q-dialog> </div>
</q-page> <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> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, onMounted, nextTick } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { apiClient, API_ENDPOINTS } from 'src/config/api'; import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path
import { useQuasar, QInput } from 'quasar'; import { onClickOutside } from '@vueuse/core';
import { useNotificationStore } from '@/stores/notifications';
interface Group { interface Group {
id: string; id: string | number;
name: string; name: string;
// Add other relevant group properties here
} }
const router = useRouter(); const router = useRouter();
const $q = useQuasar();
const notificationStore = useNotificationStore();
const groups = ref<Group[]>([]); const groups = ref<Group[]>([]);
const loading = ref(true); const loading = ref(true);
const fetchError = ref<string | null>(null);
const showCreateGroupDialog = ref(false); const showCreateGroupDialog = ref(false);
const newGroupName = ref(''); const newGroupName = ref('');
const creatingGroup = ref(false); 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 inviteCodeToJoin = ref('');
const joiningGroup = ref(false); 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 () => { const fetchGroups = async () => {
loading.value = true; loading.value = true;
fetchError.value = null;
try { try {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE); const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
groups.value = Array.isArray(response.data) ? response.data : []; groups.value = Array.isArray(response.data) ? response.data : [];
} catch (error: unknown) { } 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 = []; groups.value = [];
$q.notify({ console.error('Error fetching groups:', error);
color: 'negative', notificationStore.addNotification({ message, type: 'error' });
position: 'top',
message: 'Failed to load groups. Please try again.',
icon: 'report_problem',
});
} finally { } finally {
loading.value = false; loading.value = false;
} }
@ -131,103 +158,126 @@ const fetchGroups = async () => {
const openCreateGroupDialog = () => { const openCreateGroupDialog = () => {
newGroupName.value = ''; newGroupName.value = '';
createGroupFormError.value = null;
showCreateGroupDialog.value = true; showCreateGroupDialog.value = true;
nextTick(() => {
newGroupNameInputRef.value?.focus();
});
}; };
const closeCreateGroupDialog = () => {
showCreateGroupDialog.value = false;
};
onClickOutside(createGroupModalRef, closeCreateGroupDialog);
const handleCreateGroup = async () => { const handleCreateGroup = async () => {
if (!newGroupName.value || newGroupName.value.trim() === '') { if (!newGroupName.value.trim()) {
void newGroupNameInput.value?.validate(); createGroupFormError.value = 'Group name is required';
newGroupNameInputRef.value?.focus();
return; return;
} }
createGroupFormError.value = null;
creatingGroup.value = true; creatingGroup.value = true;
try { try {
const response = await apiClient.post(API_ENDPOINTS.GROUPS.BASE, { const response = await apiClient.post(API_ENDPOINTS.GROUPS.BASE, {
name: newGroupName.value, name: newGroupName.value,
}); });
const newGroup = response.data; const newGroup = response.data as Group;
if (newGroup && newGroup.id && newGroup.name) {
if (newGroup && typeof newGroup.id === 'string' && typeof newGroup.name === 'string') {
groups.value.push(newGroup); groups.value.push(newGroup);
showCreateGroupDialog.value = false; closeCreateGroupDialog();
$q.notify({ notificationStore.addNotification({ message: `Group '${newGroup.name}' created successfully.`, type: 'success' });
color: 'positive',
position: 'top',
message: `Group '${newGroup.name}' created successfully.`,
icon: 'check_circle',
});
} else { } else {
console.error('Invalid group data received from API after creation:', response.data); throw new Error('Invalid data received from server.');
$q.notify({
color: 'negative',
position: 'top',
message: 'Failed to create group: Invalid data received from server.',
icon: 'report_problem',
});
} }
} catch (error: unknown) { } 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); console.error('Error creating group:', error);
$q.notify({ notificationStore.addNotification({ message, type: 'error' });
color: 'negative',
position: 'top',
message: 'Failed to create group. Please try again.',
icon: 'report_problem',
});
} finally { } finally {
creatingGroup.value = false; creatingGroup.value = false;
} }
}; };
const handleJoinGroup = async () => { const handleJoinGroup = async () => {
if (!inviteCodeToJoin.value || inviteCodeToJoin.value.trim() === '') { if (!inviteCodeToJoin.value.trim()) {
void joinInviteCodeInput.value?.validate(); joinGroupFormError.value = 'Invite code is required';
joinInviteCodeInputRef.value?.focus();
return; return;
} }
joinGroupFormError.value = null;
joiningGroup.value = true; joiningGroup.value = true;
try { try {
const response = await apiClient.post(API_ENDPOINTS.INVITES.ACCEPT(inviteCodeToJoin.value)); const response = await apiClient.post(API_ENDPOINTS.INVITES.ACCEPT(inviteCodeToJoin.value));
const joinedGroup = response.data; const joinedGroup = response.data as Group; // Adjust based on actual API response for joined group
if (joinedGroup && joinedGroup.id && joinedGroup.name) {
if (joinedGroup && typeof joinedGroup.id === 'string' && typeof joinedGroup.name === 'string') { // Check if group already in list to prevent duplicates if API returns the group info
groups.value.push(joinedGroup); if (!groups.value.find(g => g.id === joinedGroup.id)) {
groups.value.push(joinedGroup);
}
inviteCodeToJoin.value = ''; inviteCodeToJoin.value = '';
$q.notify({ notificationStore.addNotification({ message: `Successfully joined group '${joinedGroup.name}'.`, type: 'success' });
color: 'positive',
position: 'top',
message: `Successfully joined group '${joinedGroup.name}'.`,
icon: 'check_circle',
});
} else { } else {
console.error('Invalid group data received from API after joining:', response.data); // If API returns only success message, re-fetch groups
$q.notify({ await fetchGroups(); // Refresh the list of groups
color: 'negative', inviteCodeToJoin.value = '';
position: 'top', notificationStore.addNotification({ message: `Successfully joined group.`, type: 'success' });
message: 'Failed to join group: Invalid data received from server.',
icon: 'report_problem',
});
} }
} catch (error: unknown) { } 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); console.error('Error joining group:', error);
$q.notify({ notificationStore.addNotification({ message, type: 'error' });
color: 'negative',
position: 'top',
message: 'Failed to join group. Please check the invite code and try again.',
icon: 'report_problem',
});
} finally { } finally {
joiningGroup.value = false; joiningGroup.value = false;
} }
}; };
const selectGroup = (group: Group) => { const selectGroup = (group: Group) => {
console.log('Selected group:', group); router.push(`/groups/${group.id}`);
void router.push(`/groups/${group.id}`);
}; };
onMounted(() => { onMounted(() => {
void fetchGroups(); fetchGroups();
}); });
</script> </script>
<style scoped> <style scoped>
/* Add any page-specific styles here */ .page-padding { padding: 1rem; }
</style> .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>

View File

@ -1,43 +1,49 @@
<template> <template>
<q-page class="row items-center justify-evenly"> <main class="container page-padding text-center">
<example-component <h1>Welcome to Valerie UI App</h1>
title="Example component" <p class="mb-3">This is the main index page.</p>
active
:todos="todos" <!-- The ExampleComponent is not provided, so this section is a placeholder -->
:meta="meta" <div v-if="todos.length" class="card">
></example-component> <div class="card-header">
</q-page> <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> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import type { Todo, Meta } from 'components/models'; import type { Todo, Meta } from '@/components/models'; // Adjusted path if models.ts is in the same directory
import ExampleComponent from 'components/ExampleComponent.vue'; // import ExampleComponent from 'components/ExampleComponent.vue'; // This component is not provided for conversion
const todos = ref<Todo[]>([ const todos = ref<Todo[]>([
{ { id: 1, content: 'ct1' },
id: 1, { id: 2, content: 'ct2' },
content: 'ct1' { id: 3, content: 'ct3' },
}, { id: 4, content: 'ct4' },
{ { id: 5, content: 'ct5' },
id: 2,
content: 'ct2'
},
{
id: 3,
content: 'ct3'
},
{
id: 4,
content: 'ct4'
},
{
id: 5,
content: 'ct5'
}
]); ]);
const meta = ref<Meta>({ const meta = ref<Meta>({
totalCount: 1200 totalCount: 1200,
}); });
</script> </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

View File

@ -1,98 +1,91 @@
<template> <template>
<q-page padding> <main class="container page-padding">
<h1 class="text-h4 q-mb-md">{{ pageTitle }}</h1> <h1 class="mb-3">{{ pageTitle }}</h1>
<div v-if="loading" class="text-center"> <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> <p>Loading lists...</p>
</div> </div>
<q-banner v-else-if="error" inline-actions class="text-white bg-red q-mb-md"> <div v-else-if="error" class="alert alert-error mb-3" role="alert">
<template v-slot:avatar> <div class="alert-content">
<q-icon name="warning" /> <svg class="icon" aria-hidden="true"><use xlink:href="#icon-alert-triangle" /></svg>
</template> {{ error }}
{{ error }} </div>
<template v-slot:action> <button type="button" class="btn btn-sm btn-danger" @click="fetchListsAndGroups">Retry</button>
<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> </div>
<q-list v-else bordered separator> <div v-else-if="filteredLists.length === 0" class="card empty-state-card">
<q-item <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" v-for="list in filteredLists"
:key="list.id" :key="list.id"
clickable class="list-item interactive-list-item"
v-ripple tabindex="0"
:to="`/lists/${list.id}`" @click="navigateToList(list.id)"
@keydown.enter="navigateToList(list.id)"
> >
<q-item-section> <div class="list-item-content">
<q-item-label>{{ list.name }}</q-item-label> <div class="list-item-main" style="flex-direction: column; align-items: flex-start;">
<q-item-label caption>{{ list.description || 'No description' }}</q-item-label> <span class="item-text" style="font-size: 1.1rem; font-weight: bold;">{{ list.name }}</span>
<q-item-label caption v-if="!list.group_id && !props.groupId"> <small class="item-caption">{{ list.description || 'No description' }}</small>
<q-icon name="person" /> Personal List <small v-if="!list.group_id && !props.groupId" class="item-caption icon-caption">
</q-item-label> <svg class="icon icon-sm"><use xlink:href="#icon-user"/></svg> Personal List
<q-item-label caption v-if="list.group_id && !props.groupId"> </small>
<q-icon name="group" /> Group List (ID: {{ list.group_id }}) <small v-if="list.group_id && !props.groupId" class="item-caption icon-caption">
</q-item-label> <svg class="icon icon-sm"><use xlink:href="#icon-user"/></svg> <!-- Placeholder, group icon not in Valerie -->
</q-item-section> Group List ({{ getGroupName(list.group_id) || `ID: ${list.group_id}`}})
<q-item-section side top> </small>
<q-badge </div>
:color="list.is_complete ? 'green' : 'orange'" <div class="list-item-details" style="flex-direction: column; align-items: flex-end;">
:label="list.is_complete ? 'Complete' : 'Active'" <span class="item-badge" :class="list.is_complete ? 'badge-settled' : 'badge-pending'">
/> {{ list.is_complete ? 'Complete' : 'Active' }}
<q-item-label caption class="q-mt-xs"> </span>
Updated: {{ new Date(list.updated_at).toLocaleDateString() }} <small class="item-caption mt-1">
</q-item-label> Updated: {{ new Date(list.updated_at).toLocaleDateString() }}
</q-item-section> </small>
</q-item> </div>
</q-list> </div>
</li>
</ul>
<q-page-sticky position="bottom-right" :offset="[18, 18]"> <div class="page-sticky-bottom-right">
<q-btn <button
fab class="btn btn-primary btn-icon-only"
color="primary" style="width: 56px; height: 56px; border-radius: 50%; padding: 0;"
icon="add"
@click="showCreateModal = true" @click="showCreateModal = true"
:label="currentGroupId ? 'Create Group List' : 'Create List'" :aria-label="currentGroupId ? 'Create Group List' : 'Create List'"
/> data-tooltip="Create New List"
</q-page-sticky> >
<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 <CreateListModal
v-model="showCreateModal" v-model="showCreateModal"
:groups="availableGroups" :groups="availableGroupsForModal"
@created="fetchLists" @created="onListCreated"
/> />
</q-page> </main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue'; import { ref, onMounted, computed, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { apiClient, API_ENDPOINTS } from 'src/config/api'; import { apiClient, API_ENDPOINTS } from '@/config/api';
import CreateListModal from 'components/CreateListModal.vue'; import CreateListModal from '@/components/CreateListModal.vue'; // Adjusted path
import {
QSpinnerDots,
QBanner,
QIcon,
QList,
QItem,
QItemSection,
QItemLabel,
QBadge,
QBtn,
QPageSticky,
} from 'quasar'; // Explicitly import Quasar components
interface List { interface List {
id: number; id: number;
@ -112,46 +105,57 @@ interface Group {
} }
const props = defineProps<{ 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 route = useRoute();
const router = useRouter();
const loading = ref(true); const loading = ref(true);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const lists = ref<List[]>([]); const lists = ref<List[]>([]);
const availableGroups = ref<{ label: string; value: number }[]>([]); const allFetchedGroups = ref<Group[]>([]); // Store all groups user has access to for display
const groupName = ref<string | null>(null); const currentViewedGroup = ref<Group | null>(null); // For the title if on a specific group's list page
const showCreateModal = ref(false); const showCreateModal = ref(false);
const currentGroupId = computed(() => { const currentGroupId = computed<number | null>(() => {
if (props.groupId) { const idFromProp = props.groupId;
return typeof props.groupId === 'string' ? parseInt(props.groupId, 10) : props.groupId; const idFromRoute = route.params.groupId;
if (idFromProp) {
return typeof idFromProp === 'string' ? parseInt(idFromProp, 10) : idFromProp;
} }
if (route.params.groupId) { if (idFromRoute) {
return parseInt(route.params.groupId as string, 10); return parseInt(idFromRoute as string, 10);
} }
return null; // No specific group selected, show personal lists or all accessible return null;
}); });
const fetchGroupName = async () => { const fetchCurrentViewGroupName = async () => {
if (!currentGroupId.value) return; 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 { try {
const response = await apiClient.get( const response = await apiClient.get(API_ENDPOINTS.GROUPS.BY_ID(String(currentGroupId.value)));
API_ENDPOINTS.GROUPS.BY_ID(String(currentGroupId.value)) currentViewedGroup.value = response.data as Group;
);
groupName.value = (response.data as Group).name;
} catch (err) { } catch (err) {
console.error('Failed to fetch group name:', err); console.error(`Failed to fetch group name for ID ${currentGroupId.value}:`, err);
groupName.value = null; currentViewedGroup.value = null; // Set to null if fetch fails
} }
}; };
const pageTitle = computed(() => { const pageTitle = computed(() => {
if (currentGroupId.value) { if (currentGroupId.value) {
return groupName.value return currentViewedGroup.value
? `Lists for ${groupName.value}` ? `Lists for ${currentViewedGroup.value.name}`
: `Lists for Group ${currentGroupId.value}`; : `Lists for Group ${currentGroupId.value}`;
} }
return 'All My Lists'; return 'All My Lists';
@ -161,18 +165,16 @@ const noListsMessage = computed(() => {
if (currentGroupId.value) { if (currentGroupId.value) {
return 'No lists found for this group.'; 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 { try {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE); const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
availableGroups.value = (response.data as Group[]).map((group) => ({ allFetchedGroups.value = (response.data as Group[]);
label: group.name,
value: group.id,
}));
} catch (err) { } 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; loading.value = true;
error.value = null; error.value = null;
try { try {
// If currentGroupId is set, fetch lists for that group. Otherwise, fetch all user's lists.
const endpoint = currentGroupId.value const endpoint = currentGroupId.value
? API_ENDPOINTS.GROUPS.LISTS(String(currentGroupId.value)) ? API_ENDPOINTS.GROUPS.LISTS(String(currentGroupId.value))
: API_ENDPOINTS.LISTS.BASE; : API_ENDPOINTS.LISTS.BASE;
const response = await apiClient.get(endpoint); const response = await apiClient.get(endpoint);
lists.value = response.data as List[]; lists.value = response.data as List[];
} catch (err: unknown) { } catch (err: unknown) {
console.error('Failed to fetch lists:', err); error.value = err instanceof Error ? err.message : 'Failed to fetch lists.';
error.value = console.error(error.value, err);
err instanceof Error ? err.message : 'An unexpected error occurred while fetching lists.';
} finally { } finally {
loading.value = false; loading.value = false;
} }
}; };
onMounted(() => { const fetchListsAndGroups = async () => {
void fetchLists(); loading.value = true;
void fetchGroups(); await Promise.all([
void fetchGroupName(); 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(() => { const getGroupName = (groupId?: number | null): string | undefined => {
if (currentGroupId.value) { if (!groupId) return undefined;
return lists.value.filter((list) => list.group_id === currentGroupId.value); 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> </script>
<style scoped> <style scoped>
.q-item__label--caption .q-icon { .page-padding { padding: 1rem; }
margin-right: 4px; .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);
} }
</style> .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>

View File

@ -1,97 +1,109 @@
<template> <template>
<q-page class="flex flex-center"> <main class="flex items-center justify-center page-container">
<q-card class="login-card"> <div class="card login-card">
<q-card-section> <div class="card-header">
<div class="text-h6">Login</div> <h3>Login</h3>
</q-card-section> </div>
<div class="card-body">
<q-card-section> <form @submit.prevent="onSubmit" class="form-layout">
<q-form @submit="onSubmit" class="q-gutter-md"> <div class="form-group mb-2">
<q-input <label for="email" class="form-label">Email</label>
v-model="email" <input
label="Email" type="email"
type="email" id="email"
:rules="[(val) => !!val || 'Email is required', isValidEmail]" v-model="email"
/> class="form-input"
required
<q-input autocomplete="email"
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"
/> />
<p v-if="formErrors.email" class="form-error-text">{{ formErrors.email }}</p>
</div> </div>
<div class="text-center q-mt-sm"> <div class="form-group mb-3">
<router-link to="/signup" class="text-primary" <label for="password" class="form-label">Password</label>
>Don't have an account? Sign up</router-link <div class="input-with-icon-append">
> <input
:type="isPwdVisible ? 'text' : 'password'"
id="password"
v-model="password"
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>
</q-form>
</q-card-section> <p v-if="formErrors.general" class="alert alert-error form-error-text">{{ formErrors.general }}</p>
</q-card>
</q-page> <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>
</form>
</div>
</div>
</main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import { useQuasar } from 'quasar'; import { useAuthStore } from '@/stores/auth'; // Assuming path
import { useAuthStore } from 'stores/auth'; import { useNotificationStore } from '@/stores/notifications';
const $q = useQuasar();
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const authStore = useAuthStore(); const authStore = useAuthStore();
const notificationStore = useNotificationStore();
const email = ref(''); const email = ref('');
const password = ref(''); const password = ref('');
const isPwd = ref(true); const isPwdVisible = ref(false);
const loading = ref(false); const loading = ref(false);
const formErrors = ref<{ email?: string; password?: string; general?: string }>({});
const isValidEmail = (val: string) => { const isValidEmail = (val: string): boolean => {
const emailPattern = 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}$/;
/^(?=[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);
return emailPattern.test(val) || 'Invalid email'; };
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 () => { const onSubmit = async () => {
if (!validateForm()) {
return;
}
loading.value = true;
formErrors.value.general = undefined; // Clear previous general errors
try { try {
loading.value = true;
await authStore.login(email.value, password.value); await authStore.login(email.value, password.value);
notificationStore.addNotification({ message: 'Login successful', type: 'success' });
$q.notify({
color: 'positive',
message: 'Login successful',
position: 'top',
});
// Redirect to the originally requested page or home
const redirectPath = (route.query.redirect as string) || '/'; const redirectPath = (route.query.redirect as string) || '/';
await router.push(redirectPath); router.push(redirectPath);
} catch (error: unknown) { } catch (error: unknown) {
$q.notify({ const message = error instanceof Error ? error.message : 'Login failed. Please check your credentials.';
color: 'negative', formErrors.value.general = message;
message: error instanceof Error ? error.message : 'Login failed', console.error(message, error);
position: 'top', notificationStore.addNotification({ message, type: 'error' });
});
} finally { } finally {
loading.value = false; loading.value = false;
} }
@ -99,9 +111,62 @@ const onSubmit = async () => {
</script> </script>
<style scoped> <style scoped>
.page-container {
min-height: 100vh; /* dvh for dynamic viewport height */
min-height: 100dvh;
padding: 1rem;
}
.login-card { .login-card {
width: 100%; width: 100%;
max-width: 400px; max-width: 400px;
padding: 20px;
} }
</style> /* 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>

View File

@ -1,118 +1,135 @@
<template> <template>
<q-page class="flex flex-center"> <main class="flex items-center justify-center page-container">
<q-card class="signup-card"> <div class="card signup-card">
<q-card-section> <div class="card-header">
<div class="text-h6">Sign Up</div> <h3>Sign Up</h3>
</q-card-section> </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> <div class="form-group mb-2">
<q-form @submit="onSubmit" class="q-gutter-md"> <label for="email" class="form-label">Email</label>
<q-input <input type="email" id="email" v-model="email" class="form-input" required autocomplete="email"/>
v-model="name" <p v-if="formErrors.email" class="form-error-text">{{ formErrors.email }}</p>
label="Full Name" </div>
:rules="[(val) => !!val || 'Name is required']"
/>
<q-input <div class="form-group mb-2">
v-model="email" <label for="password" class="form-label">Password</label>
label="Email" <div class="input-with-icon-append">
type="email" <input
:rules="[(val) => !!val || 'Email is required', isValidEmail]" :type="isPwdVisible ? 'text' : 'password'"
/> id="password"
v-model="password"
class="form-input"
required
autocomplete="new-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>
<q-input <div class="form-group mb-3">
v-model="password" <label for="confirmPassword" class="form-label">Confirm Password</label>
label="Password" <input
:type="isPwd ? 'password' : 'text'" :type="isPwdVisible ? 'text' : 'password'"
:rules="[ id="confirmPassword"
(val) => !!val || 'Password is required', v-model="confirmPassword"
(val) => val.length >= 8 || 'Password must be at least 8 characters', class="form-input"
]" required
> autocomplete="new-password"
<template v-slot:append>
<q-icon
:name="isPwd ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="isPwd = !isPwd"
/>
</template>
</q-input>
<q-input
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"
/> />
<p v-if="formErrors.confirmPassword" class="form-error-text">{{ formErrors.confirmPassword }}</p>
</div> </div>
<p v-if="formErrors.general" class="alert alert-error form-error-text">{{ formErrors.general }}</p>
<div class="text-center q-mt-sm"> <button type="submit" class="btn btn-primary w-full mt-2" :disabled="loading">
<router-link to="/login" class="text-primary" <span v-if="loading" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
>Already have an account? Login</router-link 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> </div>
</q-form> </form>
</q-card-section> </div>
</q-card> </div>
</q-page> </main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useQuasar } from 'quasar'; import { useAuthStore } from 'stores/auth'; // Assuming path
import { useAuthStore } from 'stores/auth'; import { useNotificationStore } from '@/stores/notifications';
const $q = useQuasar();
const router = useRouter(); const router = useRouter();
const authStore = useAuthStore(); const authStore = useAuthStore();
const notificationStore = useNotificationStore();
const name = ref(''); const name = ref('');
const email = ref(''); const email = ref('');
const password = ref(''); const password = ref('');
const confirmPassword = ref(''); const confirmPassword = ref('');
const isPwd = ref(true); const isPwdVisible = ref(false);
const loading = ref(false); const loading = ref(false);
const formErrors = ref<{ name?: string; email?: string; password?: string; confirmPassword?: string; general?: string }>({});
const isValidEmail = (val: string) => { const isValidEmail = (val: string): boolean => {
const emailPattern = 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}$/;
/^(?=[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);
return emailPattern.test(val) || 'Invalid email'; };
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 () => { const onSubmit = async () => {
if (!validateForm()) {
return;
}
loading.value = true;
formErrors.value.general = undefined;
try { try {
loading.value = true;
await authStore.signup({ await authStore.signup({
name: name.value, name: name.value,
email: email.value, email: email.value,
password: password.value, password: password.value,
}); });
notificationStore.addNotification({ message: 'Account created successfully. Please login.', type: 'success' });
$q.notify({ router.push('auth/login');
color: 'positive',
message: 'Account created successfully',
position: 'top',
});
await router.push('/login');
} catch (error: unknown) { } catch (error: unknown) {
$q.notify({ const message = error instanceof Error ? error.message : 'Signup failed. Please try again.';
color: 'negative', formErrors.value.general = message;
message: error instanceof Error ? error.message : 'Signup failed', console.error(message, error);
position: 'top', notificationStore.addNotification({ message, type: 'error' });
});
} finally { } finally {
loading.value = false; loading.value = false;
} }
@ -120,9 +137,63 @@ const onSubmit = async () => {
</script> </script>
<style scoped> <style scoped>
/* Using styles from LoginPage.vue where applicable */
.page-container {
min-height: 100vh;
min-height: 100dvh;
padding: 1rem;
}
.signup-card { .signup-card {
width: 100%; width: 100%;
max-width: 400px; max-width: 400px;
padding: 20px;
} }
</style> /* 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>

View File

@ -1,61 +1,32 @@
import { defineRouter } from '#q-app/wrappers'; // src/router/index.ts
import { import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router';
createMemoryHistory,
createRouter,
createWebHashHistory,
createWebHistory,
} from 'vue-router';
import routes from './routes'; import routes from './routes';
import { useAuthStore } from 'stores/auth'; import { useAuthStore } from '../stores/auth';
/* const history = import.meta.env.VITE_ROUTER_MODE === 'history'
* If not building with SSR mode, you can ? createWebHistory(import.meta.env.BASE_URL)
* directly export the Router instantiation; : createWebHashHistory(import.meta.env.BASE_URL);
*
* The function below can be async too; either use
* async/await or return a Promise which resolves
* with the Router instance.
*/
export default defineRouter(function (/* { store, ssrContext } */) { const router = createRouter({
const createHistory = process.env.SERVER history,
? createMemoryHistory routes,
: process.env.VUE_ROUTER_MODE === 'history' scrollBehavior: () => ({ left: 0, top: 0 }),
? createWebHistory
: createWebHashHistory;
const Router = createRouter({
scrollBehavior: () => ({ left: 0, top: 0 }),
routes,
// Leave this as is and make changes in quasar.conf.js instead!
// quasar.conf.js -> build -> vueRouterMode
// quasar.conf.js -> build -> publicPath
history: createHistory(process.env.VUE_ROUTER_BASE),
});
// Navigation guard to check authentication
Router.beforeEach((to, from, next) => {
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 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 } });
} else if (!requiresAuth && isAuthenticated) {
// Redirect to home if trying to access login/signup while authenticated
next({ path: '/' });
} else {
// Proceed with navigation
next();
}
});
return Router;
}); });
router.beforeEach(async (to, from, next) => {
// Auth guard logic
const authStore = useAuthStore();
const isAuthenticated = authStore.isAuthenticated;
const publicRoutes = ['/auth/login', '/auth/signup']; // Fixed public routes paths
const requiresAuth = !publicRoutes.includes(to.path);
if (requiresAuth && !isAuthenticated) {
next({ path: '/auth/login', query: { redirect: to.fullPath } }); // Fixed login path with leading slash
} else if (!requiresAuth && isAuthenticated) {
next({ path: '/' });
} else {
next();
}
});
export default router;

View File

@ -1,50 +1,48 @@
// src/router/routes.ts
// Adapt paths to new component locations
import type { RouteRecordRaw } from 'vue-router'; import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
{ {
path: '/', path: '/',
component: () => import('layouts/MainLayout.vue'), component: () => import('../layouts/MainLayout.vue'), // Use .. alias
children: [ children: [
{ path: '', redirect: '/lists' }, { path: '', redirect: '/lists' },
{ path: 'lists', name: 'PersonalLists', component: () => import('pages/ListsPage.vue') }, { path: 'lists', name: 'PersonalLists', component: () => import('../pages/ListsPage.vue') },
{ {
path: 'lists/:id', path: 'lists/:id',
name: 'ListDetail', name: 'ListDetail',
component: () => import('pages/ListDetailPage.vue'), component: () => import('../pages/ListDetailPage.vue'),
props: true, props: true,
}, },
{ path: 'groups', name: 'GroupsList', component: () => import('pages/GroupsPage.vue') }, { path: 'groups', name: 'GroupsList', component: () => import('../pages/GroupsPage.vue') },
{ {
path: 'groups/:id', path: 'groups/:id',
name: 'GroupDetail', name: 'GroupDetail',
component: () => import('pages/GroupDetailPage.vue'), component: () => import('../pages/GroupDetailPage.vue'),
props: true, props: true,
}, },
{ {
path: 'groups/:groupId/lists', path: 'groups/:groupId/lists',
name: 'GroupLists', name: 'GroupLists',
component: () => import('pages/ListsPage.vue'), component: () => import('../pages/ListsPage.vue'), // Reusing ListsPage
props: true, props: true,
}, },
{ path: 'account', name: 'Account', component: () => import('pages/AccountPage.vue') }, { path: 'account', name: 'Account', component: () => import('../pages/AccountPage.vue') },
], ],
}, },
{ {
path: '/', path: '/auth', // Group auth routes under a common path for clarity (optional)
component: () => import('layouts/AuthLayout.vue'), component: () => import('../layouts/AuthLayout.vue'),
children: [ children: [
{ path: 'login', component: () => import('pages/LoginPage.vue') }, { path: 'login', name: 'Login', component: () => import('../pages/LoginPage.vue') },
{ path: 'signup', component: () => import('pages/SignupPage.vue') }, { path: 'signup', name: 'Signup', component: () => import('../pages/SignupPage.vue') },
], ],
}, },
// {
// Always leave this as last one, // path: '/:catchAll(.*)*', name: '404',
// but you can also remove it // component: () => import('../pages/ErrorNotFound.vue'),
{ // },
path: '/:catchAll(.*)*',
component: () => import('pages/ErrorNotFound.vue'),
},
]; ];
export default routes; export default routes;

91
fe/src/services/api.ts Normal file
View 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

View File

@ -1,6 +1,8 @@
import { API_ENDPOINTS } from '@/config/api-config';
import { apiClient } from '@/services/api';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { apiClient, API_ENDPOINTS } from 'src/config/api';
interface AuthState { interface AuthState {
accessToken: string | null; accessToken: string | null;

View File

@ -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
})

View 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,
};
});

View File

@ -1,64 +1,40 @@
// src/stores/offline.ts (Example modification)
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { useQuasar } from 'quasar'; // import { LocalStorage } from 'quasar'; // REMOVE
import { LocalStorage } from 'quasar'; import { useStorage } from '@vueuse/core'; // VueUse alternative
import { useNotificationStore } from '@/stores/notifications'; // Your custom notification store
export interface OfflineAction { // ... (interfaces remain the same)
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;
}
export const useOfflineStore = defineStore('offline', () => { export const useOfflineStore = defineStore('offline', () => {
const $q = useQuasar(); // const $q = useQuasar(); // REMOVE
const notificationStore = useNotificationStore();
const isOnline = ref(navigator.onLine); 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 isProcessingQueue = ref(false);
const showConflictDialog = ref(false); const showConflictDialog = ref(false); // You'll need to implement this dialog
const currentConflict = ref<ConflictData | null>(null); const currentConflict = ref<ConflictData | null>(null);
// Initialize from IndexedDB // init is now handled by useStorage automatically loading the value
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);
}
};
// Save to IndexedDB // saveToStorage is also handled by useStorage automatically saving on change
const saveToStorage = () => {
try {
LocalStorage.set('offline-actions', JSON.stringify(pendingActions.value));
} catch (error) {
console.error('Failed to save offline actions:', error);
}
};
// Add a new offline action
const addAction = (action: Omit<OfflineAction, 'id' | 'timestamp'>) => { const addAction = (action: Omit<OfflineAction, 'id' | 'timestamp'>) => {
const newAction: OfflineAction = { const newAction: OfflineAction = {
...action, ...action,
@ -66,91 +42,89 @@ export const useOfflineStore = defineStore('offline', () => {
timestamp: Date.now(), timestamp: Date.now(),
}; };
pendingActions.value.push(newAction); pendingActions.value.push(newAction);
saveToStorage(); // useStorage handles saving
}; };
// Process the queue when online
const processQueue = async () => { const processQueue = async () => {
if (isProcessingQueue.value || !isOnline.value) return; if (isProcessingQueue.value || !isOnline.value) return;
isProcessingQueue.value = true; 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 { 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); pendingActions.value = pendingActions.value.filter(a => a.id !== action.id);
saveToStorage();
} catch (error) { } catch (error) {
if (error instanceof Error && error.message.includes('409')) { if (error instanceof Error && 'response' in error && typeof error.response === 'object' && error.response && 'status' in error.response && error.response.status === 409) {
$q.notify({ notificationStore.addNotification({
type: 'warning', type: 'warning',
message: 'Item was modified by someone else while you were offline. Please review.', message: 'Item was modified by someone else. Please review.',
actions: [ // actions: [ ... ] // Custom actions for notifications would be more complex
{
label: 'Review',
color: 'white',
handler: () => {
// TODO: Implement conflict resolution UI
}
}
]
}); });
// 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 { } 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; isProcessingQueue.value = false;
}; };
// Process a single action // processAction needs to be implemented with your actual API calls
const processAction = async (action: OfflineAction) => { const processAction = async (action: OfflineAction) => {
// TODO: Implement actual API calls console.log('Processing action (TODO: Implement API call):', action);
switch (action.type) { // Example:
case 'add': // import { apiClient } from '@/services/api';
// await api.addItem(action.data); // import { API_ENDPOINTS } from '@/config/api-config';
break; // switch (action.type) {
case 'complete': // case 'add':
// await api.completeItem(action.itemId, action.data); // // Assuming action.data is { listId: string, itemData: { name: string, quantity?: string } }
break; // // await apiClient.post(API_ENDPOINTS.LISTS.ITEMS(action.data.listId), action.data.itemData);
case 'update': // break;
// await api.updateItem(action.itemId, action.data); // // ... other cases
break; // }
case 'delete': // Simulate async work
// await api.deleteItem(action.itemId); return new Promise(resolve => setTimeout(resolve, 500));
break;
}
}; };
// Listen for online/offline status changes
const setupNetworkListeners = () => { const setupNetworkListeners = () => {
window.addEventListener('online', () => { window.addEventListener('online', () => {
(async () => { isOnline.value = true;
isOnline.value = true; processQueue().catch(err => console.error("Error processing queue on online event:", err));
await processQueue(); });
})().catch(error => { window.addEventListener('offline', () => {
console.error('Error processing queue:', error); isOnline.value = false;
}); });
});
window.addEventListener('offline', () => {
isOnline.value = false;
});
}; };
setupNetworkListeners(); // Call this once
// Computed properties
const hasPendingActions = computed(() => pendingActions.value.length > 0); const hasPendingActions = computed(() => pendingActions.value.length > 0);
const pendingActionCount = computed(() => pendingActions.value.length); const pendingActionCount = computed(() => pendingActions.value.length);
// Initialize const handleConflictResolution = (resolution: { version: 'local' | 'server' | 'merge'; action: OfflineAction; mergedData?: Record<string, unknown> }) => {
init(); console.log('Conflict resolution chosen:', resolution);
setupNetworkListeners(); // TODO: Implement logic to apply the chosen resolution
// This might involve making another API call with the resolved data
const handleConflictResolution = (resolution: ConflictResolution) => { // or updating local state and then trying to sync again.
// Implement the logic to handle the conflict resolution // After resolving, remove the action from pending or mark as resolved.
console.log('Conflict resolution:', resolution); // 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 { return {

View File

@ -1,8 +1,8 @@
/* // src/sw.ts
* This file (which will be your service worker) // Make sure this file is in src/ as configured in vite.config.ts (srcDir & filename)
* is picked up by the build system ONLY if
* quasar.config file > pwa > workboxMode is set to "InjectManifest" // Ensure globalThis.skipWaiting and clientsClaim are available in the SW scope
*/ /// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope & declare const self: ServiceWorkerGlobalScope &
typeof globalThis & { skipWaiting: () => Promise<void> }; typeof globalThis & { skipWaiting: () => Promise<void> };
@ -25,13 +25,13 @@ self.skipWaiting().catch((error) => {
clientsClaim(); clientsClaim();
// Use with precache injection // 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(); cleanupOutdatedCaches();
// Cache app shell and static assets with Cache First strategy // Cache app shell and static assets with Cache First strategy
registerRoute( registerRoute(
// Match static assets
({ request }) => ({ request }) =>
request.destination === 'style' || request.destination === 'style' ||
request.destination === 'script' || request.destination === 'script' ||
@ -53,8 +53,7 @@ registerRoute(
// Cache API calls with Network First strategy // Cache API calls with Network First strategy
registerRoute( registerRoute(
// Match API calls ({ url }) => url.pathname.startsWith('/api/'), // Make sure this matches your actual API path structure
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({ new NetworkFirst({
cacheName: 'api-cache', cacheName: 'api-cache',
plugins: [ plugins: [
@ -71,10 +70,14 @@ registerRoute(
// Non-SSR fallbacks to index.html // Non-SSR fallbacks to index.html
// Production SSR fallbacks to offline.html (except for dev) // 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( registerRoute(
new NavigationRoute(createHandlerBoundToURL(process.env.PWA_FALLBACK_HTML), { new NavigationRoute(createHandlerBoundToURL(PWA_FALLBACK_HTML), {
denylist: [new RegExp(process.env.PWA_SERVICE_WORKER_REGEX), /workbox-(.)*\.js$/], denylist: [PWA_SERVICE_WORKER_REGEX, /workbox-(.)*\.js$/],
}), }),
); );
} }

12
fe/tsconfig.app.json Normal file
View 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/*"]
}
}
}

View File

@ -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
View 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
View 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
View 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
View 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

File diff suppressed because one or more lines are too long