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_REDOC_URL: str = "/api/redoc"
CORS_ORIGINS: list[str] = [
"http://localhost:5174",
"http://localhost:5173",
"http://localhost:8000",
"http://localhost:9000",
# Add your deployed frontend URL here later
# "https://your-frontend-domain.com",
]

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

1
fe/.gitattributes vendored Normal file
View File

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

44
fe/.gitignore vendored
View File

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

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",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

View File

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

View File

@ -1,16 +1,13 @@
{
"editor.bracketPairColorization.enabled": true,
"editor.guides.bracketPairs": true,
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"tsconfig.json": "tsconfig.*.json, env.d.ts",
"vite.config.*": "jsconfig*, vitest.config.*, cypress.config.*, playwright.config.*",
"package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .oxlint*, oxlint*, .prettier*, prettier*, .editorconfig"
},
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": [
"source.fixAll.eslint"
],
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"vue"
],
"typescript.tsdk": "node_modules/typescript/lib"
"editor.defaultFormatter": "esbenp.prettier-vscode"
}

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
```bash
yarn
# or
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Start the app in development mode (hot-code reloading, error reporting, etc.)
```bash
quasar dev
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
### Lint the files
```bash
yarn lint
# or
```sh
npm run build
```
### Run Unit Tests with [Vitest](https://vitest.dev/)
```sh
npm run test:unit
```
### Run End-to-End Tests with [Playwright](https://playwright.dev)
```sh
# Install browsers for the first run
npx playwright install
# When testing on CI, must build the project first
npm run build
# Runs the end-to-end tests
npm run test:e2e
# Runs the tests only on Chromium
npm run test:e2e -- --project=chromium
# Runs the tests of a specific file
npm run test:e2e -- tests/example.spec.ts
# Runs the tests in debug mode
npm run test:e2e -- --debug
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```
### Format the files
```bash
yarn format
# or
npm run format
```
### Build the app for production
```bash
quasar build
```
### Customize the configuration
See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js).

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>
<html>
<html lang="en">
<head>
<title><%= productName %></title>
<meta charset="utf-8">
<meta name="description" content="<%= productDescription %>">
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <!-- Or your favicon -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="mitlist pwa">
<meta name="format-detection" content="telephone=no">
<meta name="msapplication-tap-highlight" content="no">
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>">
<link rel="icon" type="image/png" sizes="128x128" href="icons/favicon-128x128.png">
<link rel="icon" type="image/png" sizes="96x96" href="icons/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png">
<link rel="icon" type="image/ico" href="favicon.ico">
<!-- PWA manifest and theme color will be injected by vite-plugin-pwa -->
<title>mitlist</title>
</head>
<body>
<!-- quasar:entry-point -->
<svg width="0" height="0" style="position: absolute">
<defs>
<symbol viewBox="0 0 24 24" id="icon-plus"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" /></symbol>
<symbol viewBox="0 0 24 24" id="icon-edit"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" /></symbol>
<symbol viewBox="0 0 24 24" id="icon-trash"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" /></symbol>
<symbol viewBox="0 0 24 24" id="icon-check"><path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" /></symbol>
<symbol viewBox="0 0 24 24" id="icon-close"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" /></symbol>
<symbol viewBox="0 0 24 24" id="icon-alert-triangle"><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" /></symbol>
<symbol viewBox="0 0 24 24" id="icon-clipboard"><path d="M16 2H8C6.9 2 6 2.9 6 4v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-4 18c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm4-10H8V8h8v2zm2-4V4l4 4h-4z" /></symbol>
<symbol viewBox="0 0 24 24" id="icon-info"><path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></symbol>
<symbol viewBox="0 0 24 24" id="icon-settings"><path d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z"/></symbol>
<symbol viewBox="0 0 24 24" id="icon-user"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></symbol>
<symbol viewBox="0 0 24 24" id="icon-bell"> <path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2zm-2 1H8v-6c0-2.21 1.79-4 4-4s4 1.79 4 4v6z"/> </symbol>
</defs>
</svg>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

6429
fe/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

110
fe/playwright.config.ts Normal file
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>
<router-view />
<NotificationDisplay /> <!-- For custom notifications -->
</template>
<script setup lang="ts">
//
import NotificationDisplay from '@/components/global/NotificationDisplay.vue';
// Potentially initialize offline store or other global listeners here if needed
// import { useOfflineStore } from './stores/offline';
// const offlineStore = useOfflineStore();
// offlineStore.init(); // If you move init logic here
</script>
<style lang="scss">
body {
margin: 0;
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2c3e50;
background-color: #f0f2f5; /* Example background */
}
#app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
</style>

1
fe/src/assets/logo.svg Normal file
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>
<q-dialog v-model="show" persistent>
<q-card style="min-width: 600px">
<q-card-section>
<div class="text-h6">Conflict Resolution</div>
<div class="text-subtitle2 q-mt-sm">
<div v-if="show" class="modal-backdrop open" @click.self="closeDialog">
<div class="modal-container" style="min-width: 600px" ref="modalContentRef" role="dialog" aria-modal="true" aria-labelledby="conflictDialogTitle">
<div class="modal-header">
<h3 id="conflictDialogTitle">Conflict Resolution</h3>
</div>
<div class="modal-body">
<p class="mb-2">
This item was modified while you were offline. Please review the changes and choose how to resolve the conflict.
</div>
</q-card-section>
</p>
<q-card-section class="q-pt-none">
<q-tabs
v-model="activeTab"
class="text-primary"
active-color="primary"
indicator-color="primary"
align="justify"
narrow-indicator
<div class="tabs">
<ul class="tab-list" role="tablist" aria-label="Conflict Resolution Options">
<li
class="tab-item"
role="tab"
:aria-selected="activeTab === 'compare'"
:tabindex="activeTab === 'compare' ? 0 : -1"
@click="activeTab = 'compare'"
@keydown.enter.space="activeTab = 'compare'"
>
<q-tab name="compare" label="Compare Versions" />
<q-tab name="merge" label="Merge Changes" />
</q-tabs>
Compare Versions
</li>
<li
class="tab-item"
role="tab"
:aria-selected="activeTab === 'merge'"
:tabindex="activeTab === 'merge' ? 0 : -1"
@click="activeTab = 'merge'"
@keydown.enter.space="activeTab = 'merge'"
>
Merge Changes
</li>
</ul>
<q-tab-panels v-model="activeTab" animated>
<!-- Compare Versions Tab -->
<q-tab-panel name="compare">
<div class="row q-col-gutter-md">
<div v-if="activeTab === 'compare'" class="tab-content" role="tabpanel" aria-labelledby="tab-compare">
<div class="flex" style="gap: 1rem;">
<!-- Local Version -->
<div class="col-6">
<q-card flat bordered>
<q-card-section>
<div class="text-subtitle1">Your Version</div>
<div class="text-caption">
Last modified: {{ formatDate(conflictData?.localVersion.timestamp ?? 0) }}
<div class="card flex-grow" style="width: 50%;">
<div class="card-header">
<h4>Your Version</h4>
</div>
<div class="card-body">
<p class="text-caption mb-1">
Last modified: {{ formatDate(conflictData?.localVersion.timestamp ?? 0) }}
</p>
<ul class="item-list simple-list">
<li v-for="(value, key) in conflictData?.localVersion.data" :key="key" class="list-item-simple">
<strong class="text-caption-strong">{{ formatKey(key) }}</strong>
<span :class="{ 'text-positive-inline': isDifferent(key as string) }">{{ formatValue(value) }}</span>
</li>
</ul>
</div>
</q-card-section>
<q-card-section class="q-pt-none">
<q-list>
<q-item v-for="(value, key) in conflictData?.localVersion.data" :key="key">
<q-item-section>
<q-item-label class="text-caption text-grey">
{{ formatKey(key) }}
</q-item-label>
<q-item-label :class="{ 'text-positive': isDifferent(key) }">
{{ formatValue(value) }}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
</div>
<!-- Server Version -->
<div class="col-6">
<q-card flat bordered>
<q-card-section>
<div class="text-subtitle1">Server Version</div>
<div class="text-caption">
<div class="card flex-grow" style="width: 50%;">
<div class="card-header">
<h4>Server Version</h4>
</div>
<div class="card-body">
<p class="text-caption mb-1">
Last modified: {{ formatDate(conflictData?.serverVersion.timestamp ?? 0) }}
</div>
</q-card-section>
<q-card-section class="q-pt-none">
<q-list>
<q-item v-for="(value, key) in conflictData?.serverVersion.data" :key="key">
<q-item-section>
<q-item-label class="text-caption text-grey">
{{ formatKey(key) }}
</q-item-label>
<q-item-label :class="{ 'text-positive': isDifferent(key) }">
{{ formatValue(value) }}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
</p>
<ul class="item-list simple-list">
<li v-for="(value, key) in conflictData?.serverVersion.data" :key="key" class="list-item-simple">
<strong class="text-caption-strong">{{ formatKey(key) }}</strong>
<span :class="{ 'text-positive-inline': isDifferent(key as string) }">{{ formatValue(value) }}</span>
</li>
</ul>
</div>
</div>
</div>
</div>
</q-tab-panel>
<!-- Merge Changes Tab -->
<q-tab-panel name="merge">
<q-card flat bordered>
<q-card-section>
<div class="text-subtitle1">Merge Changes</div>
<div class="text-caption">
Select which version to keep for each field
<div v-if="activeTab === 'merge'" class="tab-content" role="tabpanel" aria-labelledby="tab-merge">
<div class="card">
<div class="card-header">
<h4>Merge Changes</h4>
</div>
</q-card-section>
<q-card-section class="q-pt-none">
<q-list>
<q-item v-for="(value, key) in conflictData?.localVersion.data" :key="key">
<q-item-section>
<q-item-label class="text-caption text-grey">
{{ formatKey(key) }}
</q-item-label>
<div class="row q-col-gutter-sm q-mt-xs">
<div class="col">
<q-radio
v-model="mergeChoices[key]"
val="local"
label="Your Version"
/>
<div class="text-caption">
{{ formatValue(value) }}
<div class="card-body">
<p class="text-caption mb-2">Select which version to keep for each field.</p>
<ul class="item-list simple-list">
<li v-for="(localValue, key) in conflictData?.localVersion.data" :key="key" class="list-item-simple merge-choice-item">
<strong class="text-caption-strong">{{ formatKey(key) }}</strong>
<div class="flex" style="gap: 1rem; margin-top: 0.5rem;">
<div class="radio-group-inline">
<label class="radio-label">
<input type="radio" :name="String(key)" v-model="mergeChoices[key]" value="local" />
<span class="checkmark radio-mark"></span>
Your Version: <span class="value-preview">{{ formatValue(localValue) }}</span>
</label>
</div>
<div class="radio-group-inline">
<label class="radio-label">
<input type="radio" :name="String(key)" v-model="mergeChoices[key]" value="server" />
<span class="checkmark radio-mark"></span>
Server Version: <span class="value-preview">{{ formatValue(conflictData?.serverVersion.data[key]) }}</span>
</label>
</div>
</div>
</li>
</ul>
</div>
</div>
<div class="col">
<q-radio
v-model="mergeChoices[key]"
val="server"
label="Server Version"
/>
<div class="text-caption">
{{ formatValue(conflictData?.serverVersion.data[key]) }}
</div>
</div>
</div>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
</q-tab-panel>
</q-tab-panels>
</q-card-section>
<q-card-actions align="right">
<q-btn
<div class="modal-footer">
<button
v-if="activeTab === 'compare'"
flat
label="Keep Local Version"
color="primary"
type="button"
class="btn btn-neutral"
@click="resolveConflict('local')"
/>
<q-btn
>
Keep Local Version
</button>
<button
v-if="activeTab === 'compare'"
flat
label="Keep Server Version"
color="primary"
type="button"
class="btn btn-neutral ml-2"
@click="resolveConflict('server')"
/>
<q-btn
>
Keep Server Version
</button>
<button
v-if="activeTab === 'compare'"
flat
label="Merge Changes"
color="primary"
type="button"
class="btn btn-primary ml-2"
@click="activeTab = 'merge'"
/>
<q-btn
>
Merge Changes
</button>
<button
v-if="activeTab === 'merge'"
flat
label="Apply Merged Changes"
color="primary"
type="button"
class="btn btn-primary ml-2"
@click="applyMergedChanges"
/>
<q-btn
flat
label="Cancel"
color="negative"
@click="show = false"
/>
</q-card-actions>
</q-card>
</q-dialog>
>
Apply Merged Changes
</button>
<button
type="button"
class="btn btn-danger ml-2"
@click="closeDialog"
>
Cancel
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import type { OfflineAction } from 'src/stores/offline';
import { useVModel, onClickOutside } from '@vueuse/core';
// Assuming OfflineAction is defined elsewhere, e.g. in a Pinia store or a types file
// For this example, let's define a placeholder if not available from `src/stores/offline`
// import type { OfflineAction } from 'src/stores/offline';
interface OfflineAction {
id: string | number;
type: string;
payload: unknown;
timestamp: number;
// other potential fields
}
interface ConflictData {
localVersion: {
@ -179,7 +176,7 @@ interface ConflictData {
data: Record<string, unknown>;
timestamp: number;
};
action: OfflineAction;
action: OfflineAction; // Assuming OfflineAction is defined
}
const props = defineProps<{
@ -192,47 +189,53 @@ const emit = defineEmits<{
(e: 'resolve', resolution: { version: 'local' | 'server' | 'merge'; action: OfflineAction; mergedData?: Record<string, unknown> }): void;
}>();
const show = ref(props.modelValue);
const show = useVModel(props, 'modelValue', emit);
const activeTab = ref('compare');
const mergeChoices = ref<Record<string, 'local' | 'server'>>({});
const modalContentRef = ref<HTMLElement | null>(null);
// Watch for changes in modelValue
watch(() => props.modelValue, (newValue: boolean) => {
show.value = newValue;
onClickOutside(modalContentRef, () => {
if (show.value) {
// Potentially ask for confirmation before closing or just close
// closeDialog();
}
});
// Watch for changes in show
watch(show, (newValue: boolean) => {
emit('update:modelValue', newValue);
});
const closeDialog = () => {
show.value = false;
};
// Initialize merge choices when conflict data changes
watch(() => props.conflictData, (newData) => {
if (newData) {
const choices: Record<string, 'local' | 'server'> = {};
Object.keys(newData.localVersion.data).forEach(key => {
choices[key] = isDifferent(key) ? 'local' : 'local';
// Default to local, or server if local is undefined/null but server is not
if (isDifferent(key)) {
choices[key] = newData.localVersion.data[key] !== undefined ? 'local' : 'server';
} else {
choices[key] = 'local';
}
});
mergeChoices.value = choices;
activeTab.value = 'compare'; // Reset to compare tab
}
}, { immediate: true });
}, { immediate: true, deep: true });
const formatDate = (timestamp: number): string => {
if (timestamp === 0) return 'N/A';
return new Date(timestamp).toLocaleString();
};
const formatKey = (key: string): string => {
return key
.split(/(?=[A-Z])/)
.join(' ')
.toLowerCase()
.replace(/^\w/, (c) => c.toUpperCase());
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (str) => str.toUpperCase());
};
const formatValue = (value: unknown): string => {
if (value === null || value === undefined) return '-';
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
if (typeof value === 'object') return JSON.stringify(value);
if (typeof value === 'object') return JSON.stringify(value, null, 2); // Pretty print object
if (typeof value === 'number' || typeof value === 'string') return String(value);
return '[Unsupported Type]';
};
@ -244,15 +247,13 @@ const isDifferent = (key: string): boolean => {
return JSON.stringify(localValue) !== JSON.stringify(serverValue);
};
const resolveConflict = (version: 'local' | 'server' | 'merge'): void => {
const resolveConflict = (version: 'local' | 'server'): void => {
if (!props.conflictData) return;
emit('resolve', {
version,
action: props.conflictData.action
action: props.conflictData.action,
});
show.value = false;
closeDialog();
};
const applyMergedChanges = (): void => {
@ -260,30 +261,79 @@ const applyMergedChanges = (): void => {
const mergedData: Record<string, unknown> = {};
Object.entries(mergeChoices.value).forEach(([key, choice]) => {
const localValue = props.conflictData?.localVersion.data[key];
const serverValue = props.conflictData?.serverVersion.data[key];
mergedData[key] = choice === 'local' ? localValue : serverValue;
mergedData[key] = choice === 'local'
? props.conflictData!.localVersion.data[key]
: props.conflictData!.serverVersion.data[key];
});
emit('resolve', {
version: 'merge',
action: props.conflictData.action,
mergedData
mergedData,
});
show.value = false;
closeDialog();
};
</script>
<style lang="scss" scoped>
.q-card {
<style scoped>
.text-caption {
font-size: 0.8rem;
font-size: 0.85rem;
color: var(--dark);
opacity: 0.8;
}
.text-caption-strong {
font-size: 0.9rem;
color: var(--dark);
font-weight: bold;
display: block;
margin-bottom: 0.25rem;
}
.text-positive {
color: $positive;
font-weight: 500;
.text-positive-inline {
color: var(--success); /* Assuming --success is greenish */
font-weight: bold;
background-color: #e6ffed; /* Light green background for highlight */
padding: 2px 4px;
border-radius: 3px;
}
.simple-list {
list-style: none;
padding: 0;
margin: 0;
}
.list-item-simple {
padding: 0.5rem 0;
border-bottom: 1px solid #eee;
}
.list-item-simple:last-child {
border-bottom: none;
}
.merge-choice-item .radio-group-inline {
margin-bottom: 0.5rem;
}
.merge-choice-item .radio-label {
align-items: flex-start; /* Better alignment for multi-line content */
}
.value-preview {
font-style: italic;
color: #555;
margin-left: 0.5em;
display: inline-block;
max-width: 200px; /* Adjust as needed */
white-space: pre-wrap; /* Show formatted JSON */
word-break: break-all;
}
.ml-2 {
margin-left: 0.5rem;
}
.mb-1 { margin-bottom: 0.5rem; }
.mb-2 { margin-bottom: 1rem; }
.flex-grow {
flex-grow: 1;
}
</style>

View File

@ -1,47 +1,64 @@
<template>
<q-dialog v-model="isOpen" persistent>
<q-card style="min-width: 350px">
<q-card-section class="row items-center">
<div class="text-h6">Create New List</div>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-card-section>
<q-form @submit="onSubmit" class="q-gutter-md">
<q-input
v-model="listName"
label="List Name"
:rules="[(val) => !!val || 'Name is required']"
outlined
/>
<q-input v-model="description" label="Description" type="textarea" outlined />
<q-select
v-model="selectedGroup"
:options="groups"
label="Associate with Group (Optional)"
outlined
clearable
/>
<div class="row justify-end q-mt-md">
<q-btn label="Cancel" color="grey" flat v-close-popup />
<q-btn label="Create" type="submit" color="primary" class="q-ml-sm" />
<div v-if="isOpen" class="modal-backdrop open" @click.self="closeModal">
<div class="modal-container" role="dialog" aria-modal="true" aria-labelledby="createListModalTitle">
<div class="modal-header">
<h3 id="createListModalTitle">Create New List</h3>
<button class="close-button" @click="closeModal" aria-label="Close modal">
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-close" /></svg>
</button>
</div>
<form @submit.prevent="onSubmit">
<div class="modal-body">
<div class="form-group">
<label for="listName" class="form-label">List Name</label>
<input
type="text"
id="listName"
v-model="listName"
class="form-input"
required
ref="listNameInput"
/>
<p v-if="formErrors.listName" class="form-error-text">{{ formErrors.listName }}</p>
</div>
<div class="form-group">
<label for="description" class="form-label">Description</label>
<textarea
id="description"
v-model="description"
class="form-input"
rows="3"
></textarea>
</div>
<div class="form-group" v-if="groups && groups.length > 0">
<label for="selectedGroup" class="form-label">Associate with Group (Optional)</label>
<select id="selectedGroup" v-model="selectedGroupId" class="form-input">
<option :value="null">None</option>
<option v-for="group in groups" :key="group.value" :value="group.value">
{{ group.label }}
</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary ml-2" :disabled="loading">
<span v-if="loading" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
Create
</button>
</div>
</form>
</div>
</div>
</q-form>
</q-card-section>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useQuasar } from 'quasar';
import { apiClient, API_ENDPOINTS } from 'src/config/api';
const $q = useQuasar();
import { ref, watch, nextTick } from 'vue';
import { useVModel, onClickOutside } from '@vueuse/core';
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming this path is correct
import { useNotificationStore } from '@/stores/notifications';
const props = defineProps<{
modelValue: boolean;
@ -53,50 +70,81 @@ const emit = defineEmits<{
(e: 'created'): void;
}>();
const isOpen = ref(props.modelValue);
const isOpen = useVModel(props, 'modelValue', emit);
const listName = ref('');
const description = ref('');
const selectedGroup = ref<{ label: string; value: number } | null>(null);
const selectedGroupId = ref<number | null>(null); // Store only the ID
const loading = ref(false);
const formErrors = ref<{ listName?: string }>({});
const notificationStore = useNotificationStore();
// Watch for modelValue changes
watch(
() => props.modelValue,
(newVal) => {
isOpen.value = newVal;
},
);
const listNameInput = ref<HTMLInputElement | null>(null);
const modalContainerRef = ref<HTMLElement | null>(null); // For onClickOutside
// Watch for isOpen changes
watch(isOpen, (newVal) => {
emit('update:modelValue', newVal);
if (newVal) {
// Reset form when opening
listName.value = '';
description.value = '';
selectedGroupId.value = null;
formErrors.value = {};
nextTick(() => {
listNameInput.value?.focus();
});
}
});
onClickOutside(modalContainerRef, () => {
if (isOpen.value) {
closeModal();
}
});
const closeModal = () => {
isOpen.value = false;
};
const validateForm = () => {
formErrors.value = {};
if (!listName.value.trim()) {
formErrors.value.listName = 'Name is required';
}
return Object.keys(formErrors.value).length === 0;
};
const onSubmit = async () => {
if (!validateForm()) {
return;
}
loading.value = true;
try {
await apiClient.post(API_ENDPOINTS.LISTS.BASE, {
name: listName.value,
description: description.value,
group_id: selectedGroup.value?.value,
group_id: selectedGroupId.value,
});
$q.notify({
type: 'positive',
message: 'List created successfully',
});
notificationStore.addNotification({ message: 'List created successfully', type: 'success' });
// Reset form
listName.value = '';
description.value = '';
selectedGroup.value = null;
// Close modal and emit created event
isOpen.value = false;
emit('created');
closeModal();
} catch (error: unknown) {
$q.notify({
type: 'negative',
message: error instanceof Error ? error.message : 'Failed to create list',
});
const message = error instanceof Error ? error.message : 'Failed to create list';
notificationStore.addNotification({ message, type: 'error' });
console.error(message, error);
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.form-error-text {
color: var(--danger);
font-size: 0.85rem;
margin-top: 0.25rem;
}
.ml-2 {
margin-left: 0.5rem; /* from Valerie UI utilities */
}
</style>

View File

@ -1,35 +1,74 @@
<template>
<q-item
clickable
tag="a"
target="_blank"
:href="link"
>
<q-item-section
v-if="icon"
avatar
>
<q-icon :name="icon" />
</q-item-section>
<q-item-section>
<q-item-label>{{ title }}</q-item-label>
<q-item-label caption>{{ caption }}</q-item-label>
</q-item-section>
</q-item>
<li>
<a :href="link" :target="isExternalLink ? '_blank' : undefined" class="list-item-link">
<svg v-if="icon" class="icon" aria-hidden="true"><use :xlink:href="`#icon-${icon}`" /></svg>
<span class="link-content">
<span class="link-title">{{ title }}</span>
<span v-if="caption" class="link-caption">{{ caption }}</span>
</span>
</a>
</li>
</template>
<script setup lang="ts">
import { computed } from 'vue';
export interface EssentialLinkProps {
title: string;
caption?: string;
link?: string;
icon?: string;
};
}
withDefaults(defineProps<EssentialLinkProps>(), {
const props = withDefaults(defineProps<EssentialLinkProps>(), {
caption: '',
link: '#',
icon: '',
});
const isExternalLink = computed(() => {
return props.link?.startsWith('http') || props.link?.startsWith('//');
});
</script>
<style scoped>
/* Basic styling for EssentialLink as a list item link */
.list-item-link {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
text-decoration: none;
color: var(--dark);
border: var(--border-width) var(--border-style) transparent; /* For focus indication */
transition: background-color var(--transition-speed) var(--transition-ease-out);
}
.list-item-link:hover,
.list-item-link:focus-visible {
background-color: rgba(0,0,0,0.05);
border-color: var(--primary);
outline: none;
}
.list-item-link .icon {
margin-right: 0.8rem;
flex-shrink: 0;
width: 1.2em;
height: 1.2em;
}
.link-content {
display: flex;
flex-direction: column;
}
.link-title {
font-weight: bold;
}
.link-caption {
font-size: 0.85rem;
opacity: 0.7;
margin-top: 0.15rem;
}
</style>

View File

@ -1,131 +1,142 @@
<template>
<q-banner
<div>
<div
v-if="!isOnline || hasPendingActions"
:class="[
'offline-indicator',
{ 'offline': !isOnline },
{ 'pending': hasPendingActions }
]"
rounded
class="alert offline-indicator"
:class="{
'alert-error': !isOnline,
'alert-warning': isOnline && hasPendingActions
}"
role="status"
>
<template v-slot:avatar>
<q-icon
:name="!isOnline ? 'wifi_off' : 'sync'"
:color="!isOnline ? 'negative' : 'warning'"
/>
</template>
<template v-if="!isOnline">
<div class="alert-content">
<svg class="icon" aria-hidden="true">
<use :xlink:href="!isOnline ? '#icon-alert-triangle' : '#icon-info'" />
<!-- Placeholder icons, wifi_off and sync are not in Valerie UI default -->
</svg>
<span v-if="!isOnline">
You are currently offline. Changes will be saved locally.
</template>
<template v-else>
</span>
<span v-else>
Syncing {{ pendingActionCount }} pending {{ pendingActionCount === 1 ? 'change' : 'changes' }}...
</template>
<template v-slot:action>
<q-btn
</span>
</div>
<button
v-if="hasPendingActions"
flat
color="primary"
label="View Changes"
@click="showPendingActions = true"
/>
</template>
</q-banner>
class="btn btn-sm btn-neutral"
@click="showPendingActionsModal = true"
>
View Changes
</button>
</div>
<q-dialog v-model="showPendingActions">
<q-card style="min-width: 350px">
<q-card-section>
<div class="text-h6">Pending Changes</div>
</q-card-section>
<q-card-section class="q-pt-none">
<q-list>
<q-item v-for="action in pendingActions" :key="action.id">
<q-item-section>
<q-item-label>
{{ getActionLabel(action) }}
</q-item-label>
<q-item-label caption>
{{ new Date(action.timestamp).toLocaleString() }}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Close" color="primary" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
<div v-if="showPendingActionsModal" class="modal-backdrop open" @click.self="showPendingActionsModal = false">
<div class="modal-container" ref="pendingActionsModalRef" role="dialog" aria-modal="true" aria-labelledby="pendingActionsTitle">
<div class="modal-header">
<h3 id="pendingActionsTitle">Pending Changes</h3>
<button class="close-button" @click="showPendingActionsModal = false" aria-label="Close">
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-close" /></svg>
</button>
</div>
<div class="modal-body">
<ul v-if="pendingActions.length" class="item-list">
<li v-for="action in pendingActions" :key="action.id" class="list-item">
<div class="list-item-content" style="flex-direction: column; align-items: flex-start;">
<span class="item-text">{{ getActionLabel(action) }}</span>
<small class="text-caption">{{ new Date(action.timestamp).toLocaleString() }}</small>
</div>
</li>
</ul>
<p v-else>No pending changes.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" @click="showPendingActionsModal = false">Close</button>
</div>
</div>
</div>
<!-- Conflict Resolution Dialog -->
<ConflictResolutionDialog
v-model="showConflictDialog"
:conflict-data="currentConflict"
@resolve="handleConflictResolution"
v-model="offlineStore.showConflictDialog"
:conflict-data="offlineStore.currentConflict"
@resolve="offlineStore.handleConflictResolution"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useOfflineStore } from 'src/stores/offline';
import type { OfflineAction } from 'src/stores/offline';
import ConflictResolutionDialog from './ConflictResolutionDialog.vue';
import { useNetwork, onClickOutside } from '@vueuse/core';
import { useOfflineStore } from '@/stores/offline'; // Assuming path
import type { OfflineAction } from '@/stores/offline'; // Assuming path
import ConflictResolutionDialog from '@/components/ConflictResolutionDialog.vue';
const offlineStore = useOfflineStore();
const showPendingActions = ref(false);
const showPendingActionsModal = ref(false);
const pendingActionsModalRef = ref<HTMLElement | null>(null);
const { isOnline } = useNetwork(); // VueUse composable for network status
// Expose parts of the store directly, this pattern is fine with Pinia
const {
isOnline,
pendingActions,
hasPendingActions,
pendingActionCount,
showConflictDialog,
currentConflict,
handleConflictResolution,
// showConflictDialog, // Handled by offlineStore.showConflictDialog
// currentConflict, // Handled by offlineStore.currentConflict
// handleConflictResolution // Handled by offlineStore.handleConflictResolution
} = offlineStore;
onClickOutside(pendingActionsModalRef, () => {
showPendingActionsModal.value = false;
});
const getActionLabel = (action: OfflineAction) => {
// This is a simplified version of your original getActionLabel
// You might need to adjust based on the actual structure of action.data
const data = action.payload as { title?: string; name?: string; [key: string]: unknown };
const itemTitle = data.title || data.name || (typeof data === 'string' ? data : 'Untitled Item');
switch (action.type) {
case 'add': {
const data = action.data as { title?: string };
return `Add new item: ${data.title || 'Untitled'}`;
}
case 'complete': {
const data = action.data as { title?: string };
return `Complete item: ${data.title || 'Untitled'}`;
}
case 'update': {
const data = action.data as { title?: string };
return `Update item: ${data.title || 'Untitled'}`;
}
case 'delete': {
const data = action.data as { title?: string };
return `Delete item: ${data.title || 'Untitled'}`;
}
case 'add':
case 'create': // Common alias
return `Add: ${itemTitle}`;
case 'complete':
return `Complete: ${itemTitle}`;
case 'update':
return `Update: ${itemTitle}`;
case 'delete':
return `Delete: ${itemTitle}`;
default:
return 'Unknown action';
return `Unknown action: ${action.type} for ${itemTitle}`;
}
};
</script>
<style lang="scss" scoped>
<style scoped>
.offline-indicator {
position: fixed;
bottom: 16px;
right: 16px;
bottom: 1rem;
right: 1rem;
z-index: 1000;
max-width: 400px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
&.offline {
background-color: #ffebee;
/* Valerie UI .alert already has box-shadow */
}
&.pending {
background-color: #fff3e0;
/* Styles for text-caption if not globally available enough */
.text-caption {
font-size: 0.85rem;
color: var(--dark);
opacity: 0.7;
}
/* Simplified list item for pending actions modal */
.item-list .list-item .list-item-content {
padding: 0.75rem 1rem;
}
.item-list .list-item .item-text {
font-weight: 500;
margin-bottom: 0.25rem;
}
</style>

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';
// 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>
<q-layout view="hHh lpR fFf">
<q-page-container>
<div class="auth-layout">
<main class="auth-page-container">
<router-view />
</q-page-container>
</q-layout>
</main>
</div>
</template>
<script setup lang="ts">
// No additional setup needed for this layout
// No specific logic for AuthLayout
</script>
<style lang="scss" scoped>
.auth-layout {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: var(--bg-color-page, #f0f2f5);
}
.auth-page-container {
width: 100%;
max-width: 450px; // Max width for login/signup forms
padding: 2rem;
}
</style>

View File

@ -1,93 +1,189 @@
<template>
<q-layout view="hHh lpR fFf">
<!-- Header -->
<q-header elevated class="bg-primary text-white">
<q-toolbar>
<q-toolbar-title> Mooo </q-toolbar-title>
<div class="main-layout">
<header class="app-header">
<div class="toolbar-title">Mooo</div>
<div class="user-menu" v-if="authStore.isAuthenticated">
<button @click="toggleUserMenu" class="user-menu-button">
<!-- Placeholder for user icon -->
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#FFFFFF"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
</button>
<div v-if="userMenuOpen" class="dropdown-menu" ref="userMenuDropdown">
<a href="#" @click.prevent="handleLogout">Logout</a>
</div>
</div>
</header>
<q-btn
v-if="authStore.isAuthenticated"
flat
round
dense
icon="account_circle"
aria-label="User Profile"
>
<q-menu>
<q-list style="min-width: 150px">
<q-item clickable v-close-popup @click="handleLogout">
<q-item-section avatar>
<q-icon name="logout" />
</q-item-section>
<q-item-section>Logout</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
</q-toolbar>
</q-header>
<!-- Main Content -->
<q-page-container>
<main class="page-container">
<router-view />
</q-page-container>
</main>
<!-- Offline Indicator -->
<OfflineIndicator />
<!-- Bottom Navigation -->
<q-footer elevated class="bg-white text-primary">
<q-tabs
v-model="activeTab"
class="text-primary"
active-color="primary"
indicator-color="primary"
align="justify"
narrow-indicator
>
<q-route-tab name="lists" icon="list" label="Lists" to="/lists" />
<q-route-tab name="groups" icon="group" label="Groups" to="/groups" />
<q-route-tab name="account" icon="person" label="Account" to="/account" />
</q-tabs>
</q-footer>
</q-layout>
<footer class="app-footer">
<nav class="tabs">
<router-link to="/lists" class="tab-item" active-class="active">Lists</router-link>
<router-link to="/groups" class="tab-item" active-class="active">Groups</router-link>
<router-link to="/account" class="tab-item" active-class="active">Account</router-link>
</nav>
</footer>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useQuasar } from 'quasar';
import { useAuthStore } from 'stores/auth';
import OfflineIndicator from 'components/OfflineIndicator.vue';
import { useAuthStore } from '@/stores/auth';
import OfflineIndicator from '@/components/OfflineIndicator.vue';
import { onClickOutside } from '@vueuse/core';
import { useNotificationStore } from '@/stores/notifications';
const router = useRouter();
const $q = useQuasar();
const authStore = useAuthStore();
const activeTab = ref('lists');
const notificationStore = useNotificationStore();
const handleLogout = () => {
const userMenuOpen = ref(false);
const userMenuDropdown = ref<HTMLElement | null>(null);
const toggleUserMenu = () => {
userMenuOpen.value = !userMenuOpen.value;
};
onClickOutside(userMenuDropdown, () => {
userMenuOpen.value = false;
}, { ignore: ['.user-menu-button'] });
const handleLogout = async () => {
try {
authStore.logout();
$q.notify({
color: 'positive',
authStore.logout(); // Pinia action
notificationStore.addNotification({
type: 'success',
message: 'Logged out successfully',
position: 'top',
});
void router.push('/login');
await router.push('/auth/login'); // Adjusted path
} catch (error: unknown) {
$q.notify({
color: 'negative',
notificationStore.addNotification({
type: 'error',
message: error instanceof Error ? error.message : 'Logout failed',
position: 'top',
});
}
userMenuOpen.value = false;
};
</script>
<style lang="scss">
.q-footer {
.q-tabs {
height: 56px;
<style lang="scss" scoped>
.main-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.app-header {
background-color: var(--primary-color);
color: white;
padding: 0 1rem;
height: var(--header-height);
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
position: sticky;
top: 0;
z-index: 100;
}
.toolbar-title {
font-size: 1.25rem;
font-weight: 500;
}
.user-menu {
position: relative;
}
.user-menu-button {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 0.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background-color: rgba(255,255,255,0.1);
}
}
.dropdown-menu {
position: absolute;
right: 0;
top: calc(100% + 5px);
background-color: white;
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
min-width: 150px;
z-index: 101;
a {
display: block;
padding: 0.5rem 1rem;
color: var(--text-color);
text-decoration: none;
&:hover {
background-color: #f5f5f5;
}
}
}
.page-container {
flex-grow: 1;
padding: 1rem; // Add some default padding
padding-bottom: calc(var(--footer-height) + 1rem); // Space for fixed footer
}
.app-footer {
background-color: white;
border-top: 1px solid #e0e0e0;
height: var(--footer-height);
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
}
.tabs {
display: flex;
height: 100%;
}
.tab-item {
flex-grow: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-color); // Or a specific inactive tab color
text-decoration: none;
font-size: 0.8rem; // Example size
padding: 0.5rem 0;
border-bottom: 2px solid transparent;
// Icon would go here if you add them
// Example: svg or <i> for icon fonts
&.active {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
}
&:hover {
background-color: #f0f0f0;
}
}
</style>

41
fe/src/main.ts Normal file
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>
<q-page padding>
<h1 class="text-h4 q-mb-md">Account Settings</h1>
<main class="container page-padding">
<h1 class="mb-3">Account Settings</h1>
<div v-if="loading" class="text-center">
<q-spinner-dots color="primary" size="2em" />
<div class="spinner-dots" role="status"><span/><span/><span/></div>
<p>Loading profile...</p>
</div>
<q-banner v-else-if="error" inline-actions class="text-white bg-red q-mb-md">
<template v-slot:avatar>
<q-icon name="warning" />
</template>
<div v-else-if="error" class="alert alert-error mb-3" role="alert">
<div class="alert-content">
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-alert-triangle" /></svg>
{{ error }}
<template v-slot:action>
<q-btn flat color="white" label="Retry" @click="fetchProfile" />
</template>
</q-banner>
</div>
<button type="button" class="btn btn-sm btn-danger" @click="fetchProfile">Retry</button>
</div>
<template v-else>
<q-form @submit="onSubmit" class="q-gutter-md">
<form v-else @submit.prevent="onSubmitProfile">
<!-- Profile Section -->
<q-card class="q-mb-md">
<q-card-section>
<div class="text-h6">Profile Information</div>
</q-card-section>
<q-card-section>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<q-input
v-model="profile.name"
label="Name"
:rules="[(val) => !!val || 'Name is required']"
outlined
/>
<div class="card mb-3">
<div class="card-header">
<h3>Profile Information</h3>
</div>
<div class="col-12 col-md-6">
<q-input
v-model="profile.email"
label="Email"
type="email"
:rules="[(val) => !!val || 'Email is required']"
outlined
readonly
/>
<div class="card-body">
<div class="flex flex-wrap" style="gap: 1rem;">
<div class="form-group flex-grow">
<label for="profileName" class="form-label">Name</label>
<input type="text" id="profileName" v-model="profile.name" class="form-input" required />
</div>
<div class="form-group flex-grow">
<label for="profileEmail" class="form-label">Email</label>
<input type="email" id="profileEmail" v-model="profile.email" class="form-input" required readonly />
</div>
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn
type="submit"
color="primary"
label="Save Changes"
:loading="saving"
/>
</q-card-actions>
</q-card>
</div>
<div class="card-footer">
<button type="submit" class="btn btn-primary" :disabled="saving">
<span v-if="saving" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
Save Changes
</button>
</div>
</div>
</form>
<!-- Password Section -->
<q-card class="q-mb-md">
<q-card-section>
<div class="text-h6">Change Password</div>
</q-card-section>
<q-card-section>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<q-input
v-model="password.current"
label="Current Password"
type="password"
:rules="[(val) => !!val || 'Current password is required']"
outlined
/>
<form @submit.prevent="onChangePassword">
<div class="card mb-3">
<div class="card-header">
<h3>Change Password</h3>
</div>
<div class="col-12 col-md-6">
<q-input
v-model="password.new"
label="New Password"
type="password"
:rules="[(val) => !!val || 'New password is required']"
outlined
/>
<div class="card-body">
<div class="flex flex-wrap" style="gap: 1rem;">
<div class="form-group flex-grow">
<label for="currentPassword" class="form-label">Current Password</label>
<input type="password" id="currentPassword" v-model="password.current" class="form-input" required />
</div>
<div class="form-group flex-grow">
<label for="newPassword" class="form-label">New Password</label>
<input type="password" id="newPassword" v-model="password.newPassword" class="form-input" required />
</div>
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn
color="primary"
label="Change Password"
:loading="changingPassword"
@click="onChangePassword"
/>
</q-card-actions>
</q-card>
</div>
<div class="card-footer">
<button type="submit" class="btn btn-primary" :disabled="changingPassword">
<span v-if="changingPassword" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
Change Password
</button>
</div>
</div>
</form>
<!-- Notifications Section -->
<q-card>
<q-card-section>
<div class="text-h6">Notification Preferences</div>
</q-card-section>
<q-card-section>
<q-list>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Email Notifications</q-item-label>
<q-item-label caption>Receive email notifications for important updates</q-item-label>
</q-item-section>
<q-item-section side>
<q-toggle v-model="preferences.emailNotifications" @update:model-value="onPreferenceChange" />
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>List Updates</q-item-label>
<q-item-label caption>Get notified when lists are updated</q-item-label>
</q-item-section>
<q-item-section side>
<q-toggle v-model="preferences.listUpdates" @update:model-value="onPreferenceChange" />
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Group Activities</q-item-label>
<q-item-label caption>Receive notifications for group activities</q-item-label>
</q-item-section>
<q-item-section side>
<q-toggle v-model="preferences.groupActivities" @update:model-value="onPreferenceChange" />
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
</q-form>
</template>
</q-page>
<div class="card">
<div class="card-header">
<h3>Notification Preferences</h3>
</div>
<div class="card-body">
<ul class="item-list preference-list">
<li class="preference-item">
<div class="preference-label">
<span>Email Notifications</span>
<small>Receive email notifications for important updates</small>
</div>
<label class="switch-container">
<input type="checkbox" v-model="preferences.emailNotifications" @change="onPreferenceChange" />
<span class="switch" aria-hidden="true"></span>
</label>
</li>
<li class="preference-item">
<div class="preference-label">
<span>List Updates</span>
<small>Get notified when lists are updated</small>
</div>
<label class="switch-container">
<input type="checkbox" v-model="preferences.listUpdates" @change="onPreferenceChange" />
<span class="switch" aria-hidden="true"></span>
</label>
</li>
<li class="preference-item">
<div class="preference-label">
<span>Group Activities</span>
<small>Receive notifications for group activities</small>
</div>
<label class="switch-container">
<input type="checkbox" v-model="preferences.groupActivities" @change="onPreferenceChange" />
<span class="switch" aria-hidden="true"></span>
</label>
</li>
</ul>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useQuasar } from 'quasar';
import { apiClient, API_ENDPOINTS } from 'src/config/api';
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path
import { useNotificationStore } from '@/stores/notifications';
interface Profile {
name: string;
email: string;
}
interface Password {
interface PasswordForm {
current: string;
new: string;
newPassword: string; // Renamed from 'new' to avoid conflict
}
interface Preferences {
@ -163,22 +133,14 @@ interface Preferences {
groupActivities: boolean;
}
const $q = useQuasar();
const loading = ref(true);
const saving = ref(false);
const changingPassword = ref(false);
const error = ref<string | null>(null);
const profile = ref<Profile>({
name: '',
email: '',
});
const password = ref<Password>({
current: '',
new: '',
});
const notificationStore = useNotificationStore();
const profile = ref<Profile>({ name: '', email: '' });
const password = ref<PasswordForm>({ current: '', newPassword: '' });
const preferences = ref<Preferences>({
emailNotifications: true,
listUpdates: true,
@ -191,74 +153,110 @@ const fetchProfile = async () => {
try {
const response = await apiClient.get(API_ENDPOINTS.USERS.PROFILE);
profile.value = response.data;
// Assume preferences are also fetched or part of profile
// preferences.value = response.data.preferences || preferences.value;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to load profile';
error.value = message;
console.error('Failed to fetch profile:', err);
error.value = err instanceof Error ? err.message : 'Failed to load profile';
$q.notify({
type: 'negative',
message: error.value,
});
notificationStore.addNotification({ message, type: 'error' });
} finally {
loading.value = false;
}
};
const onSubmit = async () => {
const onSubmitProfile = async () => {
saving.value = true;
try {
await apiClient.put(API_ENDPOINTS.USERS.UPDATE_PROFILE, profile.value);
$q.notify({
type: 'positive',
message: 'Profile updated successfully',
});
notificationStore.addNotification({ message: 'Profile updated successfully', type: 'success' });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to update profile';
console.error('Failed to update profile:', err);
$q.notify({
type: 'negative',
message: err instanceof Error ? err.message : 'Failed to update profile',
});
notificationStore.addNotification({ message, type: 'error' });
} finally {
saving.value = false;
}
};
const onChangePassword = async () => {
if (!password.value.current || !password.value.newPassword) {
notificationStore.addNotification({ message: 'Please fill in both current and new password fields.', type: 'warning' });
return;
}
if (password.value.newPassword.length < 8) {
notificationStore.addNotification({ message: 'New password must be at least 8 characters long.', type: 'warning' });
return;
}
changingPassword.value = true;
try {
await apiClient.put(API_ENDPOINTS.USERS.PASSWORD, password.value);
password.value = { current: '', new: '' };
$q.notify({
type: 'positive',
message: 'Password changed successfully',
// API endpoint expects 'new' not 'newPassword'
await apiClient.put(API_ENDPOINTS.USERS.PASSWORD, {
current: password.value.current,
new: password.value.newPassword
});
password.value = { current: '', newPassword: '' };
notificationStore.addNotification({ message: 'Password changed successfully', type: 'success' });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to change password';
console.error('Failed to change password:', err);
$q.notify({
type: 'negative',
message: err instanceof Error ? err.message : 'Failed to change password',
});
notificationStore.addNotification({ message, type: 'error' });
} finally {
changingPassword.value = false;
}
};
const onPreferenceChange = async () => {
// This will be called for each toggle change.
// Consider debouncing or providing a "Save Preferences" button if API calls are expensive.
try {
await apiClient.put(API_ENDPOINTS.USERS.PREFERENCES, preferences.value);
$q.notify({
type: 'positive',
message: 'Preferences updated successfully',
});
notificationStore.addNotification({ message: 'Preferences updated successfully', type: 'success' });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to update preferences';
console.error('Failed to update preferences:', err);
$q.notify({
type: 'negative',
message: err instanceof Error ? err.message : 'Failed to update preferences',
});
notificationStore.addNotification({ message, type: 'error' });
// Optionally revert the toggle if the API call fails
// await fetchProfile(); // Or manage state more granularly
}
};
onMounted(() => {
void fetchProfile();
fetchProfile();
});
</script>
<style scoped>
.page-padding {
padding: 1rem; /* Or use var(--padding-page) if defined in Valerie UI */
}
.mb-3 { margin-bottom: 1.5rem; } /* From Valerie UI */
.flex-grow { flex-grow: 1; }
.preference-list {
list-style: none;
padding: 0;
margin: 0;
}
.preference-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid #eee; /* Softer border for list items */
}
.preference-item:last-child {
border-bottom: none;
}
.preference-label {
display: flex;
flex-direction: column;
margin-right: 1rem;
}
.preference-label small {
font-size: 0.85rem;
opacity: 0.7;
margin-top: 0.2rem;
}
</style>

View File

@ -1,27 +1,49 @@
<template>
<div class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center">
<div class="fullscreen-error text-center">
<div>
<div style="font-size: 30vh">
404
</div>
<div class="text-h2" style="opacity:.4">
Oops. Nothing here...
</div>
<q-btn
class="q-mt-xl"
color="white"
text-color="blue"
unelevated
to="/"
label="Go Home"
no-caps
/>
<div class="error-code">404</div>
<div class="error-message">Oops. Nothing here...</div>
<router-link to="/" class="btn btn-primary mt-3">Go Home</router-link>
</div>
</div>
</template>
<script setup lang="ts">
//
// No script logic needed for this simple page
</script>
<style scoped>
.fullscreen-error {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh; /* Fallback for browsers that don't support dvh */
min-height: 100dvh;
background-color: var(--secondary-accent); /* Light Blue */
color: var(--dark);
padding: 2rem;
font-family: "Patrick Hand", cursive;
}
.error-code {
font-size: clamp(15vh, 25vw, 30vh); /* Responsive font size */
font-weight: bold;
color: var(--primary);
line-height: 1;
text-shadow: var(--shadow-md);
}
.error-message {
font-size: clamp(1.5rem, 4vw, 2.5rem);
opacity: 0.8;
margin-top: -1rem; /* Adjust based on font size */
margin-bottom: 2rem;
}
.btn-primary {
/* Ensure primary button styles are applied if not already by global .btn */
background-color: var(--primary);
color: var(--dark);
}
.mt-3 { margin-top: 1.5rem; }
</style>

View File

@ -1,79 +1,110 @@
<template>
<q-page padding>
<div v-if="group">
<h4 class="q-mt-none q-mb-sm">Group: {{ group.name }}</h4>
<main class="container page-padding">
<div v-if="loading" class="text-center">
<div class="spinner-dots" role="status"><span/><span/><span/></div>
<p>Loading group details...</p>
</div>
<div v-else-if="error" class="alert alert-error" role="alert">
<div class="alert-content">
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-alert-triangle" /></svg>
{{ error }}
</div>
</div>
<div v-else-if="group">
<h1 class="mb-3">Group: {{ group.name }}</h1>
<!-- Invite Code Generation -->
<div class="q-mt-lg">
<h5>Invite Members</h5>
<q-btn
label="Generate Invite Code"
color="secondary"
@click="void generateInviteCode"
:loading="generatingInvite"
<!-- Invite Members Section -->
<div class="card mt-3">
<div class="card-header">
<h3>Invite Members</h3>
</div>
<div class="card-body">
<button
class="btn btn-secondary"
@click="generateInviteCode"
:disabled="generatingInvite"
>
<span v-if="generatingInvite" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
Generate Invite Code
</button>
<div v-if="inviteCode" class="form-group mt-2">
<label for="inviteCodeInput" class="form-label">Invite Code:</label>
<div class="flex items-center">
<input
id="inviteCodeInput"
type="text"
:value="inviteCode"
class="form-input flex-grow"
readonly
/>
<div v-if="inviteCode" class="q-mt-md">
<q-input readonly :model-value="inviteCode" label="Invite Code">
<template v-slot:append>
<q-btn round dense flat icon="content_copy" @click="copyInviteCode" />
</template>
</q-input>
<q-banner v-if="copySuccess" class="bg-green-2 text-green-9 q-mt-sm" dense>
Invite code copied to clipboard!
</q-banner>
<button class="btn btn-neutral btn-icon-only ml-1" @click="copyInviteCodeHandler" aria-label="Copy invite code">
<svg class="icon"><use xlink:href="#icon-clipboard"></use></svg> <!-- Assuming #icon-clipboard is 'content_copy' -->
</button>
</div>
<p v-if="copySuccess" class="form-success-text mt-1">Invite code copied to clipboard!</p>
</div>
</div>
</div>
<div v-else-if="loading"><q-spinner-dots size="2em" /> Loading group details...</div>
<div v-else>
<p>Group not found or an error occurred.</p>
<!-- Placeholder for lists related to this group -->
<div class="mt-4">
<h2>Lists in this Group</h2>
<ListsPage :group-id="groupId" />
</div>
</q-page>
</div>
<div v-else class="alert alert-info" role="status">
<div class="alert-content">Group not found or an error occurred.</div>
</div>
</main>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRoute } from 'vue-router';
import { apiClient, API_ENDPOINTS } from 'src/config/api';
import { copyToClipboard, useQuasar } from 'quasar';
// import { useRoute } from 'vue-router';
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path
import { useClipboard } from '@vueuse/core';
import ListsPage from './ListsPage.vue'; // Import ListsPage
import { useNotificationStore } from '@/stores/notifications';
interface Group {
id: string;
id: string | number; // API might return number
name: string;
// other properties if needed
}
const props = defineProps({
id: {
type: String,
required: true,
},
});
const props = defineProps<{
id: string; // From router param, always string
}>();
const route = useRoute();
const $q = useQuasar();
// const route = useRoute();
// const $q = useQuasar(); // Not used anymore
const notificationStore = useNotificationStore();
const group = ref<Group | null>(null);
const loading = ref(false);
const loading = ref(true);
const error = ref<string | null>(null);
const inviteCode = ref<string | null>(null);
const generatingInvite = ref(false);
const copySuccess = ref(false);
const groupId = computed(() => props.id || (route.params.id as string));
// groupId is directly from props.id now, which comes from the route path param
const groupId = computed(() => props.id);
const { copy, copied, isSupported: clipboardIsSupported } = useClipboard({ source: inviteCode });
const fetchGroupDetails = async () => {
if (!groupId.value) return;
loading.value = true;
error.value = null;
try {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BY_ID(groupId.value));
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BY_ID(String(groupId.value)));
group.value = response.data;
} catch (error: unknown) {
console.error('Error fetching group details:', error);
$q.notify({
color: 'negative',
message: error instanceof Error ? error.message : 'Failed to fetch group details.',
icon: 'report_problem',
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to fetch group details.';
error.value = message;
console.error('Error fetching group details:', err);
notificationStore.addNotification({ message, type: 'error' });
} finally {
loading.value = false;
}
@ -83,46 +114,58 @@ const generateInviteCode = async () => {
if (!groupId.value) return;
generatingInvite.value = true;
inviteCode.value = null;
copySuccess.value = false;
try {
const response = await apiClient.post(API_ENDPOINTS.INVITES.BASE, {
group_id: groupId.value,
group_id: groupId.value, // Ensure this matches API expectation (string or number)
});
inviteCode.value = response.data.invite_code;
$q.notify({
color: 'positive',
message: 'Invite code generated successfully!',
icon: 'check_circle',
});
} catch (error: unknown) {
console.error('Error generating invite code:', error);
$q.notify({
color: 'negative',
message: error instanceof Error ? error.message : 'Failed to generate invite code.',
icon: 'report_problem',
});
notificationStore.addNotification({ message: 'Invite code generated successfully!', type: 'success' });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to generate invite code.';
console.error('Error generating invite code:', err);
notificationStore.addNotification({ message, type: 'error' });
} finally {
generatingInvite.value = false;
}
};
const copyInviteCode = () => {
if (inviteCode.value) {
copyToClipboard(inviteCode.value)
.then(() => {
const copyInviteCodeHandler = async () => {
if (!clipboardIsSupported.value || !inviteCode.value) {
notificationStore.addNotification({ message: 'Clipboard not supported or no code to copy.', type: 'warning' });
return;
}
await copy(inviteCode.value);
if (copied.value) {
copySuccess.value = true;
setTimeout(() => (copySuccess.value = false), 2000);
})
.catch(() => {
console.error('Failed to copy invite code');
});
// Optionally, notify success via store if preferred over inline message
// notificationStore.addNotification({ message: 'Invite code copied!', type: 'info' });
} else {
notificationStore.addNotification({ message: 'Failed to copy invite code.', type: 'error' });
}
};
onMounted(() => {
void fetchGroupDetails();
fetchGroupDetails();
});
</script>
<style scoped>
/* Add any page-specific styles here */
.page-padding {
padding: 1rem;
}
.mt-1 { margin-top: 0.5rem; }
.mt-2 { margin-top: 1rem; }
.mt-3 { margin-top: 1.5rem; }
.mt-4 { margin-top: 2rem; }
.mb-3 { margin-bottom: 1.5rem; }
.ml-1 { margin-left: 0.25rem; } /* Adjusted from Valerie UI for tighter fit */
.form-success-text {
color: var(--success); /* Or a darker green for text */
font-size: 0.9rem;
font-weight: bold;
}
.flex-grow { flex-grow: 1; }
</style>

View File

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

View File

@ -1,43 +1,49 @@
<template>
<q-page class="row items-center justify-evenly">
<example-component
title="Example component"
active
:todos="todos"
:meta="meta"
></example-component>
</q-page>
<main class="container page-padding text-center">
<h1>Welcome to Valerie UI App</h1>
<p class="mb-3">This is the main index page.</p>
<!-- The ExampleComponent is not provided, so this section is a placeholder -->
<div v-if="todos.length" class="card">
<div class="card-header">
<h3>Sample Todos (from IndexPage data)</h3>
</div>
<div class="card-body">
<ul class="item-list">
<li v-for="todo in todos" :key="todo.id" class="list-item">
<div class="list-item-content">
<span class="item-text">{{ todo.id }}: {{ todo.content }}</span>
</div>
</li>
</ul>
<p class="mt-2">Total count from meta: {{ meta.totalCount }}</p>
</div>
</div>
<p v-else>No todos to display.</p>
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import type { Todo, Meta } from 'components/models';
import ExampleComponent from 'components/ExampleComponent.vue';
import type { Todo, Meta } from '@/components/models'; // Adjusted path if models.ts is in the same directory
// import ExampleComponent from 'components/ExampleComponent.vue'; // This component is not provided for conversion
const todos = ref<Todo[]>([
{
id: 1,
content: 'ct1'
},
{
id: 2,
content: 'ct2'
},
{
id: 3,
content: 'ct3'
},
{
id: 4,
content: 'ct4'
},
{
id: 5,
content: 'ct5'
}
{ id: 1, content: 'ct1' },
{ id: 2, content: 'ct2' },
{ id: 3, content: 'ct3' },
{ id: 4, content: 'ct4' },
{ id: 5, content: 'ct5' },
]);
const meta = ref<Meta>({
totalCount: 1200
totalCount: 1200,
});
</script>
<style scoped>
.page-padding { padding: 1rem; }
.mb-3 { margin-bottom: 1.5rem; }
.mt-2 { margin-top: 1rem; }
.text-center { text-align: center; }
</style>

File diff suppressed because it is too large Load Diff

View File

@ -1,98 +1,91 @@
<template>
<q-page padding>
<h1 class="text-h4 q-mb-md">{{ pageTitle }}</h1>
<main class="container page-padding">
<h1 class="mb-3">{{ pageTitle }}</h1>
<div v-if="loading" class="text-center">
<q-spinner-dots color="primary" size="2em" />
<div class="spinner-dots" role="status"><span/><span/><span/></div>
<p>Loading lists...</p>
</div>
<q-banner v-else-if="error" inline-actions class="text-white bg-red q-mb-md">
<template v-slot:avatar>
<q-icon name="warning" />
</template>
<div v-else-if="error" class="alert alert-error mb-3" role="alert">
<div class="alert-content">
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-alert-triangle" /></svg>
{{ error }}
<template v-slot:action>
<q-btn flat color="white" label="Retry" @click="fetchLists" />
</template>
</q-banner>
<div v-else-if="filteredLists.length === 0">
<p>{{ noListsMessage }}</p>
<q-btn
color="primary"
icon="add"
label="Create New List"
@click="showCreateModal = true"
class="q-mt-md"
/>
</div>
<button type="button" class="btn btn-sm btn-danger" @click="fetchListsAndGroups">Retry</button>
</div>
<q-list v-else bordered separator>
<q-item
<div v-else-if="filteredLists.length === 0" class="card empty-state-card">
<svg class="icon icon-lg" aria-hidden="true"><use xlink:href="#icon-clipboard" /></svg>
<h3>{{ noListsMessage }}</h3>
<p v-if="!currentGroupId">Create a personal list or join a group to see shared lists.</p>
<p v-else>This group doesn't have any lists yet.</p>
<button class="btn btn-primary mt-2" @click="showCreateModal = true">
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-plus" /></svg>
Create New List
</button>
</div>
<ul v-else class="item-list">
<li
v-for="list in filteredLists"
:key="list.id"
clickable
v-ripple
:to="`/lists/${list.id}`"
class="list-item interactive-list-item"
tabindex="0"
@click="navigateToList(list.id)"
@keydown.enter="navigateToList(list.id)"
>
<q-item-section>
<q-item-label>{{ list.name }}</q-item-label>
<q-item-label caption>{{ list.description || 'No description' }}</q-item-label>
<q-item-label caption v-if="!list.group_id && !props.groupId">
<q-icon name="person" /> Personal List
</q-item-label>
<q-item-label caption v-if="list.group_id && !props.groupId">
<q-icon name="group" /> Group List (ID: {{ list.group_id }})
</q-item-label>
</q-item-section>
<q-item-section side top>
<q-badge
:color="list.is_complete ? 'green' : 'orange'"
:label="list.is_complete ? 'Complete' : 'Active'"
/>
<q-item-label caption class="q-mt-xs">
<div class="list-item-content">
<div class="list-item-main" style="flex-direction: column; align-items: flex-start;">
<span class="item-text" style="font-size: 1.1rem; font-weight: bold;">{{ list.name }}</span>
<small class="item-caption">{{ list.description || 'No description' }}</small>
<small v-if="!list.group_id && !props.groupId" class="item-caption icon-caption">
<svg class="icon icon-sm"><use xlink:href="#icon-user"/></svg> Personal List
</small>
<small v-if="list.group_id && !props.groupId" class="item-caption icon-caption">
<svg class="icon icon-sm"><use xlink:href="#icon-user"/></svg> <!-- Placeholder, group icon not in Valerie -->
Group List ({{ getGroupName(list.group_id) || `ID: ${list.group_id}`}})
</small>
</div>
<div class="list-item-details" style="flex-direction: column; align-items: flex-end;">
<span class="item-badge" :class="list.is_complete ? 'badge-settled' : 'badge-pending'">
{{ list.is_complete ? 'Complete' : 'Active' }}
</span>
<small class="item-caption mt-1">
Updated: {{ new Date(list.updated_at).toLocaleDateString() }}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</small>
</div>
</div>
</li>
</ul>
<q-page-sticky position="bottom-right" :offset="[18, 18]">
<q-btn
fab
color="primary"
icon="add"
<div class="page-sticky-bottom-right">
<button
class="btn btn-primary btn-icon-only"
style="width: 56px; height: 56px; border-radius: 50%; padding: 0;"
@click="showCreateModal = true"
:label="currentGroupId ? 'Create Group List' : 'Create List'"
/>
</q-page-sticky>
:aria-label="currentGroupId ? 'Create Group List' : 'Create List'"
data-tooltip="Create New List"
>
<svg class="icon icon-lg" style="margin-right:0;"><use xlink:href="#icon-plus" /></svg>
</button>
<!-- Basic Tooltip (requires JS from Valerie UI example to function on hover/focus) -->
<!-- <span class="tooltip-text" role="tooltip">{{ currentGroupId ? 'Create Group List' : 'Create List' }}</span> -->
</div>
<CreateListModal
v-model="showCreateModal"
:groups="availableGroups"
@created="fetchLists"
:groups="availableGroupsForModal"
@created="onListCreated"
/>
</q-page>
</main>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRoute } from 'vue-router';
import { apiClient, API_ENDPOINTS } from 'src/config/api';
import CreateListModal from 'components/CreateListModal.vue';
import {
QSpinnerDots,
QBanner,
QIcon,
QList,
QItem,
QItemSection,
QItemLabel,
QBadge,
QBtn,
QPageSticky,
} from 'quasar'; // Explicitly import Quasar components
import { ref, onMounted, computed, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { apiClient, API_ENDPOINTS } from '@/config/api';
import CreateListModal from '@/components/CreateListModal.vue'; // Adjusted path
interface List {
id: number;
@ -112,46 +105,57 @@ interface Group {
}
const props = defineProps<{
groupId?: number | string; // Can be passed as prop, or we might use route.params
groupId?: number | string; // Prop for when ListsPage is embedded (e.g. in GroupDetailPage)
}>();
const route = useRoute();
const router = useRouter();
const loading = ref(true);
const error = ref<string | null>(null);
const lists = ref<List[]>([]);
const availableGroups = ref<{ label: string; value: number }[]>([]);
const groupName = ref<string | null>(null);
const allFetchedGroups = ref<Group[]>([]); // Store all groups user has access to for display
const currentViewedGroup = ref<Group | null>(null); // For the title if on a specific group's list page
const showCreateModal = ref(false);
const currentGroupId = computed(() => {
if (props.groupId) {
return typeof props.groupId === 'string' ? parseInt(props.groupId, 10) : props.groupId;
const currentGroupId = computed<number | null>(() => {
const idFromProp = props.groupId;
const idFromRoute = route.params.groupId;
if (idFromProp) {
return typeof idFromProp === 'string' ? parseInt(idFromProp, 10) : idFromProp;
}
if (route.params.groupId) {
return parseInt(route.params.groupId as string, 10);
if (idFromRoute) {
return parseInt(idFromRoute as string, 10);
}
return null; // No specific group selected, show personal lists or all accessible
return null;
});
const fetchGroupName = async () => {
if (!currentGroupId.value) return;
const fetchCurrentViewGroupName = async () => {
if (!currentGroupId.value) {
currentViewedGroup.value = null;
return;
}
// Try to find in already fetched groups first
const found = allFetchedGroups.value.find(g => g.id === currentGroupId.value);
if (found) {
currentViewedGroup.value = found;
return;
}
// If not found, fetch it specifically (might happen if navigating directly)
try {
const response = await apiClient.get(
API_ENDPOINTS.GROUPS.BY_ID(String(currentGroupId.value))
);
groupName.value = (response.data as Group).name;
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BY_ID(String(currentGroupId.value)));
currentViewedGroup.value = response.data as Group;
} catch (err) {
console.error('Failed to fetch group name:', err);
groupName.value = null;
console.error(`Failed to fetch group name for ID ${currentGroupId.value}:`, err);
currentViewedGroup.value = null; // Set to null if fetch fails
}
};
const pageTitle = computed(() => {
if (currentGroupId.value) {
return groupName.value
? `Lists for ${groupName.value}`
return currentViewedGroup.value
? `Lists for ${currentViewedGroup.value.name}`
: `Lists for Group ${currentGroupId.value}`;
}
return 'All My Lists';
@ -161,18 +165,16 @@ const noListsMessage = computed(() => {
if (currentGroupId.value) {
return 'No lists found for this group.';
}
return 'You have no lists yet. Create a personal list or join a group to see shared lists.';
return 'You have no lists yet.';
});
const fetchGroups = async () => {
const fetchAllAccessibleGroups = async () => {
try {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
availableGroups.value = (response.data as Group[]).map((group) => ({
label: group.name,
value: group.id,
}));
allFetchedGroups.value = (response.data as Group[]);
} catch (err) {
console.error('Failed to fetch groups:', err);
console.error('Failed to fetch groups for modal:', err);
// Not critical for page load, modal might not show groups
}
};
@ -180,38 +182,112 @@ const fetchLists = async () => {
loading.value = true;
error.value = null;
try {
// If currentGroupId is set, fetch lists for that group. Otherwise, fetch all user's lists.
const endpoint = currentGroupId.value
? API_ENDPOINTS.GROUPS.LISTS(String(currentGroupId.value))
: API_ENDPOINTS.LISTS.BASE;
const response = await apiClient.get(endpoint);
lists.value = response.data as List[];
} catch (err: unknown) {
console.error('Failed to fetch lists:', err);
error.value =
err instanceof Error ? err.message : 'An unexpected error occurred while fetching lists.';
error.value = err instanceof Error ? err.message : 'Failed to fetch lists.';
console.error(error.value, err);
} finally {
loading.value = false;
}
};
onMounted(() => {
void fetchLists();
void fetchGroups();
void fetchGroupName();
const fetchListsAndGroups = async () => {
loading.value = true;
await Promise.all([
fetchLists(),
fetchAllAccessibleGroups()
]);
await fetchCurrentViewGroupName(); // Depends on allFetchedGroups
loading.value = false;
};
const availableGroupsForModal = computed(() => {
return allFetchedGroups.value.map(group => ({
label: group.name,
value: group.id,
}));
});
const filteredLists = computed(() => {
if (currentGroupId.value) {
return lists.value.filter((list) => list.group_id === currentGroupId.value);
const getGroupName = (groupId?: number | null): string | undefined => {
if (!groupId) return undefined;
return allFetchedGroups.value.find(g => g.id === groupId)?.name;
}
// Show all accessible lists when no groupId is specified
return lists.value;
const onListCreated = () => {
fetchLists(); // Refresh lists after one is created
};
const navigateToList = (listId: number) => {
router.push(`/lists/${listId}`);
};
onMounted(() => {
fetchListsAndGroups();
});
// Watch for changes in groupId (e.g., if used as a component and prop changes)
watch(currentGroupId, () => {
fetchListsAndGroups();
});
</script>
<style scoped>
.q-item__label--caption .q-icon {
margin-right: 4px;
.page-padding { padding: 1rem; }
.mb-3 { margin-bottom: 1.5rem; }
.mt-1 { margin-top: 0.5rem; }
.mt-2 { margin-top: 1rem; }
.interactive-list-item {
cursor: pointer;
transition: background-color var(--transition-speed) var(--transition-ease-out);
}
.interactive-list-item:hover,
.interactive-list-item:focus-visible {
background-color: rgba(0,0,0,0.03);
outline: var(--focus-outline);
outline-offset: -3px;
}
.item-caption {
display: block;
font-size: 0.85rem;
opacity: 0.7;
margin-top: 0.25rem;
}
.icon-caption .icon {
vertical-align: -0.1em; /* Align icon better with text */
}
.page-sticky-bottom-right {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 999; /* Below modals */
}
.page-sticky-bottom-right .btn {
box-shadow: var(--shadow-lg); /* Make it pop more */
}
/* Ensure list item content uses full width for proper layout */
.list-item-content {
display: flex;
justify-content: space-between;
width: 100%;
align-items: flex-start; /* Align items to top if they wrap */
}
.list-item-main {
flex-grow: 1;
margin-right: 1rem; /* Space before details */
}
.list-item-details {
flex-shrink: 0; /* Prevent badges from shrinking */
text-align: right;
}
</style>

View File

@ -1,97 +1,109 @@
<template>
<q-page class="flex flex-center">
<q-card class="login-card">
<q-card-section>
<div class="text-h6">Login</div>
</q-card-section>
<q-card-section>
<q-form @submit="onSubmit" class="q-gutter-md">
<q-input
v-model="email"
label="Email"
<main class="flex items-center justify-center page-container">
<div class="card login-card">
<div class="card-header">
<h3>Login</h3>
</div>
<div class="card-body">
<form @submit.prevent="onSubmit" class="form-layout">
<div class="form-group mb-2">
<label for="email" class="form-label">Email</label>
<input
type="email"
:rules="[(val) => !!val || 'Email is required', isValidEmail]"
id="email"
v-model="email"
class="form-input"
required
autocomplete="email"
/>
<p v-if="formErrors.email" class="form-error-text">{{ formErrors.email }}</p>
</div>
<q-input
<div class="form-group mb-3">
<label for="password" class="form-label">Password</label>
<div class="input-with-icon-append">
<input
:type="isPwdVisible ? 'text' : 'password'"
id="password"
v-model="password"
label="Password"
:type="isPwd ? 'password' : 'text'"
:rules="[(val) => !!val || 'Password is required']"
>
<template v-slot:append>
<q-icon
:name="isPwd ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="isPwd = !isPwd"
/>
</template>
</q-input>
<div>
<q-btn
label="Login"
type="submit"
color="primary"
class="full-width"
:loading="loading"
class="form-input"
required
autocomplete="current-password"
/>
<button type="button" @click="isPwdVisible = !isPwdVisible" class="icon-append-btn" aria-label="Toggle password visibility">
<svg class="icon icon-sm"><use :xlink:href="isPwdVisible ? '#icon-settings' : '#icon-settings'"></use></svg> <!-- Placeholder for visibility icons -->
</button>
</div>
<p v-if="formErrors.password" class="form-error-text">{{ formErrors.password }}</p>
</div>
<div class="text-center q-mt-sm">
<router-link to="/signup" class="text-primary"
>Don't have an account? Sign up</router-link
>
<p v-if="formErrors.general" class="alert alert-error form-error-text">{{ formErrors.general }}</p>
<button type="submit" class="btn btn-primary w-full mt-2" :disabled="loading">
<span v-if="loading" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
Login
</button>
<div class="text-center mt-2">
<router-link to="/signup" class="link-styled">Don't have an account? Sign up</router-link>
</div>
</q-form>
</q-card-section>
</q-card>
</q-page>
</form>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useQuasar } from 'quasar';
import { useAuthStore } from 'stores/auth';
import { useAuthStore } from '@/stores/auth'; // Assuming path
import { useNotificationStore } from '@/stores/notifications';
const $q = useQuasar();
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
const notificationStore = useNotificationStore();
const email = ref('');
const password = ref('');
const isPwd = ref(true);
const isPwdVisible = ref(false);
const loading = ref(false);
const formErrors = ref<{ email?: string; password?: string; general?: string }>({});
const isValidEmail = (val: string) => {
const emailPattern =
/^(?=[a-zA-Z0-9@._%+-]{6,254}$)[a-zA-Z0-9._%+-]{1,64}@(?:[a-zA-Z0-9-]{1,63}\.){1,8}[a-zA-Z]{2,63}$/;
return emailPattern.test(val) || 'Invalid email';
const isValidEmail = (val: string): boolean => {
const emailPattern = /^(?=[a-zA-Z0-9@._%+-]{6,254}$)[a-zA-Z0-9._%+-]{1,64}@(?:[a-zA-Z0-9-]{1,63}\.){1,8}[a-zA-Z]{2,63}$/;
return emailPattern.test(val);
};
const validateForm = (): boolean => {
formErrors.value = {};
if (!email.value.trim()) {
formErrors.value.email = 'Email is required';
} else if (!isValidEmail(email.value)) {
formErrors.value.email = 'Invalid email format';
}
if (!password.value) {
formErrors.value.password = 'Password is required';
}
return Object.keys(formErrors.value).length === 0;
};
const onSubmit = async () => {
try {
if (!validateForm()) {
return;
}
loading.value = true;
formErrors.value.general = undefined; // Clear previous general errors
try {
await authStore.login(email.value, password.value);
$q.notify({
color: 'positive',
message: 'Login successful',
position: 'top',
});
// Redirect to the originally requested page or home
notificationStore.addNotification({ message: 'Login successful', type: 'success' });
const redirectPath = (route.query.redirect as string) || '/';
await router.push(redirectPath);
router.push(redirectPath);
} catch (error: unknown) {
$q.notify({
color: 'negative',
message: error instanceof Error ? error.message : 'Login failed',
position: 'top',
});
const message = error instanceof Error ? error.message : 'Login failed. Please check your credentials.';
formErrors.value.general = message;
console.error(message, error);
notificationStore.addNotification({ message, type: 'error' });
} finally {
loading.value = false;
}
@ -99,9 +111,62 @@ const onSubmit = async () => {
</script>
<style scoped>
.page-container {
min-height: 100vh; /* dvh for dynamic viewport height */
min-height: 100dvh;
padding: 1rem;
}
.login-card {
width: 100%;
max-width: 400px;
padding: 20px;
}
/* form-layout, w-full, mt-2, text-center styles were removed as utility classes from Valerie UI are used directly or are available. */
.link-styled {
color: var(--primary);
text-decoration: none;
border-bottom: 2px solid transparent;
transition: border-color var(--transition-speed) var(--transition-ease-out);
}
.link-styled:hover, .link-styled:focus {
border-bottom-color: var(--primary);
}
.form-error-text {
color: var(--danger);
font-size: 0.85rem;
margin-top: 0.25rem;
}
.alert.form-error-text { /* For general error message */
padding: 0.75rem 1rem;
margin-bottom: 1rem;
}
.input-with-icon-append {
position: relative;
display: flex;
}
.input-with-icon-append .form-input {
padding-right: 3rem; /* Space for the button */
}
.icon-append-btn {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 3rem; /* Width of the button */
background: transparent;
border: none;
border-left: var(--border); /* Separator line */
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--dark);
opacity: 0.7;
}
.icon-append-btn:hover, .icon-append-btn:focus {
opacity: 1;
background-color: rgba(0,0,0,0.03);
}
.icon-append-btn .icon { margin: 0; } /* Remove default icon margin */
</style>

View File

@ -1,118 +1,135 @@
<template>
<q-page class="flex flex-center">
<q-card class="signup-card">
<q-card-section>
<div class="text-h6">Sign Up</div>
</q-card-section>
<main class="flex items-center justify-center page-container">
<div class="card signup-card">
<div class="card-header">
<h3>Sign Up</h3>
</div>
<div class="card-body">
<form @submit.prevent="onSubmit" class="form-layout">
<div class="form-group mb-2">
<label for="name" class="form-label">Full Name</label>
<input type="text" id="name" v-model="name" class="form-input" required autocomplete="name"/>
<p v-if="formErrors.name" class="form-error-text">{{ formErrors.name }}</p>
</div>
<q-card-section>
<q-form @submit="onSubmit" class="q-gutter-md">
<q-input
v-model="name"
label="Full Name"
:rules="[(val) => !!val || 'Name is required']"
/>
<div class="form-group mb-2">
<label for="email" class="form-label">Email</label>
<input type="email" id="email" v-model="email" class="form-input" required autocomplete="email"/>
<p v-if="formErrors.email" class="form-error-text">{{ formErrors.email }}</p>
</div>
<q-input
v-model="email"
label="Email"
type="email"
:rules="[(val) => !!val || 'Email is required', isValidEmail]"
/>
<q-input
<div class="form-group mb-2">
<label for="password" class="form-label">Password</label>
<div class="input-with-icon-append">
<input
:type="isPwdVisible ? 'text' : 'password'"
id="password"
v-model="password"
label="Password"
:type="isPwd ? 'password' : 'text'"
:rules="[
(val) => !!val || 'Password is required',
(val) => val.length >= 8 || 'Password must be at least 8 characters',
]"
>
<template v-slot:append>
<q-icon
:name="isPwd ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="isPwd = !isPwd"
class="form-input"
required
autocomplete="new-password"
/>
</template>
</q-input>
<button type="button" @click="isPwdVisible = !isPwdVisible" class="icon-append-btn" aria-label="Toggle password visibility">
<svg class="icon icon-sm"><use :xlink:href="isPwdVisible ? '#icon-settings' : '#icon-settings'"></use></svg> <!-- Placeholder for visibility icons -->
</button>
</div>
<p v-if="formErrors.password" class="form-error-text">{{ formErrors.password }}</p>
</div>
<q-input
<div class="form-group mb-3">
<label for="confirmPassword" class="form-label">Confirm Password</label>
<input
:type="isPwdVisible ? 'text' : 'password'"
id="confirmPassword"
v-model="confirmPassword"
label="Confirm Password"
:type="isPwd ? 'password' : 'text'"
:rules="[
(val) => !!val || 'Please confirm your password',
(val) => val === password || 'Passwords do not match',
]"
/>
<div>
<q-btn
label="Sign Up"
type="submit"
color="primary"
class="full-width"
:loading="loading"
class="form-input"
required
autocomplete="new-password"
/>
<p v-if="formErrors.confirmPassword" class="form-error-text">{{ formErrors.confirmPassword }}</p>
</div>
<div class="text-center q-mt-sm">
<router-link to="/login" class="text-primary"
>Already have an account? Login</router-link
>
<p v-if="formErrors.general" class="alert alert-error form-error-text">{{ formErrors.general }}</p>
<button type="submit" class="btn btn-primary w-full mt-2" :disabled="loading">
<span v-if="loading" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
Sign Up
</button>
<div class="text-center mt-2">
<router-link to="auth/login" class="link-styled">Already have an account? Login</router-link>
</div>
</q-form>
</q-card-section>
</q-card>
</q-page>
</form>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useQuasar } from 'quasar';
import { useAuthStore } from 'stores/auth';
import { useAuthStore } from 'stores/auth'; // Assuming path
import { useNotificationStore } from '@/stores/notifications';
const $q = useQuasar();
const router = useRouter();
const authStore = useAuthStore();
const notificationStore = useNotificationStore();
const name = ref('');
const email = ref('');
const password = ref('');
const confirmPassword = ref('');
const isPwd = ref(true);
const isPwdVisible = ref(false);
const loading = ref(false);
const formErrors = ref<{ name?: string; email?: string; password?: string; confirmPassword?: string; general?: string }>({});
const isValidEmail = (val: string) => {
const emailPattern =
/^(?=[a-zA-Z0-9@._%+-]{6,254}$)[a-zA-Z0-9._%+-]{1,64}@(?:[a-zA-Z0-9-]{1,63}\.){1,8}[a-zA-Z]{2,63}$/;
return emailPattern.test(val) || 'Invalid email';
const isValidEmail = (val: string): boolean => {
const emailPattern = /^(?=[a-zA-Z0-9@._%+-]{6,254}$)[a-zA-Z0-9._%+-]{1,64}@(?:[a-zA-Z0-9-]{1,63}\.){1,8}[a-zA-Z]{2,63}$/;
return emailPattern.test(val);
};
const validateForm = (): boolean => {
formErrors.value = {};
if (!name.value.trim()) {
formErrors.value.name = 'Name is required';
}
if (!email.value.trim()) {
formErrors.value.email = 'Email is required';
} else if (!isValidEmail(email.value)) {
formErrors.value.email = 'Invalid email format';
}
if (!password.value) {
formErrors.value.password = 'Password is required';
} else if (password.value.length < 8) {
formErrors.value.password = 'Password must be at least 8 characters';
}
if (!confirmPassword.value) {
formErrors.value.confirmPassword = 'Please confirm your password';
} else if (password.value !== confirmPassword.value) {
formErrors.value.confirmPassword = 'Passwords do not match';
}
return Object.keys(formErrors.value).length === 0;
};
const onSubmit = async () => {
try {
if (!validateForm()) {
return;
}
loading.value = true;
formErrors.value.general = undefined;
try {
await authStore.signup({
name: name.value,
email: email.value,
password: password.value,
});
$q.notify({
color: 'positive',
message: 'Account created successfully',
position: 'top',
});
await router.push('/login');
notificationStore.addNotification({ message: 'Account created successfully. Please login.', type: 'success' });
router.push('auth/login');
} catch (error: unknown) {
$q.notify({
color: 'negative',
message: error instanceof Error ? error.message : 'Signup failed',
position: 'top',
});
const message = error instanceof Error ? error.message : 'Signup failed. Please try again.';
formErrors.value.general = message;
console.error(message, error);
notificationStore.addNotification({ message, type: 'error' });
} finally {
loading.value = false;
}
@ -120,9 +137,63 @@ const onSubmit = async () => {
</script>
<style scoped>
/* Using styles from LoginPage.vue where applicable */
.page-container {
min-height: 100vh;
min-height: 100dvh;
padding: 1rem;
}
.signup-card {
width: 100%;
max-width: 400px;
padding: 20px;
}
/* form-layout, w-full, mt-2, text-center styles were removed as utility classes from Valerie UI are used directly or are available. */
.link-styled {
color: var(--primary);
text-decoration: none;
border-bottom: 2px solid transparent;
transition: border-color var(--transition-speed) var(--transition-ease-out);
}
.link-styled:hover, .link-styled:focus {
border-bottom-color: var(--primary);
}
.form-error-text {
color: var(--danger);
font-size: 0.85rem;
margin-top: 0.25rem;
}
.alert.form-error-text { /* For general error message */
padding: 0.75rem 1rem;
margin-bottom: 1rem;
}
.input-with-icon-append {
position: relative;
display: flex;
}
.input-with-icon-append .form-input {
padding-right: 3rem;
}
.icon-append-btn {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 3rem;
background: transparent;
border: none;
border-left: var(--border);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--dark);
opacity: 0.7;
}
.icon-append-btn:hover, .icon-append-btn:focus {
opacity: 1;
background-color: rgba(0,0,0,0.03);
}
.icon-append-btn .icon { margin: 0; }
</style>

View File

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

View File

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

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

View File

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

12
fe/tsconfig.app.json Normal file
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