Svelte to Quasar
7
fe/.editorconfig
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
|
||||||
|
charset = utf-8
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
46
fe/.gitignore
vendored
@ -1,23 +1,33 @@
|
|||||||
|
.DS_Store
|
||||||
|
.thumbs.db
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
# Output
|
# Quasar core related directories
|
||||||
.output
|
.quasar
|
||||||
.vercel
|
/dist
|
||||||
.netlify
|
/quasar.config.*.temporary.compiled*
|
||||||
.wrangler
|
|
||||||
/.svelte-kit
|
|
||||||
/build
|
|
||||||
|
|
||||||
# OS
|
# Cordova related directories and files
|
||||||
.DS_Store
|
/src-cordova/node_modules
|
||||||
Thumbs.db
|
/src-cordova/platforms
|
||||||
|
/src-cordova/plugins
|
||||||
|
/src-cordova/www
|
||||||
|
|
||||||
# Env
|
# Capacitor related directories and files
|
||||||
.env
|
/src-capacitor/www
|
||||||
.env.*
|
/src-capacitor/node_modules
|
||||||
!.env.example
|
|
||||||
!.env.test
|
|
||||||
|
|
||||||
# Vite
|
# Log files
|
||||||
vite.config.js.timestamp-*
|
npm-debug.log*
|
||||||
vite.config.ts.timestamp-*
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
|
||||||
|
# local .env files
|
||||||
|
.env.local*
|
||||||
|
@ -1 +1,5 @@
|
|||||||
engine-strict=true
|
# pnpm-related options
|
||||||
|
shamefully-hoist=true
|
||||||
|
strict-peer-dependencies=false
|
||||||
|
# to get the latest compatible packages when creating the project https://github.com/pnpm/pnpm/issues/6463
|
||||||
|
resolution-mode=highest
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
# Package Managers
|
|
||||||
package-lock.json
|
|
||||||
pnpm-lock.yaml
|
|
||||||
yarn.lock
|
|
||||||
bun.lock
|
|
||||||
bun.lockb
|
|
@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"useTabs": true,
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "none",
|
|
||||||
"printWidth": 100,
|
|
||||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"files": "*.svelte",
|
|
||||||
"options": {
|
|
||||||
"parser": "svelte"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
5
fe/.prettierrc.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
15
fe/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
}
|
16
fe/.vscode/settings.json
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"editor.bracketPairColorization.enabled": true,
|
||||||
|
"editor.guides.bracketPairs": true,
|
||||||
|
"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"
|
||||||
|
}
|
52
fe/README.md
@ -1,38 +1,40 @@
|
|||||||
# sv
|
# mitlist (mitlist)
|
||||||
|
|
||||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
mitlist pwa
|
||||||
|
|
||||||
## Creating a project
|
|
||||||
|
|
||||||
If you're seeing this, you've probably already done this step. Congrats!
|
|
||||||
|
|
||||||
|
## Install the dependencies
|
||||||
```bash
|
```bash
|
||||||
# create a new project in the current directory
|
yarn
|
||||||
npx sv create
|
# or
|
||||||
|
npm install
|
||||||
# create a new project in my-app
|
|
||||||
npx sv create my-app
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Developing
|
### Start the app in development mode (hot-code reloading, error reporting, etc.)
|
||||||
|
|
||||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
quasar dev
|
||||||
|
|
||||||
# or start the server and open the app in a new browser tab
|
|
||||||
npm run dev -- --open
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
To create a production version of your app:
|
|
||||||
|
|
||||||
|
### Lint the files
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
yarn lint
|
||||||
|
# or
|
||||||
|
npm run lint
|
||||||
```
|
```
|
||||||
|
|
||||||
You can preview the production build with `npm run preview`.
|
|
||||||
|
|
||||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
### 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).
|
||||||
|
86
fe/eslint.config.js
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
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
|
||||||
|
)
|
21
fe/index.html
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title><%= productName %></title>
|
||||||
|
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="description" content="<%= productDescription %>">
|
||||||
|
<meta name="format-detection" content="telephone=no">
|
||||||
|
<meta name="msapplication-tap-highlight" content="no">
|
||||||
|
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>">
|
||||||
|
|
||||||
|
<link rel="icon" type="image/png" sizes="128x128" href="icons/favicon-128x128.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="96x96" href="icons/favicon-96x96.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png">
|
||||||
|
<link rel="icon" type="image/ico" href="favicon.ico">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- quasar:entry-point -->
|
||||||
|
</body>
|
||||||
|
</html>
|
15215
fe/package-lock.json
generated
@ -1,34 +1,55 @@
|
|||||||
{
|
{
|
||||||
"name": "fe",
|
"name": "mitlist",
|
||||||
"private": true,
|
"version": "0.0.1",
|
||||||
"version": "0.0.1",
|
"description": "mitlist pwa",
|
||||||
"type": "module",
|
"productName": "mitlist",
|
||||||
"scripts": {
|
"author": "Mohamad <Mohamad.elsena@edvring.de>",
|
||||||
"dev": "vite dev",
|
"type": "module",
|
||||||
"build": "vite build",
|
"private": true,
|
||||||
"preview": "vite preview",
|
"scripts": {
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"lint": "eslint -c ./eslint.config.js \"./src*/**/*.{ts,js,cjs,mjs,vue}\"",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"format": "prettier --write \"**/*.{js,ts,vue,scss,html,md,json}\" --ignore-path .gitignore",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"test": "echo \"No test specified\" && exit 0",
|
||||||
"format": "prettier --write .",
|
"dev": "quasar dev",
|
||||||
"lint": "prettier --check . && eslint ."
|
"build": "quasar build",
|
||||||
},
|
"postinstall": "quasar prepare"
|
||||||
"devDependencies": {
|
},
|
||||||
"@sveltejs/adapter-node": "^5.2.11",
|
"dependencies": {
|
||||||
"@sveltejs/kit": "^2.16.0",
|
"@quasar/extras": "^1.16.4",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"axios": "^1.2.1",
|
||||||
"@tailwindcss/forms": "^0.5.9",
|
"pinia": "^3.0.1",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"quasar": "^2.16.0",
|
||||||
"prettier": "^3.4.2",
|
"register-service-worker": "^1.7.2",
|
||||||
"prettier-plugin-svelte": "^3.3.3",
|
"vue": "^3.4.18",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
"vue-i18n": "^11.0.0",
|
||||||
"svelte": "^5.0.0",
|
"vue-router": "^4.0.12"
|
||||||
"svelte-check": "^4.0.0",
|
},
|
||||||
"tailwindcss": "^4.0.0",
|
"devDependencies": {
|
||||||
"typescript": "^5.0.0",
|
"@eslint/js": "^9.14.0",
|
||||||
"vite": "^6.0.0"
|
"@intlify/unplugin-vue-i18n": "^4.0.0",
|
||||||
},
|
"@quasar/app-vite": "^2.1.0",
|
||||||
"dependencies": {
|
"@types/node": "^20.5.9",
|
||||||
"idb": "^8.0.2"
|
"@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",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
29
fe/postcss.config.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
// https://github.com/michael-ciniawsky/postcss-load-config
|
||||||
|
|
||||||
|
import autoprefixer from 'autoprefixer'
|
||||||
|
// import rtlcss from 'postcss-rtlcss'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
plugins: [
|
||||||
|
// https://github.com/postcss/autoprefixer
|
||||||
|
autoprefixer({
|
||||||
|
overrideBrowserslist: [
|
||||||
|
'last 4 Chrome versions',
|
||||||
|
'last 4 Firefox versions',
|
||||||
|
'last 4 Edge versions',
|
||||||
|
'last 4 Safari versions',
|
||||||
|
'last 4 Android versions',
|
||||||
|
'last 4 ChromeAndroid versions',
|
||||||
|
'last 4 FirefoxAndroid versions',
|
||||||
|
'last 4 iOS versions'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
|
||||||
|
// https://github.com/elchininet/postcss-rtlcss
|
||||||
|
// If you want to support RTL css, then
|
||||||
|
// 1. yarn/pnpm/bun/npm install postcss-rtlcss
|
||||||
|
// 2. optionally set quasar.config.js > framework > lang to an RTL language
|
||||||
|
// 3. uncomment the following line (and its import statement above):
|
||||||
|
// rtlcss()
|
||||||
|
]
|
||||||
|
}
|
BIN
fe/public/favicon.ico
Normal file
After Width: | Height: | Size: 63 KiB |
BIN
fe/public/icons/apple-icon-120x120.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
fe/public/icons/apple-icon-152x152.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
fe/public/icons/apple-icon-167x167.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
fe/public/icons/apple-icon-180x180.png
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
fe/public/icons/favicon-128x128.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
fe/public/icons/favicon-16x16.png
Normal file
After Width: | Height: | Size: 859 B |
BIN
fe/public/icons/favicon-32x32.png
Normal file
After Width: | Height: | Size: 2.0 KiB |
BIN
fe/public/icons/favicon-96x96.png
Normal file
After Width: | Height: | Size: 9.4 KiB |
BIN
fe/public/icons/icon-128x128.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
fe/public/icons/icon-192x192.png
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
fe/public/icons/icon-256x256.png
Normal file
After Width: | Height: | Size: 27 KiB |
BIN
fe/public/icons/icon-384x384.png
Normal file
After Width: | Height: | Size: 42 KiB |
BIN
fe/public/icons/icon-512x512.png
Normal file
After Width: | Height: | Size: 56 KiB |
BIN
fe/public/icons/ms-icon-144x144.png
Normal file
After Width: | Height: | Size: 15 KiB |
1
fe/public/icons/safari-pinned-tab.svg
Normal file
After Width: | Height: | Size: 7.5 KiB |
235
fe/quasar.config.ts
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
// 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: 'GenerateSW', // 'GenerateSW' or 'InjectManifest'
|
||||||
|
// swFilename: 'sw.js',
|
||||||
|
// manifestFilename: 'manifest.json',
|
||||||
|
// extendManifestJson (json) {},
|
||||||
|
// useCredentialsForManifestTag: true,
|
||||||
|
// injectPwaMetaTags: false,
|
||||||
|
// 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: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
36
fe/src-pwa/custom-service-worker.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
/*
|
||||||
|
* 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"
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare const self: ServiceWorkerGlobalScope &
|
||||||
|
typeof globalThis & { skipWaiting: () => Promise<void> };
|
||||||
|
|
||||||
|
import { clientsClaim } from 'workbox-core';
|
||||||
|
import {
|
||||||
|
precacheAndRoute,
|
||||||
|
cleanupOutdatedCaches,
|
||||||
|
createHandlerBoundToURL,
|
||||||
|
} from 'workbox-precaching';
|
||||||
|
import { registerRoute, NavigationRoute } from 'workbox-routing';
|
||||||
|
|
||||||
|
self.skipWaiting().catch((error) => {
|
||||||
|
console.error('Error during service worker activation:', error);
|
||||||
|
});
|
||||||
|
clientsClaim();
|
||||||
|
|
||||||
|
// Use with precache injection
|
||||||
|
precacheAndRoute(self.__WB_MANIFEST);
|
||||||
|
|
||||||
|
cleanupOutdatedCaches();
|
||||||
|
|
||||||
|
// Non-SSR fallbacks to index.html
|
||||||
|
// Production SSR fallbacks to offline.html (except for dev)
|
||||||
|
if (process.env.MODE !== 'ssr' || process.env.PROD) {
|
||||||
|
registerRoute(
|
||||||
|
new NavigationRoute(createHandlerBoundToURL(process.env.PWA_FALLBACK_HTML), {
|
||||||
|
denylist: [new RegExp(process.env.PWA_SERVICE_WORKER_REGEX), /workbox-(.)*\.js$/],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
32
fe/src-pwa/manifest.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#027be3",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "icons/icon-128x128.png",
|
||||||
|
"sizes": "128x128",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/icon-256x256.png",
|
||||||
|
"sizes": "256x256",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/icon-384x384.png",
|
||||||
|
"sizes": "384x384",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
7
fe/src-pwa/pwa-env.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
declare namespace NodeJS {
|
||||||
|
interface ProcessEnv {
|
||||||
|
SERVICE_WORKER_FILE: string;
|
||||||
|
PWA_FALLBACK_HTML: string;
|
||||||
|
PWA_SERVICE_WORKER_REGEX: string;
|
||||||
|
}
|
||||||
|
}
|
41
fe/src-pwa/register-service-worker.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
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)
|
||||||
|
},
|
||||||
|
});
|
7
fe/src-pwa/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["WebWorker", "ESNext"]
|
||||||
|
},
|
||||||
|
"include": ["*.ts", "*.d.ts"]
|
||||||
|
}
|
7
fe/src/App.vue
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
//
|
||||||
|
</script>
|
@ -1,2 +0,0 @@
|
|||||||
@import 'tailwindcss';
|
|
||||||
@plugin '@tailwindcss/forms';
|
|
13
fe/src/app.d.ts
vendored
@ -1,13 +0,0 @@
|
|||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
|
||||||
// for information about these interfaces
|
|
||||||
declare global {
|
|
||||||
namespace App {
|
|
||||||
// interface Error {}
|
|
||||||
// interface Locals {}
|
|
||||||
// interface PageData {}
|
|
||||||
// interface PageState {}
|
|
||||||
// interface Platform {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export {};
|
|
@ -1,17 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
|
||||||
<link rel="manifest" href="/manifest.json" />
|
|
||||||
<meta name="theme-color" content="#4a90e2">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
%sveltekit.head%
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body data-sveltekit-preload-data="hover">
|
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
15
fe/src/assets/quasar-logo-vertical.svg
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 356 360">
|
||||||
|
<path
|
||||||
|
d="M43.4 303.4c0 3.8-2.3 6.3-7.1 6.3h-15v-22h14.4c4.3 0 6.2 2.2 6.2 5.2 0 2.6-1.5 4.4-3.4 5 2.8.4 4.9 2.5 4.9 5.5zm-8-13H24.1v6.9H35c2.1 0 4-1.3 4-3.8 0-2.2-1.3-3.1-3.7-3.1zm5.1 12.6c0-2.3-1.8-3.7-4-3.7H24.2v7.7h11.7c3.4 0 4.6-1.8 4.6-4zm36.3 4v2.7H56v-22h20.6v2.7H58.9v6.8h14.6v2.3H58.9v7.5h17.9zm23-5.8v8.5H97v-8.5l-11-13.4h3.4l8.9 11 8.8-11h3.4l-10.8 13.4zm19.1-1.8V298c0-7.9 5.2-10.7 12.7-10.7 7.5 0 13 2.8 13 10.7v1.4c0 7.9-5.5 10.8-13 10.8s-12.7-3-12.7-10.8zm22.7 0V298c0-5.7-3.9-8-10-8-6 0-9.8 2.3-9.8 8v1.4c0 5.8 3.8 8.1 9.8 8.1 6 0 10-2.3 10-8.1zm37.2-11.6v21.9h-2.9l-15.8-17.9v17.9h-2.8v-22h3l15.6 18v-18h2.9zm37.9 10.2v1.3c0 7.8-5.2 10.4-12.4 10.4H193v-22h11.2c7.2 0 12.4 2.8 12.4 10.3zm-3 0c0-5.3-3.3-7.6-9.4-7.6h-8.4V307h8.4c6 0 9.5-2 9.5-7.7V298zm50.8-7.6h-9.7v19.3h-3v-19.3h-9.7v-2.6h22.4v2.6zm34.4-2.6v21.9h-3v-10.1h-16.8v10h-2.8v-21.8h2.8v9.2H296v-9.2h2.9zm34.9 19.2v2.7h-20.7v-22h20.6v2.7H316v6.8h14.5v2.3H316v7.5h17.8zM24 340.2v7.3h13.9v2.4h-14v9.6H21v-22h20v2.7H24zm41.5 11.4h-9.8v7.9H53v-22h13.3c5.1 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6H66c3.1 0 5.3-1.5 5.3-4.7 0-3.3-2.2-4.1-5.3-4.1H55.7v8.8zm47.9 6.2H89l-2 4.3h-3.2l10.7-22.2H98l10.7 22.2h-3.2l-2-4.3zm-1-2.3l-6.3-13-6 13h12.2zm46.3-15.3v21.9H146v-17.2L135.7 358h-2.1l-10.2-15.6v17h-2.8v-21.8h3l11 16.9 11.3-17h3zm35 19.3v2.6h-20.7v-22h20.6v2.7H166v6.8h14.5v2.3H166v7.6h17.8zm47-19.3l-8.3 22h-3l-7.1-18.6-7 18.6h-3l-8.2-22h3.3L204 356l6.8-18.5h3.4L221 356l6.6-18.5h3.3zm10 11.6v-1.4c0-7.8 5.2-10.7 12.7-10.7 7.6 0 13 2.9 13 10.7v1.4c0 7.9-5.4 10.8-13 10.8-7.5 0-12.7-3-12.7-10.8zm22.8 0v-1.4c0-5.7-4-8-10-8s-9.9 2.3-9.9 8v1.4c0 5.8 3.8 8.2 9.8 8.2 6.1 0 10-2.4 10-8.2zm28.3 2.4h-9.8v7.9h-2.8v-22h13.2c5.2 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6h10.2c3 0 5.2-1.5 5.2-4.7 0-3.3-2.1-4.1-5.2-4.1h-10.2v8.8zm40.3-1.5l-6.8 5.6v6.4h-2.9v-22h2.9v12.3l15.2-12.2h3.7l-9.9 8.1 10.3 13.8h-3.6l-8.9-12z" />
|
||||||
|
<path fill="#050A14"
|
||||||
|
d="M188.4 71.7a10.4 10.4 0 01-20.8 0 10.4 10.4 0 1120.8 0zM224.2 45c-2.2-3.9-5-7.5-8.2-10.7l-12 7c-3.7-3.2-8-5.7-12.6-7.3a49.4 49.4 0 00-9.7 13.9 59 59 0 0140.1 14l7.6-4.4a57 57 0 00-5.2-12.5zM178 125.1c4.5 0 9-.6 13.4-1.7v-14a40 40 0 0012.5-7.2 47.7 47.7 0 00-7.1-15.3 59 59 0 01-32.2 27.7v8.7c4.4 1.2 8.9 1.8 13.4 1.8zM131.8 45c-2.3 4-4 8.1-5.2 12.5l12 7a40 40 0 000 14.4c5.7 1.5 11.3 2 16.9 1.5a59 59 0 01-8-41.7l-7.5-4.3c-3.2 3.2-6 6.7-8.2 10.6z" />
|
||||||
|
<path fill="#00B4FF"
|
||||||
|
d="M224.2 98.4c2.3-3.9 4-8 5.2-12.4l-12-7a40 40 0 000-14.5c-5.7-1.5-11.3-2-16.9-1.5a59 59 0 018 41.7l7.5 4.4c3.2-3.2 6-6.8 8.2-10.7zm-92.4 0c2.2 4 5 7.5 8.2 10.7l12-7a40 40 0 0012.6 7.3c4-4.1 7.3-8.8 9.7-13.8a59 59 0 01-40-14l-7.7 4.4c1.2 4.3 3 8.5 5.2 12.4zm46.2-80c-4.5 0-9 .5-13.4 1.7V34a40 40 0 00-12.5 7.2c1.5 5.7 4 10.8 7.1 15.4a59 59 0 0132.2-27.7V20a53.3 53.3 0 00-13.4-1.8z" />
|
||||||
|
<path fill="#00B4FF"
|
||||||
|
d="M178 9.2a62.6 62.6 0 11-.1 125.2A62.6 62.6 0 01178 9.2m0-9.2a71.7 71.7 0 100 143.5A71.7 71.7 0 00178 0z" />
|
||||||
|
<path fill="#050A14"
|
||||||
|
d="M96.6 212v4.3c-9.2-.8-15.4-5.8-15.4-17.8V180h4.6v18.4c0 8.6 4 12.6 10.8 13.5zm16-31.9v18.4c0 8.9-4.3 12.8-10.9 13.5v4.4c9.2-.7 15.5-5.6 15.5-18v-18.3h-4.7zM62.2 199v-2.2c0-12.7-8.8-17.4-21-17.4-12.1 0-20.7 4.7-20.7 17.4v2.2c0 12.8 8.6 17.6 20.7 17.6 1.5 0 3-.1 4.4-.3l11.8 6.2 2-3.3-8.2-4-6.4-3.1a32 32 0 01-3.6.2c-9.8 0-16-3.9-16-13.3v-2.2c0-9.3 6.2-13.1 16-13.1 9.9 0 16.3 3.8 16.3 13.1v2.2c0 5.3-2.1 8.7-5.6 10.8l4.8 2.4c3.4-2.8 5.5-7 5.5-13.2zM168 215.6h5.1L156 179.7h-4.8l17 36zM143 205l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.8-3.7H143zm133.7 10.7h5.2l-17.3-35.9h-4.8l17 36zm-25-10.7l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.7-3.7h-14.8zm73.8-2.5c6-1.2 9-5.4 9-11.4 0-8-4.5-10.9-12.9-10.9h-21.4v35.5h4.6v-31.3h16.5c5 0 8.5 1.4 8.5 6.7 0 5.2-3.5 7.7-8.5 7.7h-11.4v4.1h10.7l9.3 12.8h5.5l-9.9-13.2zm-117.4 9.9c-9.7 0-14.7-2.5-18.6-6.3l-2.2 3.8c5.1 5 11 6.7 21 6.7 1.6 0 3.1-.1 4.6-.3l-1.9-4h-3zm18.4-7c0-6.4-4.7-8.6-13.8-9.4l-10.1-1c-6.7-.7-9.3-2.2-9.3-5.6 0-2.5 1.4-4 4.6-5l-1.8-3.8c-4.7 1.4-7.5 4.2-7.5 8.9 0 5.2 3.4 8.7 13 9.6l11.3 1.2c6.4.6 8.9 2 8.9 5.4 0 2.7-2.1 4.7-6 5.8l1.8 3.9c5.3-1.6 8.9-4.7 8.9-10zm-20.3-21.9c7.9 0 13.3 1.8 18.1 5.7l1.8-3.9a30 30 0 00-19.6-5.9c-2 0-4 .1-5.7.3l1.9 4 3.5-.2z" />
|
||||||
|
<path fill="#00B4FF"
|
||||||
|
d="M.5 251.9c29.6-.5 59.2-.8 88.8-1l88.7-.3 88.7.3 44.4.4 44.4.6-44.4.6-44.4.4-88.7.3-88.7-.3a7981 7981 0 01-88.8-1z" />
|
||||||
|
<path fill="none" d="M-565.2 324H-252v15.8h-313.2z" />
|
||||||
|
</svg>
|
After Width: | Height: | Size: 4.4 KiB |
0
fe/src/boot/.gitkeep
Normal file
70
fe/src/boot/axios.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { boot } from 'quasar/wrappers';
|
||||||
|
import axios, { type AxiosInstance } from 'axios';
|
||||||
|
import { useAuthStore } from 'stores/auth';
|
||||||
|
|
||||||
|
declare module '@vue/runtime-core' {
|
||||||
|
interface ComponentCustomProperties {
|
||||||
|
$axios: AxiosInstance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}/api/v1`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request interceptor for adding auth token
|
||||||
|
api.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
if (authStore.accessToken) {
|
||||||
|
config.headers.Authorization = `Bearer ${authStore.accessToken}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(new Error(error.message));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response interceptor for handling errors
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
// If the error is 401 and we have a refresh token, try to refresh the access token
|
||||||
|
if (error.response?.status === 401 && authStore.refreshToken) {
|
||||||
|
try {
|
||||||
|
await authStore.refreshAccessToken();
|
||||||
|
// Retry the original request
|
||||||
|
const config = error.config;
|
||||||
|
config.headers.Authorization = `Bearer ${authStore.accessToken}`;
|
||||||
|
return api(config);
|
||||||
|
} catch (error) {
|
||||||
|
// If refresh fails, clear tokens and redirect to login
|
||||||
|
authStore.logout();
|
||||||
|
window.location.href = '/login';
|
||||||
|
return Promise.reject(
|
||||||
|
new Error(error instanceof Error ? error.message : 'Failed to refresh token'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a 401 without refresh token or refresh failed, clear tokens and redirect
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
authStore.logout();
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(new Error(error.response?.data?.detail || error.message));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default boot(({ app }) => {
|
||||||
|
app.config.globalProperties.$axios = api;
|
||||||
|
});
|
||||||
|
|
||||||
|
export { api };
|
33
fe/src/boot/i18n.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
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);
|
||||||
|
});
|
35
fe/src/components/EssentialLink.vue
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<template>
|
||||||
|
<q-item
|
||||||
|
clickable
|
||||||
|
tag="a"
|
||||||
|
target="_blank"
|
||||||
|
:href="link"
|
||||||
|
>
|
||||||
|
<q-item-section
|
||||||
|
v-if="icon"
|
||||||
|
avatar
|
||||||
|
>
|
||||||
|
<q-icon :name="icon" />
|
||||||
|
</q-item-section>
|
||||||
|
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>{{ title }}</q-item-label>
|
||||||
|
<q-item-label caption>{{ caption }}</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
export interface EssentialLinkProps {
|
||||||
|
title: string;
|
||||||
|
caption?: string;
|
||||||
|
link?: string;
|
||||||
|
icon?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
withDefaults(defineProps<EssentialLinkProps>(), {
|
||||||
|
caption: '',
|
||||||
|
link: '#',
|
||||||
|
icon: '',
|
||||||
|
});
|
||||||
|
</script>
|
37
fe/src/components/ExampleComponent.vue
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<p>{{ title }}</p>
|
||||||
|
<ul>
|
||||||
|
<li v-for="todo in todos" :key="todo.id" @click="increment">
|
||||||
|
{{ todo.id }} - {{ todo.content }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>Count: {{ todoCount }} / {{ meta.totalCount }}</p>
|
||||||
|
<p>Active: {{ active ? 'yes' : 'no' }}</p>
|
||||||
|
<p>Clicks on todos: {{ clickCount }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import type { Todo, Meta } from './models';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
todos?: Todo[];
|
||||||
|
meta: Meta;
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
todos: () => []
|
||||||
|
});
|
||||||
|
|
||||||
|
const clickCount = ref(0);
|
||||||
|
function increment() {
|
||||||
|
clickCount.value += 1;
|
||||||
|
return clickCount.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const todoCount = computed(() => props.todos.length);
|
||||||
|
</script>
|
8
fe/src/components/models.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export interface Todo {
|
||||||
|
id: number;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Meta {
|
||||||
|
totalCount: number;
|
||||||
|
}
|
1
fe/src/css/app.scss
Normal file
@ -0,0 +1 @@
|
|||||||
|
// app global css in SCSS form
|
25
fe/src/css/quasar.variables.scss
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
// 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
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
declare namespace NodeJS {
|
||||||
|
interface ProcessEnv {
|
||||||
|
NODE_ENV: string;
|
||||||
|
VUE_ROUTER_MODE: 'hash' | 'history' | 'abstract' | undefined;
|
||||||
|
VUE_ROUTER_BASE: string | undefined;
|
||||||
|
}
|
||||||
|
}
|
7
fe/src/i18n/en-US/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
// This is just an example,
|
||||||
|
// so you can safely delete all default props below
|
||||||
|
|
||||||
|
export default {
|
||||||
|
failed: 'Action failed',
|
||||||
|
success: 'Action was successful'
|
||||||
|
};
|
5
fe/src/i18n/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import enUS from './en-US';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
'en-US': enUS
|
||||||
|
};
|
11
fe/src/layouts/AuthLayout.vue
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<template>
|
||||||
|
<q-layout view="hHh lpR fFf">
|
||||||
|
<q-page-container>
|
||||||
|
<router-view />
|
||||||
|
</q-page-container>
|
||||||
|
</q-layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// No additional setup needed for this layout
|
||||||
|
</script>
|
89
fe/src/layouts/MainLayout.vue
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<router-view />
|
||||||
|
</q-page-container>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { useAuthStore } from 'stores/auth';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const $q = useQuasar();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const activeTab = ref('lists');
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
try {
|
||||||
|
authStore.logout();
|
||||||
|
$q.notify({
|
||||||
|
color: 'positive',
|
||||||
|
message: 'Logged out successfully',
|
||||||
|
position: 'top',
|
||||||
|
});
|
||||||
|
void router.push('/login');
|
||||||
|
} catch (error: unknown) {
|
||||||
|
$q.notify({
|
||||||
|
color: 'negative',
|
||||||
|
message: error instanceof Error ? error.message : 'Logout failed',
|
||||||
|
position: 'top',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.q-footer {
|
||||||
|
.q-tabs {
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,197 +0,0 @@
|
|||||||
// src/lib/apiClient.ts
|
|
||||||
|
|
||||||
// Import necessary modules/types
|
|
||||||
import { browser } from '$app/environment'; // For checks if needed
|
|
||||||
import { error } from '@sveltejs/kit'; // Can be used for throwing errors in load functions
|
|
||||||
import { authStore, logout, getCurrentToken } from './stores/authStore'; // Import store and helpers
|
|
||||||
|
|
||||||
// --- Configuration ---
|
|
||||||
// Read base URL from Vite environment variables
|
|
||||||
// Ensure VITE_API_BASE_URL is set in your fe/.env file (e.g., VITE_API_BASE_URL=http://localhost:8000/api)
|
|
||||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
|
||||||
|
|
||||||
// Initial check for configuration during module load (optional but good practice)
|
|
||||||
if (!BASE_URL && browser) { // Only log error in browser, avoid during SSR build if possible
|
|
||||||
console.error(
|
|
||||||
'VITE_API_BASE_URL is not defined. Please set it in your .env file. API calls may fail.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Custom Error Class for API Client ---
|
|
||||||
export class ApiClientError extends Error {
|
|
||||||
status: number; // HTTP status code
|
|
||||||
errorData: unknown; // Parsed error data from response body (if any)
|
|
||||||
|
|
||||||
constructor(message: string, status: number, errorData: unknown = null) {
|
|
||||||
super(message); // Pass message to the base Error class
|
|
||||||
this.name = 'ApiClientError'; // Custom error name
|
|
||||||
this.status = status;
|
|
||||||
this.errorData = errorData;
|
|
||||||
|
|
||||||
// Attempt to capture a cleaner stack trace in V8 environments (Node, Chrome)
|
|
||||||
// Conditionally check if the non-standard captureStackTrace exists
|
|
||||||
if (typeof (Error as any).captureStackTrace === 'function') {
|
|
||||||
// Call it if it exists, casting Error to 'any' to bypass static type check
|
|
||||||
(Error as any).captureStackTrace(this, ApiClientError); // Pass 'this' and the constructor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Request Options Interface ---
|
|
||||||
// Extends standard RequestInit but omits 'body' as we handle it separately
|
|
||||||
interface RequestOptions extends Omit<RequestInit, 'body' | 'headers'> {
|
|
||||||
headers?: HeadersInit;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// --- Core Request Function ---
|
|
||||||
// Uses generics <T> to allow specifying the expected successful response data type
|
|
||||||
async function request<T = unknown>(
|
|
||||||
method: string,
|
|
||||||
path: string,
|
|
||||||
bodyData?: unknown,
|
|
||||||
options: RequestOptions = {}
|
|
||||||
): Promise<T> {
|
|
||||||
if (!BASE_URL) {
|
|
||||||
throw new Error('API Base URL (VITE_API_BASE_URL) is not configured.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanBase = BASE_URL.replace(/\/$/, '');
|
|
||||||
const cleanPath = path.replace(/^\//, '');
|
|
||||||
const url = `${cleanBase}/${cleanPath}`;
|
|
||||||
|
|
||||||
// --- Refined Header Handling ---
|
|
||||||
const headers = new Headers({ Accept: 'application/json' });
|
|
||||||
|
|
||||||
if (options.headers) {
|
|
||||||
new Headers(options.headers).forEach((value, key) => {
|
|
||||||
headers.set(key, value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Prepare Request Body and Set Content-Type ---
|
|
||||||
let processedBody: BodyInit | null = null;
|
|
||||||
if (bodyData !== undefined && bodyData !== null) {
|
|
||||||
if (bodyData instanceof URLSearchParams) {
|
|
||||||
headers.set('Content-Type', 'application/x-www-form-urlencoded');
|
|
||||||
processedBody = bodyData;
|
|
||||||
} else if (bodyData instanceof FormData) {
|
|
||||||
processedBody = bodyData;
|
|
||||||
} else if (typeof bodyData === 'object') {
|
|
||||||
headers.set('Content-Type', 'application/json');
|
|
||||||
try { processedBody = JSON.stringify(bodyData); }
|
|
||||||
catch (e) { throw new Error("Invalid JSON body data provided."); }
|
|
||||||
} else {
|
|
||||||
headers.set('Content-Type', 'application/json');
|
|
||||||
try { processedBody = JSON.stringify(bodyData); }
|
|
||||||
catch (e) { throw new Error("Invalid body data provided."); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Add Authorization Header ---
|
|
||||||
const currentToken = getCurrentToken();
|
|
||||||
if (currentToken && !headers.has('Authorization')) {
|
|
||||||
headers.set('Authorization', `Bearer ${currentToken}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Assemble fetch options carefully ---
|
|
||||||
const fetchOptions: RequestInit = {
|
|
||||||
method: method.toUpperCase(),
|
|
||||||
headers: headers,
|
|
||||||
body: processedBody,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { headers: _, ...restOfOptions } = options;
|
|
||||||
Object.assign(fetchOptions, restOfOptions);
|
|
||||||
|
|
||||||
fetchOptions.credentials = fetchOptions.credentials ?? 'same-origin';
|
|
||||||
fetchOptions.mode = fetchOptions.mode ?? 'cors';
|
|
||||||
fetchOptions.cache = fetchOptions.cache ?? 'default';
|
|
||||||
|
|
||||||
// --- Execute Fetch and Handle Response ---
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, fetchOptions);
|
|
||||||
if (!response.ok) {
|
|
||||||
let errorJson: unknown = null;
|
|
||||||
try { errorJson = await response.json(); }
|
|
||||||
catch (e) { /* ignore */ }
|
|
||||||
const errorToThrow = new ApiClientError(`HTTP Error ${response.status}`, response.status, errorJson);
|
|
||||||
if (response.status === 401) { logout(); }
|
|
||||||
throw errorToThrow;
|
|
||||||
}
|
|
||||||
if (response.status === 204) { return null as T; }
|
|
||||||
return (await response.json()) as T;
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiClientError && err.status === 401) { logout(); }
|
|
||||||
if (err instanceof ApiClientError) { throw err; }
|
|
||||||
throw new ApiClientError('Unknown error occurred', 0, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// --- Convenience Methods (GET, POST, PUT, DELETE, PATCH) ---
|
|
||||||
// Provide simple wrappers around the core 'request' function
|
|
||||||
|
|
||||||
export const apiClient = {
|
|
||||||
/**
|
|
||||||
* Performs a GET request.
|
|
||||||
* @template T The expected type of the response data.
|
|
||||||
* @param path API endpoint path (e.g., '/v1/users/me').
|
|
||||||
* @param options Optional fetch request options.
|
|
||||||
* @returns Promise resolving to the parsed JSON response body of type T.
|
|
||||||
*/
|
|
||||||
get: <T = unknown>(path: string, options: RequestOptions = {}): Promise<T> => {
|
|
||||||
return request<T>('GET', path, undefined, options);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Performs a POST request.
|
|
||||||
* @template T The expected type of the response data.
|
|
||||||
* @param path API endpoint path (e.g., '/v1/auth/signup').
|
|
||||||
* @param data Request body data (object, FormData, URLSearchParams).
|
|
||||||
* @param options Optional fetch request options.
|
|
||||||
* @returns Promise resolving to the parsed JSON response body of type T.
|
|
||||||
*/
|
|
||||||
post: <T = unknown>(path: string, data: unknown, options: RequestOptions = {}): Promise<T> => {
|
|
||||||
return request<T>('POST', path, data, options);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Performs a PUT request.
|
|
||||||
* @template T The expected type of the response data.
|
|
||||||
* @param path API endpoint path.
|
|
||||||
* @param data Request body data.
|
|
||||||
* @param options Optional fetch request options.
|
|
||||||
* @returns Promise resolving to the parsed JSON response body of type T.
|
|
||||||
*/
|
|
||||||
put: <T = unknown>(path: string, data: unknown, options: RequestOptions = {}): Promise<T> => {
|
|
||||||
return request<T>('PUT', path, data, options);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Performs a DELETE request.
|
|
||||||
* @template T The expected type of the response data (often null or void).
|
|
||||||
* @param path API endpoint path.
|
|
||||||
* @param options Optional fetch request options.
|
|
||||||
* @returns Promise resolving to the parsed JSON response body (often null for 204).
|
|
||||||
*/
|
|
||||||
delete: <T = unknown>(path: string, options: RequestOptions = {}): Promise<T> => {
|
|
||||||
// DELETE requests might or might not have a body depending on API design
|
|
||||||
return request<T>('DELETE', path, undefined, options);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Performs a PATCH request.
|
|
||||||
* @template T The expected type of the response data.
|
|
||||||
* @param path API endpoint path.
|
|
||||||
* @param data Request body data (usually partial updates).
|
|
||||||
* @param options Optional fetch request options.
|
|
||||||
* @returns Promise resolving to the parsed JSON response body of type T.
|
|
||||||
*/
|
|
||||||
patch: <T = unknown>(path: string, data: unknown, options: RequestOptions = {}): Promise<T> => {
|
|
||||||
return request<T>('PATCH', path, data, options);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Optional: Export the error class as well if needed externally
|
|
||||||
// export { ApiClientError };
|
|
@ -1,234 +0,0 @@
|
|||||||
<!-- src/lib/components/ImageOcrInput.svelte -->
|
|
||||||
<script lang="ts">
|
|
||||||
import { createEventDispatcher } from 'svelte';
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{
|
|
||||||
imageSelected: File; // Dispatch the selected file object
|
|
||||||
cancel: void; // Dispatch when user cancels
|
|
||||||
}>();
|
|
||||||
|
|
||||||
let selectedFile: File | null = null;
|
|
||||||
let previewUrl: string | null = null;
|
|
||||||
let inputKey = Date.now(); // Key to reset file input if needed
|
|
||||||
let error: string | null = null;
|
|
||||||
|
|
||||||
// Refs for the input elements
|
|
||||||
let fileInput: HTMLInputElement;
|
|
||||||
let captureInput: HTMLInputElement;
|
|
||||||
|
|
||||||
const MAX_FILE_SIZE_MB = 10; // Match backend limit
|
|
||||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
|
||||||
|
|
||||||
function handleFileChange(event: Event) {
|
|
||||||
error = null; // Clear previous error
|
|
||||||
const target = event.target as HTMLInputElement;
|
|
||||||
const file = target.files?.[0];
|
|
||||||
|
|
||||||
if (file) {
|
|
||||||
// Basic Validation
|
|
||||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
|
||||||
error = `Invalid file type. Please select JPEG, PNG, or WEBP. Type found: ${file.type}`;
|
|
||||||
resetInput();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) {
|
|
||||||
error = `File is too large (max ${MAX_FILE_SIZE_MB}MB). Size: ${(file.size / 1024 / 1024).toFixed(2)}MB`;
|
|
||||||
resetInput();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedFile = file;
|
|
||||||
// Create a preview URL
|
|
||||||
if (previewUrl) URL.revokeObjectURL(previewUrl); // Revoke previous URL
|
|
||||||
previewUrl = URL.createObjectURL(file);
|
|
||||||
console.log('Image selected:', file.name, file.type, file.size);
|
|
||||||
} else {
|
|
||||||
// No file selected (e.g., user cancelled file picker)
|
|
||||||
// Optionally clear existing selection if needed
|
|
||||||
// clearSelection();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearSelection() {
|
|
||||||
selectedFile = null;
|
|
||||||
if (previewUrl) {
|
|
||||||
URL.revokeObjectURL(previewUrl);
|
|
||||||
previewUrl = null;
|
|
||||||
}
|
|
||||||
error = null;
|
|
||||||
resetInput(); // Reset the input fields
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetInput() {
|
|
||||||
// Changing the key forces Svelte to recreate the input, clearing its value
|
|
||||||
inputKey = Date.now();
|
|
||||||
// Also reset the value manually in case key trick doesn't work everywhere
|
|
||||||
if (fileInput) fileInput.value = '';
|
|
||||||
if (captureInput) captureInput.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerFileInput() {
|
|
||||||
fileInput?.click(); // Programmatically click the hidden file input
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerCaptureInput() {
|
|
||||||
captureInput?.click(); // Programmatically click the hidden capture input
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleConfirm() {
|
|
||||||
if (selectedFile) {
|
|
||||||
dispatch('imageSelected', selectedFile);
|
|
||||||
} else {
|
|
||||||
error = 'Please select or capture an image first.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCancel() {
|
|
||||||
clearSelection();
|
|
||||||
dispatch('cancel');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up object URL when component is destroyed
|
|
||||||
onDestroy(() => {
|
|
||||||
if (previewUrl) {
|
|
||||||
URL.revokeObjectURL(previewUrl);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
import { onDestroy } from 'svelte'; // Ensure onDestroy is imported
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Basic Modal Structure (adapt styling as needed) -->
|
|
||||||
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
||||||
<div
|
|
||||||
class="bg-opacity-60 fixed inset-0 z-50 flex items-center justify-center bg-black p-4"
|
|
||||||
on:click|self={handleCancel}
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="ocr-modal-title"
|
|
||||||
>
|
|
||||||
<div class="w-full max-w-lg rounded-lg bg-white p-6 shadow-xl">
|
|
||||||
<h2 id="ocr-modal-title" class="mb-4 text-xl font-semibold text-gray-800">
|
|
||||||
Add Items via Photo
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<div
|
|
||||||
class="mb-4 rounded border border-red-400 bg-red-100 p-3 text-sm text-red-700"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Hidden Inputs -->
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/jpeg, image/png, image/webp"
|
|
||||||
on:change={handleFileChange}
|
|
||||||
bind:this={fileInput}
|
|
||||||
key={inputKey}
|
|
||||||
class="hidden"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/jpeg, image/png, image/webp"
|
|
||||||
capture="environment"
|
|
||||||
on:change={handleFileChange}
|
|
||||||
bind:this={captureInput}
|
|
||||||
key={inputKey}
|
|
||||||
class="hidden"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if previewUrl}
|
|
||||||
<!-- Preview Section -->
|
|
||||||
<div class="mb-4 text-center">
|
|
||||||
<p class="mb-2 text-sm text-gray-600">Image Preview:</p>
|
|
||||||
<img
|
|
||||||
src={previewUrl}
|
|
||||||
alt="Selected list preview"
|
|
||||||
class="mx-auto max-h-60 w-auto rounded border border-gray-300 object-contain"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
on:click={clearSelection}
|
|
||||||
class="mt-2 text-xs text-red-600 hover:underline"
|
|
||||||
>
|
|
||||||
Clear Selection
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
|
||||||
<div class="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
on:click={triggerCaptureInput}
|
|
||||||
class="flex w-full items-center justify-center rounded border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none"
|
|
||||||
>
|
|
||||||
<!-- Basic Camera Icon -->
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="mr-2 h-5 w-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
><path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
|
|
||||||
/><path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
Take Photo
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
on:click={triggerFileInput}
|
|
||||||
class="flex w-full items-center justify-center rounded border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none"
|
|
||||||
>
|
|
||||||
<!-- Basic Upload Icon -->
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="mr-2 h-5 w-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
><path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
Upload File
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Confirmation/Cancel -->
|
|
||||||
<div class="mt-6 flex justify-end space-x-3 border-t pt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
on:click={handleCancel}
|
|
||||||
class="rounded border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
on:click={handleConfirm}
|
|
||||||
disabled={!selectedFile}
|
|
||||||
class="rounded border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Confirm Image
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
@ -1,318 +0,0 @@
|
|||||||
<!-- src/lib/components/ItemDisplay.svelte -->
|
|
||||||
<script lang="ts">
|
|
||||||
import { createEventDispatcher } from 'svelte';
|
|
||||||
import { apiClient, ApiClientError } from '$lib/apiClient';
|
|
||||||
import type { ItemPublic, ItemUpdate } from '$lib/schemas/item';
|
|
||||||
// --- DB and Sync Imports ---
|
|
||||||
import { putItemToDb, deleteItemFromDb, addSyncAction } from '$lib/db';
|
|
||||||
import { processSyncQueue } from '$lib/syncService';
|
|
||||||
import { browser } from '$app/environment';
|
|
||||||
import { authStore } from '$lib/stores/authStore'; // Get current user ID
|
|
||||||
import { get } from 'svelte/store'; // Import get
|
|
||||||
// --- End DB and Sync Imports ---
|
|
||||||
|
|
||||||
export let item: ItemPublic;
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{
|
|
||||||
itemUpdated: ItemPublic; // Event when item is successfully updated (toggle/edit)
|
|
||||||
itemDeleted: number; // Event when item is successfully deleted (sends ID)
|
|
||||||
updateError: string; // Event to bubble up errors
|
|
||||||
}>();
|
|
||||||
|
|
||||||
// --- Component State ---
|
|
||||||
let isEditing = false;
|
|
||||||
let isToggling = false;
|
|
||||||
let isDeleting = false;
|
|
||||||
let isSavingEdit = false;
|
|
||||||
|
|
||||||
// State for edit form
|
|
||||||
let editName = '';
|
|
||||||
let editQuantity = '';
|
|
||||||
|
|
||||||
// --- Edit Mode ---
|
|
||||||
function startEdit() {
|
|
||||||
if (isEditing) return;
|
|
||||||
editName = item.name;
|
|
||||||
editQuantity = item.quantity ?? '';
|
|
||||||
isEditing = true;
|
|
||||||
dispatch('updateError', ''); // Clear previous errors when starting edit
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelEdit() {
|
|
||||||
isEditing = false;
|
|
||||||
dispatch('updateError', ''); // Clear errors on cancel too
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- API Interactions (Modified for Offline) ---
|
|
||||||
|
|
||||||
async function handleToggleComplete() {
|
|
||||||
if (isToggling || isEditing) return;
|
|
||||||
isToggling = true;
|
|
||||||
dispatch('updateError', '');
|
|
||||||
|
|
||||||
const newStatus = !item.is_complete;
|
|
||||||
const updateData: ItemUpdate = { is_complete: newStatus };
|
|
||||||
const currentUserId = get(authStore).user?.id; // Get user ID synchronously
|
|
||||||
|
|
||||||
// 1. Optimistic DB Update (UI update delegated to parent via event)
|
|
||||||
const optimisticItem = {
|
|
||||||
...item,
|
|
||||||
is_complete: newStatus,
|
|
||||||
// Set completed_by_id based on new status and current user
|
|
||||||
completed_by_id: newStatus ? (currentUserId ?? item.completed_by_id ?? null) : null,
|
|
||||||
updated_at: new Date().toISOString() // Update timestamp locally
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
await putItemToDb(optimisticItem);
|
|
||||||
dispatch('itemUpdated', optimisticItem); // Dispatch optimistic update immediately
|
|
||||||
} catch (dbError) {
|
|
||||||
console.error('Optimistic toggle DB update failed:', dbError);
|
|
||||||
dispatch('updateError', 'Failed to save state locally.');
|
|
||||||
isToggling = false;
|
|
||||||
return; // Stop if DB update fails
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Queue or Send API Call
|
|
||||||
console.log(`Toggling item ${item.id} to ${newStatus}`);
|
|
||||||
try {
|
|
||||||
if (browser && !navigator.onLine) {
|
|
||||||
// OFFLINE: Queue action
|
|
||||||
console.log(`Offline: Queuing update for item ${item.id}`);
|
|
||||||
await addSyncAction({
|
|
||||||
type: 'update_item',
|
|
||||||
payload: { id: item.id, data: updateData },
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// ONLINE: Send API call directly
|
|
||||||
const updatedItemFromServer = await apiClient.put<ItemPublic>(
|
|
||||||
`/v1/items/${item.id}`,
|
|
||||||
updateData
|
|
||||||
);
|
|
||||||
// Update DB and dispatch again with potentially more accurate server data
|
|
||||||
await putItemToDb(updatedItemFromServer);
|
|
||||||
dispatch('itemUpdated', updatedItemFromServer);
|
|
||||||
}
|
|
||||||
// Trigger sync if online after queuing or direct call
|
|
||||||
if (browser && navigator.onLine) processSyncQueue();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Toggle item ${item.id} failed:`, err);
|
|
||||||
const errorMsg =
|
|
||||||
err instanceof ApiClientError ? `Error (${err.status}): ${err.message}` : 'Toggle failed';
|
|
||||||
dispatch('updateError', errorMsg);
|
|
||||||
// TODO: Consider reverting optimistic update on error? More complex.
|
|
||||||
// For now, just show error. User might need to manually fix state or refresh.
|
|
||||||
} finally {
|
|
||||||
isToggling = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSaveEdit() {
|
|
||||||
if (!editName.trim()) {
|
|
||||||
dispatch('updateError', 'Item name cannot be empty.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isSavingEdit) return;
|
|
||||||
|
|
||||||
isSavingEdit = true;
|
|
||||||
dispatch('updateError', '');
|
|
||||||
|
|
||||||
const updateData: ItemUpdate = {
|
|
||||||
name: editName.trim(),
|
|
||||||
quantity: editQuantity.trim() || undefined // Send undefined if empty
|
|
||||||
};
|
|
||||||
|
|
||||||
// 1. Optimistic DB / UI
|
|
||||||
const optimisticItem = {
|
|
||||||
...item,
|
|
||||||
name: updateData.name!,
|
|
||||||
quantity: updateData.quantity ?? null,
|
|
||||||
updated_at: new Date().toISOString()
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
await putItemToDb(optimisticItem);
|
|
||||||
dispatch('itemUpdated', optimisticItem);
|
|
||||||
} catch (dbError) {
|
|
||||||
console.error('Optimistic edit DB update failed:', dbError);
|
|
||||||
dispatch('updateError', 'Failed to save state locally.');
|
|
||||||
isSavingEdit = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Queue or Send API Call
|
|
||||||
console.log(`Saving edits for item ${item.id}`, updateData);
|
|
||||||
try {
|
|
||||||
if (browser && !navigator.onLine) {
|
|
||||||
console.log(`Offline: Queuing update for item ${item.id}`);
|
|
||||||
await addSyncAction({
|
|
||||||
type: 'update_item',
|
|
||||||
payload: { id: item.id, data: updateData },
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const updatedItemFromServer = await apiClient.put<ItemPublic>(
|
|
||||||
`/v1/items/${item.id}`,
|
|
||||||
updateData
|
|
||||||
);
|
|
||||||
await putItemToDb(updatedItemFromServer);
|
|
||||||
dispatch('itemUpdated', updatedItemFromServer); // Update with server data
|
|
||||||
}
|
|
||||||
if (browser && navigator.onLine) processSyncQueue();
|
|
||||||
isEditing = false; // Exit edit mode on success
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Save edit for item ${item.id} failed:`, err);
|
|
||||||
const errorMsg =
|
|
||||||
err instanceof ApiClientError ? `Error (${err.status}): ${err.message}` : 'Save failed';
|
|
||||||
dispatch('updateError', errorMsg);
|
|
||||||
// TODO: Revert optimistic update?
|
|
||||||
} finally {
|
|
||||||
isSavingEdit = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete() {
|
|
||||||
if (isDeleting || isEditing) return;
|
|
||||||
|
|
||||||
if (!confirm(`Are you sure you want to delete item "${item.name}"?`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isDeleting = true;
|
|
||||||
dispatch('updateError', '');
|
|
||||||
|
|
||||||
const itemIdToDelete = item.id;
|
|
||||||
|
|
||||||
// 1. Optimistic DB / UI
|
|
||||||
try {
|
|
||||||
await deleteItemFromDb(itemIdToDelete);
|
|
||||||
dispatch('itemDeleted', itemIdToDelete); // Notify parent immediately
|
|
||||||
} catch (dbError) {
|
|
||||||
console.error('Optimistic delete DB update failed:', dbError);
|
|
||||||
dispatch('updateError', 'Failed to delete item locally.');
|
|
||||||
isDeleting = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Queue or Send API Call
|
|
||||||
console.log(`Deleting item ${itemIdToDelete}`);
|
|
||||||
try {
|
|
||||||
if (browser && !navigator.onLine) {
|
|
||||||
console.log(`Offline: Queuing delete for item ${itemIdToDelete}`);
|
|
||||||
await addSyncAction({
|
|
||||||
type: 'delete_item',
|
|
||||||
payload: { id: itemIdToDelete },
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await apiClient.delete(`/v1/items/${itemIdToDelete}`);
|
|
||||||
}
|
|
||||||
if (browser && navigator.onLine) processSyncQueue();
|
|
||||||
// Component will be destroyed by parent on success
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Delete item ${itemIdToDelete} failed:`, err);
|
|
||||||
const errorMsg =
|
|
||||||
err instanceof ApiClientError ? `Error (${err.status}): ${err.message}` : 'Delete failed';
|
|
||||||
dispatch('updateError', errorMsg);
|
|
||||||
// If API delete failed, the item was already removed from UI/DB optimistically.
|
|
||||||
// User may need to refresh to see it again if the delete wasn't valid server-side.
|
|
||||||
// For MVP, just show the error.
|
|
||||||
isDeleting = false; // Reset loading state only on error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- TEMPLATE -->
|
|
||||||
<li
|
|
||||||
class="flex items-center justify-between gap-4 rounded border p-3 transition duration-150 ease-in-out hover:bg-gray-50"
|
|
||||||
class:border-gray-200={!isEditing}
|
|
||||||
class:border-blue-400={isEditing}
|
|
||||||
class:opacity-60={item.is_complete && !isEditing}
|
|
||||||
>
|
|
||||||
{#if isEditing}
|
|
||||||
<!-- Edit Mode Form -->
|
|
||||||
<form on:submit|preventDefault={handleSaveEdit} class="flex flex-grow items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
bind:value={editName}
|
|
||||||
required
|
|
||||||
class="flex-grow rounded border border-gray-300 px-2 py-1 text-sm shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
|
||||||
disabled={isSavingEdit}
|
|
||||||
aria-label="Edit item name"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
bind:value={editQuantity}
|
|
||||||
placeholder="Qty (opt.)"
|
|
||||||
class="w-20 rounded border border-gray-300 px-2 py-1 text-sm shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
|
||||||
disabled={isSavingEdit}
|
|
||||||
aria-label="Edit item quantity"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="rounded bg-green-600 px-2 py-1 text-xs text-white hover:bg-green-700 disabled:opacity-50"
|
|
||||||
disabled={isSavingEdit}
|
|
||||||
aria-label="Save changes"
|
|
||||||
>
|
|
||||||
{isSavingEdit ? '...' : 'Save'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
on:click={cancelEdit}
|
|
||||||
class="rounded bg-gray-500 px-2 py-1 text-xs text-white hover:bg-gray-600"
|
|
||||||
disabled={isSavingEdit}
|
|
||||||
aria-label="Cancel edit"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{:else}
|
|
||||||
<!-- Display Mode -->
|
|
||||||
<div class="flex flex-grow items-center gap-3 overflow-hidden">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={item.is_complete}
|
|
||||||
disabled={isToggling || isDeleting}
|
|
||||||
aria-label="Mark {item.name} as {item.is_complete ? 'incomplete' : 'complete'}"
|
|
||||||
class="h-5 w-5 flex-shrink-0 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
on:change={handleToggleComplete}
|
|
||||||
/>
|
|
||||||
<div class="flex-grow overflow-hidden">
|
|
||||||
<span
|
|
||||||
class="block truncate font-medium text-gray-800"
|
|
||||||
class:line-through={item.is_complete}
|
|
||||||
class:text-gray-500={item.is_complete}
|
|
||||||
title={item.name}
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</span>
|
|
||||||
{#if item.quantity}
|
|
||||||
<span
|
|
||||||
class="block truncate text-sm text-gray-500"
|
|
||||||
class:line-through={item.is_complete}
|
|
||||||
title={item.quantity}
|
|
||||||
>
|
|
||||||
Qty: {item.quantity}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-shrink-0 items-center space-x-2">
|
|
||||||
<button
|
|
||||||
on:click={startEdit}
|
|
||||||
class="rounded p-1 text-xs text-gray-400 hover:bg-gray-200 hover:text-gray-700"
|
|
||||||
title="Edit Item"
|
|
||||||
disabled={isToggling || isDeleting}
|
|
||||||
>
|
|
||||||
✏️
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
on:click={handleDelete}
|
|
||||||
class="rounded p-1 text-xs text-red-400 hover:bg-red-100 hover:text-red-600"
|
|
||||||
title="Delete Item"
|
|
||||||
disabled={isToggling || isDeleting}
|
|
||||||
>
|
|
||||||
{#if isDeleting}⏳{:else}🗑️{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</li>
|
|
@ -1,201 +0,0 @@
|
|||||||
<!-- src/lib/components/ListForm.svelte -->
|
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { apiClient, ApiClientError } from '$lib/apiClient';
|
|
||||||
import type { GroupPublic } from '$lib/schemas/group';
|
|
||||||
import type { ListPublic, ListCreate, ListUpdate } from '$lib/schemas/list'; // Import necessary types
|
|
||||||
|
|
||||||
// Props
|
|
||||||
/** Optional existing list data for editing */
|
|
||||||
export let list: ListPublic | null = null;
|
|
||||||
/** Array of user's groups for the dropdown */
|
|
||||||
export let groups: GroupPublic[] = [];
|
|
||||||
/** Optional error message passed from parent (e.g., load error) */
|
|
||||||
export let apiError: string | null = null;
|
|
||||||
|
|
||||||
// Form State
|
|
||||||
let name = '';
|
|
||||||
let description = '';
|
|
||||||
let selectedGroupId: string = 'null'; // Use 'null' string for the "Personal" option value
|
|
||||||
let isLoading = false;
|
|
||||||
let errorMessage: string | null = null;
|
|
||||||
let successMessage: string | null = null;
|
|
||||||
|
|
||||||
// Determine mode and initialize form
|
|
||||||
let isEditMode = false;
|
|
||||||
$: {
|
|
||||||
// Reactive block: runs when props change
|
|
||||||
isEditMode = !!list;
|
|
||||||
// Reset form when list prop changes (navigating between edit pages)
|
|
||||||
// or initialize for creation
|
|
||||||
name = list?.name ?? '';
|
|
||||||
description = list?.description ?? '';
|
|
||||||
// Set dropdown: if list has group_id, convert to string; otherwise, use 'null' string
|
|
||||||
selectedGroupId = list?.group_id != null ? String(list.group_id) : 'null';
|
|
||||||
errorMessage = null; // Clear errors on list change
|
|
||||||
successMessage = null;
|
|
||||||
isLoading = false;
|
|
||||||
console.log('ListForm initialized. Edit mode:', isEditMode, 'List:', list);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update local error if apiError prop changes
|
|
||||||
$: if (apiError) errorMessage = apiError;
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
if (!name.trim()) {
|
|
||||||
errorMessage = 'List name cannot be empty.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading = true;
|
|
||||||
errorMessage = null;
|
|
||||||
successMessage = null;
|
|
||||||
|
|
||||||
// Prepare data based on create or edit mode
|
|
||||||
const requestBody: ListCreate | ListUpdate = {
|
|
||||||
name: name.trim(),
|
|
||||||
description: description.trim() || undefined // Send undefined if empty
|
|
||||||
// Only include group_id for creation, not typically editable this way
|
|
||||||
// For edit, we'd usually handle 'is_complete' if needed, but not group_id change here
|
|
||||||
};
|
|
||||||
if (!isEditMode) {
|
|
||||||
(requestBody as ListCreate).group_id =
|
|
||||||
selectedGroupId === 'null' ? null : parseInt(selectedGroupId, 10);
|
|
||||||
}
|
|
||||||
// If editing, you might add other updatable fields like is_complete
|
|
||||||
// if (isEditMode) {
|
|
||||||
// (requestBody as ListUpdate).is_complete = someCheckboxValue;
|
|
||||||
// }
|
|
||||||
|
|
||||||
console.log(`Submitting list data (${isEditMode ? 'Edit' : 'Create'}):`, requestBody);
|
|
||||||
|
|
||||||
try {
|
|
||||||
let resultList: ListPublic;
|
|
||||||
if (isEditMode && list) {
|
|
||||||
// PUT request for updating
|
|
||||||
resultList = await apiClient.put<ListPublic>(`/v1/lists/${list.id}`, requestBody);
|
|
||||||
successMessage = `List "${resultList.name}" updated successfully!`;
|
|
||||||
} else {
|
|
||||||
// POST request for creating
|
|
||||||
resultList = await apiClient.post<ListPublic>('/v1/lists', requestBody);
|
|
||||||
successMessage = `List "${resultList.name}" created successfully!`;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('List submission successful:', resultList);
|
|
||||||
|
|
||||||
// Redirect after a short delay to show success message
|
|
||||||
setTimeout(async () => {
|
|
||||||
// Redirect to dashboard after create/edit
|
|
||||||
await goto('/dashboard');
|
|
||||||
// Or redirect to the list detail page after edit?
|
|
||||||
// if (isEditMode) await goto(`/groups/${resultList.id}`); // Need group detail route
|
|
||||||
}, 1000); // 1 second delay
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`List ${isEditMode ? 'update' : 'creation'} failed:`, err);
|
|
||||||
if (err instanceof ApiClientError) {
|
|
||||||
let detail = `Failed to ${isEditMode ? 'update' : 'create'} list.`;
|
|
||||||
if (err.errorData && typeof err.errorData === 'object' && 'detail' in err.errorData) {
|
|
||||||
detail = (err.errorData as { detail: string }).detail; // Use 'as' assertion
|
|
||||||
}
|
|
||||||
errorMessage = `Error (${err.status}): ${detail}`;
|
|
||||||
} else if (err instanceof Error) {
|
|
||||||
errorMessage = `Error: ${err.message}`;
|
|
||||||
} else {
|
|
||||||
errorMessage = 'An unexpected error occurred.';
|
|
||||||
}
|
|
||||||
isLoading = false; // Ensure loading stops on error
|
|
||||||
}
|
|
||||||
// No finally needed here as success leads to navigation
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<form on:submit|preventDefault={handleSubmit} class="space-y-4 rounded bg-white p-6 shadow">
|
|
||||||
<h2 class="mb-4 text-xl font-semibold text-gray-700">
|
|
||||||
{isEditMode ? 'Edit List' : 'Create New List'}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{#if successMessage}
|
|
||||||
<div
|
|
||||||
class="mb-4 rounded border border-green-400 bg-green-100 p-3 text-center text-sm text-green-700"
|
|
||||||
role="status"
|
|
||||||
>
|
|
||||||
{successMessage} Redirecting...
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if errorMessage}
|
|
||||||
<div
|
|
||||||
class="mb-4 rounded border border-red-400 bg-red-100 p-3 text-center text-sm text-red-700"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
{errorMessage}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="list-name" class="mb-1 block text-sm font-medium text-gray-600">List Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="list-name"
|
|
||||||
bind:value={name}
|
|
||||||
required
|
|
||||||
class="w-full rounded border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-70"
|
|
||||||
disabled={isLoading || !!successMessage}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="list-description" class="mb-1 block text-sm font-medium text-gray-600"
|
|
||||||
>Description (Optional)</label
|
|
||||||
>
|
|
||||||
<!-- Corrected textarea tag -->
|
|
||||||
<textarea
|
|
||||||
id="list-description"
|
|
||||||
bind:value={description}
|
|
||||||
rows="3"
|
|
||||||
class="w-full rounded border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-70"
|
|
||||||
disabled={isLoading || !!successMessage}
|
|
||||||
></textarea>
|
|
||||||
<!-- Ensure closing tag -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Only show group selector in create mode -->
|
|
||||||
{#if !isEditMode}
|
|
||||||
<div>
|
|
||||||
<label for="list-group" class="mb-1 block text-sm font-medium text-gray-600"
|
|
||||||
>Share with Group (Optional)</label
|
|
||||||
>
|
|
||||||
<select
|
|
||||||
id="list-group"
|
|
||||||
bind:value={selectedGroupId}
|
|
||||||
class="w-full rounded border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-70"
|
|
||||||
disabled={isLoading || !!successMessage}
|
|
||||||
>
|
|
||||||
<option value="null">Personal (No Group)</option>
|
|
||||||
{#each groups as group (group.id)}
|
|
||||||
<option value={String(group.id)}>{group.name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
{#if groups.length === 0}
|
|
||||||
<p class="mt-1 text-xs text-gray-500">You are not a member of any groups to share with.</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex items-center justify-end space-x-3 pt-2">
|
|
||||||
<a href="/dashboard" class="text-sm text-gray-600 hover:underline">Cancel</a>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="rounded bg-blue-600 px-4 py-2 font-medium whitespace-nowrap text-white transition duration-150 ease-in-out hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
disabled={isLoading || !!successMessage}
|
|
||||||
>
|
|
||||||
{#if isLoading}
|
|
||||||
Saving...
|
|
||||||
{:else if isEditMode}
|
|
||||||
Save Changes
|
|
||||||
{:else}
|
|
||||||
Create List
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
@ -1,165 +0,0 @@
|
|||||||
<!-- src/lib/components/OcrReview.svelte -->
|
|
||||||
<script lang="ts">
|
|
||||||
import { createEventDispatcher, onMount, tick } from 'svelte'; // Added tick
|
|
||||||
import { fade } from 'svelte/transition';
|
|
||||||
import { flip } from 'svelte/animate';
|
|
||||||
|
|
||||||
// Props
|
|
||||||
/** Initial list of item names extracted by OCR */
|
|
||||||
export let initialItems: string[] = [];
|
|
||||||
|
|
||||||
// Events
|
|
||||||
const dispatch = createEventDispatcher<{
|
|
||||||
confirm: string[]; // Final list of item names
|
|
||||||
cancel: void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
// Internal State
|
|
||||||
interface ReviewItem {
|
|
||||||
id: string; // Unique key for {#each} and focus management
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
let reviewedItems: ReviewItem[] = [];
|
|
||||||
export let isLoading: boolean = false; // Add isLoading prop
|
|
||||||
let inputRefs: Record<string, HTMLInputElement> = {}; // To store references for focusing
|
|
||||||
|
|
||||||
// Initialize items with unique IDs when component mounts or prop changes
|
|
||||||
$: if (initialItems) {
|
|
||||||
reviewedItems = initialItems.map((name) => ({
|
|
||||||
id: crypto.randomUUID(), // Generate unique ID for each item
|
|
||||||
name: name
|
|
||||||
}));
|
|
||||||
console.log('OcrReview initialized with items:', reviewedItems);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Deletes an item from the review list */
|
|
||||||
function deleteItem(idToDelete: string) {
|
|
||||||
reviewedItems = reviewedItems.filter((item) => item.id !== idToDelete);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Adds a new, empty input field to the list */
|
|
||||||
async function addItemManually() {
|
|
||||||
const newItemId = crypto.randomUUID();
|
|
||||||
// Add a new empty item at the end
|
|
||||||
reviewedItems = [...reviewedItems, { id: newItemId, name: '' }];
|
|
||||||
// Wait for the DOM to update, then focus the new input
|
|
||||||
await tick();
|
|
||||||
if (inputRefs[newItemId]) {
|
|
||||||
inputRefs[newItemId].focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Dispatches the confirmed list of non-empty item names */
|
|
||||||
function handleConfirm() {
|
|
||||||
// Filter out empty items and extract just the names
|
|
||||||
const finalItemNames = reviewedItems
|
|
||||||
.map((item) => item.name.trim())
|
|
||||||
.filter((name) => name.length > 0);
|
|
||||||
|
|
||||||
console.log('OcrReview confirming items:', finalItemNames);
|
|
||||||
dispatch('confirm', finalItemNames);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Dispatches the cancel event */
|
|
||||||
function handleCancel() {
|
|
||||||
dispatch('cancel');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Modal Structure -->
|
|
||||||
<div
|
|
||||||
transition:fade={{ duration: 150 }}
|
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60 p-4"
|
|
||||||
on:click|self={handleCancel}
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="ocr-review-title"
|
|
||||||
>
|
|
||||||
<!-- Prevent backdrop click from closing when clicking inside modal content -->
|
|
||||||
<div
|
|
||||||
class="flex max-h-[85vh] w-full max-w-lg flex-col rounded-lg bg-white shadow-xl"
|
|
||||||
on:click|stopPropagation
|
|
||||||
>
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex-shrink-0 border-b p-4">
|
|
||||||
<h2 id="ocr-review-title" class="text-xl font-semibold text-gray-800">
|
|
||||||
Review Extracted Items
|
|
||||||
</h2>
|
|
||||||
<p class="mt-1 text-sm text-gray-600">
|
|
||||||
Edit, delete, or add items below. Confirm to add them to your list.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Item List (Scrollable) -->
|
|
||||||
<div class="flex-grow overflow-y-auto p-4">
|
|
||||||
{#if reviewedItems.length > 0}
|
|
||||||
<ul class="space-y-2">
|
|
||||||
{#each reviewedItems as item (item.id)}
|
|
||||||
<li class="flex items-center gap-2" animate:flip={{ duration: 200 }}>
|
|
||||||
<!-- Bind input element reference using item.id as key -->
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
bind:value={item.name}
|
|
||||||
bind:this={inputRefs[item.id]}
|
|
||||||
placeholder="Enter item name..."
|
|
||||||
class="flex-grow rounded border border-gray-300 px-2 py-1 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
aria-label="Item name"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
on:click={() => deleteItem(item.id)}
|
|
||||||
title="Remove item"
|
|
||||||
class="flex-shrink-0 rounded p-1 text-red-500 hover:bg-red-100 focus:outline-none focus:ring-1 focus:ring-red-400"
|
|
||||||
aria-label="Remove item"
|
|
||||||
>
|
|
||||||
<!-- Basic 'X' icon -->
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{:else}
|
|
||||||
<p class="py-4 text-center text-sm text-gray-500">
|
|
||||||
No items extracted. Add items manually below.
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add Manually Button -->
|
|
||||||
<div class="flex-shrink-0 px-4 pb-4">
|
|
||||||
<button
|
|
||||||
on:click={addItemManually}
|
|
||||||
class="w-full rounded border border-dashed border-gray-400 px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
+ Add Item Manually
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer Actions -->
|
|
||||||
<div class="flex flex-shrink-0 justify-end space-x-3 border-t bg-gray-50 p-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
on:click={handleCancel}
|
|
||||||
class="rounded border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
on:click={handleConfirm}
|
|
||||||
class="rounded border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{isLoading ? 'Adding...' : 'Confirm & Add Items'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
195
fe/src/lib/db.ts
@ -1,195 +0,0 @@
|
|||||||
// src/lib/db.ts
|
|
||||||
import { openDB, type IDBPDatabase, type DBSchema } from 'idb';
|
|
||||||
import type { ListDetail, ListPublic } from './schemas/list'; // Import your list types
|
|
||||||
import type { ItemPublic } from './schemas/item'; // Import your item type
|
|
||||||
|
|
||||||
const DB_NAME = 'SharedListsDB';
|
|
||||||
const DB_VERSION = 1; // Increment this when changing schema
|
|
||||||
|
|
||||||
// Define the structure for queued actions
|
|
||||||
export interface SyncAction {
|
|
||||||
id?: number; // Optional: will be added by IndexedDB autoIncrement
|
|
||||||
type: 'create_list' | 'update_list' | 'delete_list' | 'create_item' | 'update_item' | 'delete_item';
|
|
||||||
payload: any; // Data needed for the API call (e.g., listId, itemId, updateData)
|
|
||||||
timestamp: number;
|
|
||||||
tempId?: string; // Optional temporary ID for optimistic UI mapping (e.g., for newly created items)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Define the database schema using TypeScript interface
|
|
||||||
interface SharedListsDBSchema extends DBSchema {
|
|
||||||
lists: {
|
|
||||||
key: number; // Primary key (list.id)
|
|
||||||
value: ListDetail; // Store full detail including items
|
|
||||||
indexes: Record<string, string>; // Example indexes
|
|
||||||
};
|
|
||||||
items: {
|
|
||||||
key: number; // Primary key (item.id)
|
|
||||||
value: ItemPublic;
|
|
||||||
indexes: Record<string, string>; // Index by listId is crucial
|
|
||||||
};
|
|
||||||
syncQueue: {
|
|
||||||
key: number; // Auto-incrementing key
|
|
||||||
value: SyncAction;
|
|
||||||
// No indexes needed for simple queue processing
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let dbPromise: Promise<IDBPDatabase<SharedListsDBSchema>> | null = null;
|
|
||||||
|
|
||||||
/** Gets the IndexedDB database instance, creating/upgrading if necessary. */
|
|
||||||
function getDb(): Promise<IDBPDatabase<SharedListsDBSchema>> {
|
|
||||||
if (!dbPromise) {
|
|
||||||
dbPromise = openDB<SharedListsDBSchema>(DB_NAME, DB_VERSION, {
|
|
||||||
upgrade(db, oldVersion, newVersion, transaction, event) {
|
|
||||||
console.log(`Upgrading DB from version ${oldVersion} to ${newVersion}`);
|
|
||||||
|
|
||||||
// Create 'lists' store if it doesn't exist
|
|
||||||
if (!db.objectStoreNames.contains('lists')) {
|
|
||||||
const listStore = db.createObjectStore('lists', { keyPath: 'id' });
|
|
||||||
listStore.createIndex('groupId', 'group_id'); // Index for potential filtering by group
|
|
||||||
listStore.createIndex('updated_at', 'updated_at'); // Index for sorting/filtering by date
|
|
||||||
console.log('Created lists object store');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create 'items' store if it doesn't exist
|
|
||||||
if (!db.objectStoreNames.contains('items')) {
|
|
||||||
const itemStore = db.createObjectStore('items', { keyPath: 'id' });
|
|
||||||
// Crucial index for fetching items belonging to a list
|
|
||||||
itemStore.createIndex('listId', 'list_id');
|
|
||||||
itemStore.createIndex('updated_at', 'updated_at'); // Index for sorting/filtering by date
|
|
||||||
console.log('Created items object store');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create 'syncQueue' store if it doesn't exist
|
|
||||||
if (!db.objectStoreNames.contains('syncQueue')) {
|
|
||||||
// Use autoIncrementing key
|
|
||||||
db.createObjectStore('syncQueue', { autoIncrement: true, keyPath: 'id' });
|
|
||||||
console.log('Created syncQueue object store');
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Handle specific version upgrades ---
|
|
||||||
// Example: If upgrading from version 1 to 2
|
|
||||||
// if (oldVersion < 2) {
|
|
||||||
// // Make changes needed for version 2
|
|
||||||
// const listStore = transaction.objectStore('lists');
|
|
||||||
// // listStore.createIndex('newIndex', 'newField');
|
|
||||||
// }
|
|
||||||
// if (oldVersion < 3) { ... }
|
|
||||||
},
|
|
||||||
blocked(currentVersion, blockedVersion, event) {
|
|
||||||
// Fires if an older version of the DB is open in another tab/window
|
|
||||||
console.error(`IndexedDB blocked. Current: ${currentVersion}, Blocked: ${blockedVersion}. Close other tabs.`);
|
|
||||||
alert('Database update blocked. Please close other tabs/windows using this app and refresh.');
|
|
||||||
},
|
|
||||||
blocking(currentVersion, blockedVersion, event) {
|
|
||||||
// Fires in the older tab/window that is blocking the upgrade
|
|
||||||
console.warn(`IndexedDB blocking upgrade. Current: ${currentVersion}, Upgrade: ${blockedVersion}. Closing connection.`);
|
|
||||||
// Attempt to close the connection in the blocking tab
|
|
||||||
// db.close(); // 'db' is not available here, need to handle differently if required
|
|
||||||
},
|
|
||||||
terminated() {
|
|
||||||
// Fires if the browser abruptly terminates the connection (e.g., OS shutdown)
|
|
||||||
console.error('IndexedDB connection terminated unexpectedly.');
|
|
||||||
dbPromise = null; // Reset promise to allow reconnection attempt
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return dbPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- List CRUD Operations ---
|
|
||||||
|
|
||||||
/** Gets a single list (including items) from IndexedDB by ID. */
|
|
||||||
export async function getListFromDb(id: number): Promise<ListDetail | undefined> {
|
|
||||||
const db = await getDb();
|
|
||||||
return db.get('lists', id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Gets all lists stored in IndexedDB. */
|
|
||||||
export async function getAllListsFromDb(): Promise<ListDetail[]> {
|
|
||||||
const db = await getDb();
|
|
||||||
// Consider adding sorting or filtering here if needed
|
|
||||||
return db.getAll('lists');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Adds or updates a list in IndexedDB. */
|
|
||||||
export async function putListToDb(list: ListDetail | ListPublic): Promise<number> {
|
|
||||||
const db = await getDb();
|
|
||||||
// Ensure items array exists, even if empty, for ListDetail type consistency
|
|
||||||
const listToStore: ListDetail = {
|
|
||||||
...list,
|
|
||||||
items: (list as ListDetail).items ?? [] // Default to empty array if items missing
|
|
||||||
};
|
|
||||||
return db.put('lists', listToStore);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Deletes a list and its associated items from IndexedDB. */
|
|
||||||
export async function deleteListFromDb(id: number): Promise<void> {
|
|
||||||
const db = await getDb();
|
|
||||||
// Use a transaction to delete list and its items atomically
|
|
||||||
const tx = db.transaction(['lists', 'items'], 'readwrite');
|
|
||||||
const listStore = tx.objectStore('lists');
|
|
||||||
const itemStore = tx.objectStore('items');
|
|
||||||
const itemIndex = itemStore.index('listId'); // Use the index
|
|
||||||
|
|
||||||
// Delete the list itself
|
|
||||||
await listStore.delete(id);
|
|
||||||
|
|
||||||
// Find and delete all items associated with the list
|
|
||||||
let cursor = await itemIndex.openCursor(id.toString()); // Open cursor on the index with the listId
|
|
||||||
while (cursor) {
|
|
||||||
await cursor.delete(); // Delete the item the cursor points to
|
|
||||||
cursor = await cursor.continue(); // Move to the next item with the same listId
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.done; // Complete the transaction
|
|
||||||
console.log(`Deleted list ${id} and its items from DB.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Item CRUD Operations ---
|
|
||||||
|
|
||||||
/** Gets a single item from IndexedDB by ID. */
|
|
||||||
export async function getItemFromDb(id: number): Promise<ItemPublic | undefined> {
|
|
||||||
const db = await getDb();
|
|
||||||
return db.get('items', id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Gets all items for a specific list from IndexedDB using the index. */
|
|
||||||
export async function getItemsByListIdFromDb(listId: number): Promise<ItemPublic[]> {
|
|
||||||
const db = await getDb();
|
|
||||||
return db.getAllFromIndex('items', 'listId', listId.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Adds or updates an item in IndexedDB. */
|
|
||||||
export async function putItemToDb(item: ItemPublic): Promise<number> {
|
|
||||||
const db = await getDb();
|
|
||||||
return db.put('items', item);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Deletes an item from IndexedDB by ID. */
|
|
||||||
export async function deleteItemFromDb(id: number): Promise<void> {
|
|
||||||
const db = await getDb();
|
|
||||||
return db.delete('items', id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Sync Queue Operations ---
|
|
||||||
|
|
||||||
/** Adds an action to the synchronization queue. */
|
|
||||||
export async function addSyncAction(action: Omit<SyncAction, 'id'>): Promise<number> {
|
|
||||||
const db = await getDb();
|
|
||||||
// Add the action (payload should be serializable)
|
|
||||||
return db.add('syncQueue', action);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Retrieves all actions currently in the synchronization queue. */
|
|
||||||
export async function getSyncQueue(): Promise<SyncAction[]> {
|
|
||||||
const db = await getDb();
|
|
||||||
// Fetch all items, default order is by key (insertion order)
|
|
||||||
return db.getAll('syncQueue');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Deletes a specific action from the synchronization queue by its ID. */
|
|
||||||
export async function deleteSyncAction(id: number): Promise<void> {
|
|
||||||
const db = await getDb();
|
|
||||||
return db.delete('syncQueue', id);
|
|
||||||
}
|
|
@ -1 +0,0 @@
|
|||||||
// place files you want to import through the `$lib` alias in this folder.
|
|
@ -1,4 +0,0 @@
|
|||||||
export interface Token {
|
|
||||||
access_token: string;
|
|
||||||
token_type: string;
|
|
||||||
}
|
|
@ -1,9 +0,0 @@
|
|||||||
import type { UserPublic } from "./user";
|
|
||||||
|
|
||||||
export interface GroupPublic {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
created_by_id: number;
|
|
||||||
created_at: string;
|
|
||||||
members?: UserPublic[] | null; // Ensure this is included
|
|
||||||
}
|
|
@ -1,4 +0,0 @@
|
|||||||
export interface HealthStatus {
|
|
||||||
status: string;
|
|
||||||
database: string;
|
|
||||||
}
|
|
@ -1,5 +0,0 @@
|
|||||||
export interface InviteCodePublic {
|
|
||||||
code: string;
|
|
||||||
expires_at: string; // Date as string from JSON
|
|
||||||
group_id: number;
|
|
||||||
}
|
|
@ -1,27 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||
// Ensure this interface is exported
|
|
||||||
export interface ItemPublic {
|
|
||||||
id: number;
|
|
||||||
list_id: number;
|
|
||||||
name: string;
|
|
||||||
quantity?: string | null;
|
|
||||||
is_complete: boolean;
|
|
||||||
price?: number | null; // Or Decimal if using a library
|
|
||||||
added_by_id: number;
|
|
||||||
completed_by_id?: number | null;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ItemCreate {
|
|
||||||
name: string;
|
|
||||||
quantity?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ItemUpdate {
|
|
||||||
name?: string | null;
|
|
||||||
quantity?: string | null;
|
|
||||||
is_complete?: boolean | null;
|
|
||||||
price?: number | null; // Using number
|
|
||||||
}
|
|
@ -1,35 +0,0 @@
|
|||||||
import type { ItemPublic } from './item'; // Assuming item schema exists and is exported
|
|
||||||
|
|
||||||
export interface ListBase {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description?: string | null;
|
|
||||||
created_by_id: number;
|
|
||||||
group_id?: number | null;
|
|
||||||
is_complete: boolean;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
// Export interfaces to make the file a module
|
|
||||||
export interface ListPublic extends ListBase { }
|
|
||||||
export interface ListDetail extends ListBase {
|
|
||||||
items: ItemPublic[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ListCreate {
|
|
||||||
name: string;
|
|
||||||
description?: string | null;
|
|
||||||
group_id?: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ListUpdate {
|
|
||||||
name?: string | null;
|
|
||||||
description?: string | null;
|
|
||||||
is_complete?: boolean | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ListStatus {
|
|
||||||
list_updated_at: string; // Expect string from JSON
|
|
||||||
latest_item_updated_at?: string | null; // Expect string or null from JSON
|
|
||||||
item_count: number;
|
|
||||||
}
|
|
@ -1,3 +0,0 @@
|
|||||||
export interface Message {
|
|
||||||
detail: string;
|
|
||||||
}
|
|
@ -1,8 +0,0 @@
|
|||||||
export interface OcrExtractResponse {
|
|
||||||
extracted_items: string[]; // Matches the backend schema
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OcrReviewItem {
|
|
||||||
id: number; // Temporary unique ID for the {#each} key
|
|
||||||
text: string; // The item name, editable
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
export interface UserPublic {
|
|
||||||
id: number;
|
|
||||||
email: string;
|
|
||||||
name?: string | null;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
@ -1,119 +0,0 @@
|
|||||||
// src/lib/stores/authStore.ts
|
|
||||||
import { writable, get } from 'svelte/store';
|
|
||||||
import { browser } from '$app/environment'; // Import browser check
|
|
||||||
|
|
||||||
// --- Define Types ---
|
|
||||||
|
|
||||||
// You should ideally have a shared UserPublic type or define it here
|
|
||||||
// matching the backend UserPublic schema
|
|
||||||
interface UserPublic {
|
|
||||||
id: number;
|
|
||||||
email: string;
|
|
||||||
name?: string | null;
|
|
||||||
created_at: string; // Date might be string in JSON
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuthState {
|
|
||||||
isAuthenticated: boolean;
|
|
||||||
user: UserPublic | null;
|
|
||||||
token: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Store Initialization ---
|
|
||||||
|
|
||||||
const AUTH_TOKEN_KEY = 'authToken'; // Key for localStorage
|
|
||||||
|
|
||||||
const initialAuthState: AuthState = {
|
|
||||||
isAuthenticated: false,
|
|
||||||
user: null,
|
|
||||||
token: null
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create the writable store
|
|
||||||
export const authStore = writable<AuthState>(initialAuthState);
|
|
||||||
|
|
||||||
// --- Persistence Logic ---
|
|
||||||
|
|
||||||
// Load initial state from localStorage (only in browser)
|
|
||||||
if (browser) {
|
|
||||||
const storedToken = localStorage.getItem(AUTH_TOKEN_KEY);
|
|
||||||
if (storedToken) {
|
|
||||||
// Token exists, tentatively set state.
|
|
||||||
// We don't know if it's *valid* yet, nor do we have user data.
|
|
||||||
// A call to /users/me on app load could validate & fetch user data.
|
|
||||||
authStore.update((state) => ({
|
|
||||||
...state,
|
|
||||||
token: storedToken,
|
|
||||||
// Keep isAuthenticated false until token is validated/user fetched
|
|
||||||
// Or set to true tentatively if you prefer optimistic UI
|
|
||||||
isAuthenticated: true // Optimistic: assume token might be valid
|
|
||||||
}));
|
|
||||||
console.log('AuthStore: Loaded token from localStorage.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe to store changes to persist the token (only in browser)
|
|
||||||
authStore.subscribe((state) => {
|
|
||||||
if (browser) {
|
|
||||||
if (state.token) {
|
|
||||||
// Save token to localStorage when it exists
|
|
||||||
localStorage.setItem(AUTH_TOKEN_KEY, state.token);
|
|
||||||
console.log('AuthStore: Token saved to localStorage.');
|
|
||||||
} else {
|
|
||||||
// Remove token from localStorage when it's null (logout)
|
|
||||||
localStorage.removeItem(AUTH_TOKEN_KEY);
|
|
||||||
console.log('AuthStore: Token removed from localStorage.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// --- Action Functions ---
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the auth store upon successful login.
|
|
||||||
* @param token The JWT access token.
|
|
||||||
* @param userData The public user data received from the login/signup or /users/me endpoint.
|
|
||||||
*/
|
|
||||||
export function login(token: string, userData: UserPublic): void {
|
|
||||||
authStore.set({
|
|
||||||
isAuthenticated: true,
|
|
||||||
user: userData,
|
|
||||||
token: token
|
|
||||||
});
|
|
||||||
console.log('AuthStore: User logged in.', userData);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resets the auth store to its initial state (logged out).
|
|
||||||
*/
|
|
||||||
export function logout(): void {
|
|
||||||
authStore.set(initialAuthState);
|
|
||||||
console.log('AuthStore: User logged out.');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates only the user information in the store, keeping auth state.
|
|
||||||
* Useful after fetching fresh user data from /users/me.
|
|
||||||
* @param userData The updated public user data.
|
|
||||||
*/
|
|
||||||
export function updateUser(userData: UserPublic): void {
|
|
||||||
authStore.update(state => {
|
|
||||||
if (state.isAuthenticated) {
|
|
||||||
return { ...state, user: userData };
|
|
||||||
}
|
|
||||||
return state; // No change if not authenticated
|
|
||||||
});
|
|
||||||
console.log('AuthStore: User data updated.', userData);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// --- Helper to get token synchronously (use with caution) ---
|
|
||||||
/**
|
|
||||||
* Gets the current token synchronously from the store.
|
|
||||||
* Primarily intended for use within the apiClient where reactivity isn't needed.
|
|
||||||
* @returns The current token string or null.
|
|
||||||
*/
|
|
||||||
export function getCurrentToken(): string | null {
|
|
||||||
return get(authStore).token;
|
|
||||||
}
|
|
@ -1,154 +0,0 @@
|
|||||||
// src/lib/syncService.ts
|
|
||||||
import { browser } from '$app/environment';
|
|
||||||
import { getSyncQueue, deleteSyncAction } from './db'; // Import DB functions
|
|
||||||
import { apiClient, ApiClientError } from './apiClient'; // Import API client
|
|
||||||
import { writable, get } from 'svelte/store'; // Import get for reading store value
|
|
||||||
|
|
||||||
// Store for sync status feedback
|
|
||||||
export const syncStatus = writable<'idle' | 'syncing' | 'error'>('idle');
|
|
||||||
export const syncError = writable<string | null>(null);
|
|
||||||
|
|
||||||
let isSyncing = false; // Prevent concurrent sync runs
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Processes the offline synchronization queue.
|
|
||||||
* Fetches actions from IndexedDB and attempts to send them to the API.
|
|
||||||
* Removes successful actions, handles basic errors/conflicts.
|
|
||||||
*/
|
|
||||||
export async function processSyncQueue() {
|
|
||||||
// Run only in browser, when online, and if not already syncing
|
|
||||||
if (!browser || !navigator.onLine || isSyncing) {
|
|
||||||
if (isSyncing) console.log('Sync: Already in progress, skipping.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isSyncing = true;
|
|
||||||
syncStatus.set('syncing');
|
|
||||||
syncError.set(null); // Clear previous errors
|
|
||||||
console.log('Sync: Starting queue processing...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const queue = await getSyncQueue();
|
|
||||||
console.log(`Sync: Found ${queue.length} actions in queue.`);
|
|
||||||
|
|
||||||
if (queue.length === 0) {
|
|
||||||
syncStatus.set('idle');
|
|
||||||
isSyncing = false;
|
|
||||||
return; // Nothing to do
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process actions one by one (sequential processing)
|
|
||||||
for (const action of queue) {
|
|
||||||
// Should always have an ID from IndexedDB autoIncrement
|
|
||||||
if (!action.id) {
|
|
||||||
console.error("Sync: Action missing ID, skipping.", action);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Sync: Processing action ID ${action.id}, Type: ${action.type}`);
|
|
||||||
let success = false;
|
|
||||||
try {
|
|
||||||
// --- Perform API call based on action type ---
|
|
||||||
switch (action.type) {
|
|
||||||
case 'create_list':
|
|
||||||
await apiClient.post('/v1/lists', action.payload);
|
|
||||||
// TODO: Handle mapping tempId if used
|
|
||||||
break;
|
|
||||||
case 'update_list':
|
|
||||||
// Assuming payload is { id: listId, data: ListUpdate }
|
|
||||||
await apiClient.put(`/v1/lists/${action.payload.id}`, action.payload.data);
|
|
||||||
break;
|
|
||||||
case 'delete_list':
|
|
||||||
// Assuming payload is { id: listId }
|
|
||||||
await apiClient.delete(`/v1/lists/${action.payload.id}`);
|
|
||||||
break;
|
|
||||||
case 'create_item':
|
|
||||||
// Assuming payload is { listId: number, data: ItemCreate }
|
|
||||||
await apiClient.post(`/v1/lists/${action.payload.listId}/items`, action.payload.data);
|
|
||||||
// TODO: Handle mapping tempId if used
|
|
||||||
break;
|
|
||||||
case 'update_item':
|
|
||||||
// Assuming payload is { id: itemId, data: ItemUpdate }
|
|
||||||
await apiClient.put(`/v1/items/${action.payload.id}`, action.payload.data);
|
|
||||||
break;
|
|
||||||
case 'delete_item':
|
|
||||||
// Assuming payload is { id: itemId }
|
|
||||||
await apiClient.delete(`/v1/items/${action.payload.id}`);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
console.error(`Sync: Unknown action type: ${(action as any).type}`);
|
|
||||||
// Optionally treat as error or just skip
|
|
||||||
throw new Error(`Unknown sync action type: ${(action as any).type}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
success = true; // Mark as successful if API call didn't throw
|
|
||||||
console.log(`Sync: Action ID ${action.id} (${action.type}) successful.`);
|
|
||||||
// Remove from queue ONLY on definite success
|
|
||||||
await deleteSyncAction(action.id);
|
|
||||||
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(`Sync: Failed to process action ID ${action.id} (${action.type})`, err);
|
|
||||||
|
|
||||||
// --- Basic Conflict/Error Handling ---
|
|
||||||
let errorHandled = false;
|
|
||||||
if (err instanceof ApiClientError) {
|
|
||||||
if (err.status === 409) { // Example: Conflict
|
|
||||||
syncError.set(`Sync conflict for ${action.type} (ID: ${action.payload?.id ?? 'N/A'}). Data may be outdated. Please refresh.`);
|
|
||||||
// Remove conflicting action from queue - requires manual refresh/resolution by user
|
|
||||||
await deleteSyncAction(action.id);
|
|
||||||
errorHandled = true;
|
|
||||||
} else if (err.status >= 400 && err.status < 500 && err.status !== 401) {
|
|
||||||
// Other client errors (400 Bad Request, 403 Forbidden, 404 Not Found)
|
|
||||||
// Often mean the action is invalid now (e.g., deleting something already deleted).
|
|
||||||
syncError.set(`Sync failed for ${action.type} (Error ${err.status}). Action discarded.`);
|
|
||||||
await deleteSyncAction(action.id);
|
|
||||||
errorHandled = true;
|
|
||||||
}
|
|
||||||
// Note: 401 Unauthorized is handled globally by apiClient, which calls logout.
|
|
||||||
// Sync might stop if token becomes invalid mid-process.
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!errorHandled) {
|
|
||||||
// Network error or Server error (5xx) - Keep in queue and stop processing for now
|
|
||||||
syncError.set(`Sync failed for ${action.type}. Will retry later.`);
|
|
||||||
syncStatus.set('error'); // Indicate sync stopped due to error
|
|
||||||
isSyncing = false; // Allow retry later
|
|
||||||
return; // Stop processing the rest of the queue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} // End for loop
|
|
||||||
|
|
||||||
// If loop completed without critical errors
|
|
||||||
console.log('Sync: Queue processing finished.');
|
|
||||||
syncStatus.set('idle'); // Reset status if all processed or handled
|
|
||||||
|
|
||||||
} catch (outerError) {
|
|
||||||
// Catch errors during queue fetching or unexpected issues in the loop
|
|
||||||
console.error("Sync: Critical error during queue processing loop.", outerError);
|
|
||||||
syncError.set("An unexpected error occurred during synchronization.");
|
|
||||||
syncStatus.set('error');
|
|
||||||
} finally {
|
|
||||||
isSyncing = false; // Ensure this is always reset
|
|
||||||
// If an error occurred and wasn't handled by stopping, ensure status reflects it
|
|
||||||
if (get(syncError) && get(syncStatus) !== 'error') {
|
|
||||||
syncStatus.set('error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Initialize Sync ---
|
|
||||||
|
|
||||||
// Listen for online event to trigger sync
|
|
||||||
if (browser) {
|
|
||||||
window.addEventListener('online', processSyncQueue);
|
|
||||||
// Trigger sync shortly after app loads if online
|
|
||||||
if (navigator.onLine) {
|
|
||||||
setTimeout(processSyncQueue, 3000); // Delay 3s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional: Add function to manually trigger sync if needed from UI
|
|
||||||
export function triggerSync() {
|
|
||||||
console.log("Sync: Manual trigger requested.");
|
|
||||||
processSyncQueue();
|
|
||||||
}
|
|
10
fe/src/pages/AccountPage.vue
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<template>
|
||||||
|
<q-page padding>
|
||||||
|
<h1 class="text-h4 q-mb-md">Account</h1>
|
||||||
|
<p>Your account settings will appear here.</p>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Component logic will go here
|
||||||
|
</script>
|
27
fe/src/pages/ErrorNotFound.vue
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center">
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 30vh">
|
||||||
|
404
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-h2" style="opacity:.4">
|
||||||
|
Oops. Nothing here...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
class="q-mt-xl"
|
||||||
|
color="white"
|
||||||
|
text-color="blue"
|
||||||
|
unelevated
|
||||||
|
to="/"
|
||||||
|
label="Go Home"
|
||||||
|
no-caps
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
//
|
||||||
|
</script>
|
149
fe/src/pages/GroupDetailPage.vue
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
<template>
|
||||||
|
<q-page padding>
|
||||||
|
<div v-if="group">
|
||||||
|
<h4 class="q-mt-none q-mb-sm">Group: {{ group.name }}</h4>
|
||||||
|
|
||||||
|
<!-- Invite Code Generation -->
|
||||||
|
<div class="q-mt-lg">
|
||||||
|
<h5>Invite Members</h5>
|
||||||
|
<q-btn
|
||||||
|
label="Generate Invite Code"
|
||||||
|
color="secondary"
|
||||||
|
@click="generateInviteCode"
|
||||||
|
:loading="generatingInvite"
|
||||||
|
/>
|
||||||
|
<div v-if="inviteCode" class="q-mt-md">
|
||||||
|
<q-input readonly :model-value="inviteCode" label="Invite Code">
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-btn round dense flat icon="content_copy" @click="copyInviteCode" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
<q-banner v-if="copySuccess" class="bg-green-2 text-green-9 q-mt-sm" dense>
|
||||||
|
Invite code copied to clipboard!
|
||||||
|
</q-banner>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div 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>
|
||||||
|
</div>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { api } from 'boot/axios';
|
||||||
|
import { useAuthStore } from 'stores/auth';
|
||||||
|
import { copyToClipboard, useQuasar } from 'quasar';
|
||||||
|
|
||||||
|
interface Group {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
// other properties if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
const group = ref<Group | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const inviteCode = ref<string | null>(null);
|
||||||
|
const generatingInvite = ref(false);
|
||||||
|
const copySuccess = ref(false);
|
||||||
|
|
||||||
|
const groupId = computed(() => props.id || (route.params.id as string));
|
||||||
|
|
||||||
|
const fetchGroupDetails = async () => {
|
||||||
|
if (!groupId.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
// console.log(
|
||||||
|
// `TODO: Implement API call to fetch group details for group ID: ${groupId.value} from /api/v1/groups/{group_id}`,
|
||||||
|
// );
|
||||||
|
const response = await api.get(`/api/v1/groups/${groupId.value}`, {
|
||||||
|
headers: { Authorization: `Bearer ${authStore.token}` },
|
||||||
|
});
|
||||||
|
group.value = response.data;
|
||||||
|
|
||||||
|
// Mock data for now
|
||||||
|
// group.value = { id: groupId.value, name: `Sample Group ${groupId.value}` };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching group details:', error);
|
||||||
|
$q.notify({
|
||||||
|
color: 'negative',
|
||||||
|
message: error.response?.data?.detail || 'Failed to fetch group details.',
|
||||||
|
icon: 'report_problem',
|
||||||
|
});
|
||||||
|
// Handle error (e.g., show notification, redirect)
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateInviteCode = async () => {
|
||||||
|
if (!groupId.value) return;
|
||||||
|
generatingInvite.value = true;
|
||||||
|
inviteCode.value = null; // Reset previous code
|
||||||
|
try {
|
||||||
|
// console.log(`TODO: Implement API call to POST /api/v1/groups/${groupId.value}/invites`);
|
||||||
|
const response = await api.post(
|
||||||
|
`/api/v1/groups/${groupId.value}/invites`,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${authStore.token}` },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
inviteCode.value = response.data.invite_code; // Assuming API returns { invite_code: 'XXXXX' }
|
||||||
|
|
||||||
|
// Mock data for now
|
||||||
|
// inviteCode.value = `INVITE_${groupId.value}_${Math.random().toString(36).substring(2, 7).toUpperCase()}`;
|
||||||
|
$q.notify({
|
||||||
|
color: 'positive',
|
||||||
|
message: 'Invite code generated successfully!',
|
||||||
|
icon: 'check_circle',
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error generating invite code:', error);
|
||||||
|
$q.notify({
|
||||||
|
color: 'negative',
|
||||||
|
message: error.response?.data?.detail || 'Failed to generate invite code.',
|
||||||
|
icon: 'report_problem',
|
||||||
|
});
|
||||||
|
// Handle error
|
||||||
|
} finally {
|
||||||
|
generatingInvite.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyInviteCode = () => {
|
||||||
|
if (inviteCode.value) {
|
||||||
|
copyToClipboard(inviteCode.value)
|
||||||
|
.then(() => {
|
||||||
|
copySuccess.value = true;
|
||||||
|
setTimeout(() => (copySuccess.value = false), 2000); // Hide message after 2s
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
console.error('Failed to copy invite code');
|
||||||
|
// Handle copy error (e.g., show a notification)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchGroupDetails();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Add any page-specific styles here */
|
||||||
|
</style>
|
230
fe/src/pages/GroupsPage.vue
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
<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="showCreateGroupModal = true" />
|
||||||
|
</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
|
||||||
|
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
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
|
||||||
|
<q-list bordered separator>
|
||||||
|
<q-item
|
||||||
|
v-for="group in groups"
|
||||||
|
:key="group.id"
|
||||||
|
clickable
|
||||||
|
v-ripple
|
||||||
|
@click="selectGroup(group)"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<q-dialog v-model="showCreateGroupModal">
|
||||||
|
<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
|
||||||
|
v-model="newGroupName"
|
||||||
|
autofocus
|
||||||
|
@keyup.enter="handleCreateGroup"
|
||||||
|
label="Group Name"
|
||||||
|
:rules="[(val) => !!val || 'Group name is required']"
|
||||||
|
ref="newGroupNameInput"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { QInput, useQuasar } from 'quasar'; // Import QInput for type reference
|
||||||
|
import { api } from 'boot/axios'; // Assuming you have an axios instance set up
|
||||||
|
import { useAuthStore } from 'stores/auth'; // If needed for auth token
|
||||||
|
|
||||||
|
interface Group {
|
||||||
|
id: string; // or number, depending on your API
|
||||||
|
name: string;
|
||||||
|
// Add other group properties if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const authStore = useAuthStore(); // If needed
|
||||||
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
const groups = ref<Group[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const showCreateGroupModal = ref(false);
|
||||||
|
const newGroupName = ref('');
|
||||||
|
const creatingGroup = ref(false);
|
||||||
|
const newGroupNameInput = ref<any>(null); // For focusing and validation
|
||||||
|
|
||||||
|
// For Join Group
|
||||||
|
const inviteCodeToJoin = ref('');
|
||||||
|
const joiningGroup = ref(false);
|
||||||
|
const joinInviteCodeInput = ref<QInput | null>(null);
|
||||||
|
|
||||||
|
// Fetch groups from API
|
||||||
|
const fetchGroups = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await api.get('/api/v1/groups', {
|
||||||
|
headers: { Authorization: `Bearer ${authStore.token}` }, // If auth is needed
|
||||||
|
});
|
||||||
|
groups.value = response.data;
|
||||||
|
// console.log('TODO: Implement API call to fetch groups /api/v1/groups');
|
||||||
|
// Mock data for now:
|
||||||
|
// groups.value = [
|
||||||
|
// { id: '1', name: 'First Group' },
|
||||||
|
// { id: '2', name: 'Second Group' },
|
||||||
|
// ];
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching groups:', error);
|
||||||
|
$q.notify({
|
||||||
|
color: 'negative',
|
||||||
|
message: error.response?.data?.detail || 'Failed to fetch groups. Please try again.',
|
||||||
|
icon: 'report_problem',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateGroup = async () => {
|
||||||
|
if (!newGroupName.value || newGroupName.value.trim() === '') {
|
||||||
|
newGroupNameInput.value?.validate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
creatingGroup.value = true;
|
||||||
|
try {
|
||||||
|
const response = await api.post(
|
||||||
|
'/api/v1/groups',
|
||||||
|
{ name: newGroupName.value },
|
||||||
|
{ headers: { Authorization: `Bearer ${authStore.token}` } }, // If auth is needed
|
||||||
|
);
|
||||||
|
groups.value.push(response.data); // Add new group to the list
|
||||||
|
// console.log('TODO: Implement API call to POST /api/v1/groups with name:', newGroupName.value);
|
||||||
|
// Mock adding group
|
||||||
|
// groups.value.push({ id: String(Date.now()), name: newGroupName.value });
|
||||||
|
showCreateGroupModal.value = false;
|
||||||
|
newGroupName.value = '';
|
||||||
|
$q.notify({
|
||||||
|
color: 'positive',
|
||||||
|
message: `Group '${response.data.name}' created successfully!`,
|
||||||
|
icon: 'check_circle',
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error creating group:', error);
|
||||||
|
$q.notify({
|
||||||
|
color: 'negative',
|
||||||
|
message: error.response?.data?.detail || 'Failed to create group. Please try again.',
|
||||||
|
icon: 'report_problem',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
creatingGroup.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoinGroup = async () => {
|
||||||
|
if (!inviteCodeToJoin.value || inviteCodeToJoin.value.trim() === '') {
|
||||||
|
joinInviteCodeInput.value?.validate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
joiningGroup.value = true;
|
||||||
|
try {
|
||||||
|
// console.log(
|
||||||
|
// 'TODO: Implement API call to POST /api/v1/invites/accept with code:',
|
||||||
|
// inviteCodeToJoin.value,
|
||||||
|
// );
|
||||||
|
const response = await api.post(
|
||||||
|
'/api/v1/invites/accept',
|
||||||
|
{ invite_code: inviteCodeToJoin.value }, // Ensure schema matches backend (invite_code vs code)
|
||||||
|
{ headers: { Authorization: `Bearer ${authStore.token}` } }, // If auth is needed
|
||||||
|
);
|
||||||
|
// On success, refresh the list of groups and clear input
|
||||||
|
await fetchGroups();
|
||||||
|
inviteCodeToJoin.value = '';
|
||||||
|
$q.notify({
|
||||||
|
color: 'positive',
|
||||||
|
message: response.data.detail || 'Successfully joined group!',
|
||||||
|
icon: 'check_circle',
|
||||||
|
});
|
||||||
|
// console.log('Successfully joined group (mock). Refreshing groups...');
|
||||||
|
// await fetchGroups(); // Refresh groups after mock join
|
||||||
|
// inviteCodeToJoin.value = '';
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error joining group:', error);
|
||||||
|
$q.notify({
|
||||||
|
color: 'negative',
|
||||||
|
message: error.response?.data?.detail || 'Failed to join group. Check the code or try again.',
|
||||||
|
icon: 'report_problem',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
joiningGroup.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectGroup = (group: Group) => {
|
||||||
|
console.log('Selected group:', group);
|
||||||
|
// For MVP, just displaying the group name and having it as context for lists is enough.
|
||||||
|
router.push(`/groups/${group.id}`); // Navigate to group detail page
|
||||||
|
// console.log('TODO: Implement navigation to group detail page /groups/:id or handle selection');
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchGroups();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Add any page-specific styles here */
|
||||||
|
</style>
|
43
fe/src/pages/IndexPage.vue
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<template>
|
||||||
|
<q-page class="row items-center justify-evenly">
|
||||||
|
<example-component
|
||||||
|
title="Example component"
|
||||||
|
active
|
||||||
|
:todos="todos"
|
||||||
|
:meta="meta"
|
||||||
|
></example-component>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import type { Todo, Meta } from 'components/models';
|
||||||
|
import ExampleComponent from 'components/ExampleComponent.vue';
|
||||||
|
|
||||||
|
const todos = ref<Todo[]>([
|
||||||
|
{
|
||||||
|
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
|
||||||
|
});
|
||||||
|
</script>
|
10
fe/src/pages/ListsPage.vue
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<template>
|
||||||
|
<q-page padding>
|
||||||
|
<h1 class="text-h4 q-mb-md">Lists</h1>
|
||||||
|
<p>Your lists will appear here.</p>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Component logic will go here
|
||||||
|
</script>
|
107
fe/src/pages/LoginPage.vue
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
<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"
|
||||||
|
type="email"
|
||||||
|
:rules="[(val) => !!val || 'Email is required', isValidEmail]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center q-mt-sm">
|
||||||
|
<router-link to="/signup" class="text-primary"
|
||||||
|
>Don't have an account? Sign up</router-link
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { useAuthStore } from 'stores/auth';
|
||||||
|
|
||||||
|
const $q = useQuasar();
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
const email = ref('');
|
||||||
|
const password = ref('');
|
||||||
|
const isPwd = ref(true);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
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 onSubmit = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
await authStore.login(email.value, password.value);
|
||||||
|
|
||||||
|
$q.notify({
|
||||||
|
color: 'positive',
|
||||||
|
message: 'Login successful',
|
||||||
|
position: 'top',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redirect to the originally requested page or home
|
||||||
|
const redirectPath = (route.query.redirect as string) || '/';
|
||||||
|
await router.push(redirectPath);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
$q.notify({
|
||||||
|
color: 'negative',
|
||||||
|
message: error instanceof Error ? error.message : 'Login failed',
|
||||||
|
position: 'top',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
128
fe/src/pages/SignupPage.vue
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
<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']"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-model="email"
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
:rules="[(val) => !!val || 'Email is required', isValidEmail]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-model="confirmPassword"
|
||||||
|
label="Confirm Password"
|
||||||
|
:type="isPwd ? 'password' : 'text'"
|
||||||
|
:rules="[
|
||||||
|
(val) => !!val || 'Please confirm your password',
|
||||||
|
(val) => val === password || 'Passwords do not match',
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<q-btn
|
||||||
|
label="Sign Up"
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
class="full-width"
|
||||||
|
:loading="loading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center q-mt-sm">
|
||||||
|
<router-link to="/login" class="text-primary"
|
||||||
|
>Already have an account? Login</router-link
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { useAuthStore } from 'stores/auth';
|
||||||
|
|
||||||
|
const $q = useQuasar();
|
||||||
|
const router = useRouter();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
const name = ref('');
|
||||||
|
const email = ref('');
|
||||||
|
const password = ref('');
|
||||||
|
const confirmPassword = ref('');
|
||||||
|
const isPwd = ref(true);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
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 onSubmit = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
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');
|
||||||
|
} catch (error: unknown) {
|
||||||
|
$q.notify({
|
||||||
|
color: 'negative',
|
||||||
|
message: error instanceof Error ? error.message : 'Signup failed',
|
||||||
|
position: 'top',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.signup-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
61
fe/src/router/index.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { defineRouter } from '#q-app/wrappers';
|
||||||
|
import {
|
||||||
|
createMemoryHistory,
|
||||||
|
createRouter,
|
||||||
|
createWebHashHistory,
|
||||||
|
createWebHistory,
|
||||||
|
} from 'vue-router';
|
||||||
|
import routes from './routes';
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 }),
|
||||||
|
routes,
|
||||||
|
|
||||||
|
// Leave this as is and make changes in quasar.conf.js instead!
|
||||||
|
// quasar.conf.js -> build -> vueRouterMode
|
||||||
|
// quasar.conf.js -> build -> publicPath
|
||||||
|
history: createHistory(process.env.VUE_ROUTER_BASE),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigation guard to check authentication
|
||||||
|
Router.beforeEach((to, from, next) => {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const isAuthenticated = authStore.isAuthenticated;
|
||||||
|
|
||||||
|
// Define public routes that don't require authentication
|
||||||
|
const publicRoutes = ['/login', '/signup'];
|
||||||
|
|
||||||
|
// Check if the route requires authentication
|
||||||
|
const requiresAuth = !publicRoutes.includes(to.path);
|
||||||
|
|
||||||
|
if (requiresAuth && !isAuthenticated) {
|
||||||
|
// Redirect to login if trying to access protected route without authentication
|
||||||
|
next({ path: '/login', query: { redirect: to.fullPath } });
|
||||||
|
} else if (!requiresAuth && isAuthenticated) {
|
||||||
|
// Redirect to home if trying to access login/signup while authenticated
|
||||||
|
next({ path: '/' });
|
||||||
|
} else {
|
||||||
|
// Proceed with navigation
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Router;
|
||||||
|
});
|
33
fe/src/router/routes.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import type { RouteRecordRaw } from 'vue-router';
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: () => import('layouts/MainLayout.vue'),
|
||||||
|
children: [
|
||||||
|
{ path: '', redirect: '/lists' },
|
||||||
|
{ path: 'lists', component: () => import('pages/ListsPage.vue') },
|
||||||
|
{ path: 'groups', component: () => import('pages/GroupsPage.vue') },
|
||||||
|
{ path: 'groups/:id', component: () => import('pages/GroupDetailPage.vue'), props: true },
|
||||||
|
{ path: 'account', component: () => import('pages/AccountPage.vue') },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: () => import('layouts/AuthLayout.vue'),
|
||||||
|
children: [
|
||||||
|
{ path: 'login', component: () => import('pages/LoginPage.vue') },
|
||||||
|
{ path: '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'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default routes;
|
@ -1,49 +0,0 @@
|
|||||||
<!-- src/routes/(app)/+layout.svelte -->
|
|
||||||
<script lang="ts">
|
|
||||||
import { logout as performLogout } from '$lib/stores/authStore';
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import type { LayoutData } from './$types'; // Import generated types for data prop
|
|
||||||
|
|
||||||
// Receive data from the +layout.ts load function
|
|
||||||
export let data: LayoutData;
|
|
||||||
|
|
||||||
// Destructure user from data if needed, or access as data.user
|
|
||||||
// $: user = data.user; // Reactive assignment if data can change
|
|
||||||
|
|
||||||
async function handleLogout() {
|
|
||||||
console.log('Logging out...');
|
|
||||||
performLogout(); // Clear the auth store and localStorage
|
|
||||||
await goto('/login'); // Redirect to login page
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- You can reuse the main layout structure or create a specific one -->
|
|
||||||
<!-- For simplicity, let's just add a header specific to the authenticated area -->
|
|
||||||
<div class="auth-layout min-h-screen bg-slate-100">
|
|
||||||
<header class="bg-purple-700 p-4 text-white shadow-md">
|
|
||||||
<div class="container mx-auto flex items-center justify-between">
|
|
||||||
<a href="/dashboard" class="text-lg font-semibold hover:text-purple-200">App Dashboard</a>
|
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
{#if data.user}
|
|
||||||
<span class="text-sm">Welcome, {data.user.name || data.user.email}!</span>
|
|
||||||
{/if}
|
|
||||||
<button
|
|
||||||
on:click={handleLogout}
|
|
||||||
class="rounded bg-red-500 px-3 py-1 text-sm font-medium hover:bg-red-600 focus:ring-2 focus:ring-red-400 focus:ring-offset-2 focus:ring-offset-purple-700 focus:outline-none"
|
|
||||||
>
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="container mx-auto p-4 md:p-8">
|
|
||||||
<!-- Slot for the actual page content (e.g., dashboard/+page.svelte) -->
|
|
||||||
<slot />
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Optional specific footer for authenticated area -->
|
|
||||||
<!-- <footer class="mt-auto bg-gray-700 p-3 text-center text-xs text-gray-300">
|
|
||||||
Authenticated Section Footer
|
|
||||||
</footer> -->
|
|
||||||
</div>
|
|
@ -1,38 +0,0 @@
|
|||||||
// src/routes/(app)/+layout.ts
|
|
||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
import { browser } from '$app/environment';
|
|
||||||
import { get } from 'svelte/store'; // Import get for synchronous access in load
|
|
||||||
import { authStore } from '$lib/stores/authStore';
|
|
||||||
import type { LayoutLoad } from './$types'; // Import generated types for load function
|
|
||||||
|
|
||||||
export const load: LayoutLoad = ({ url }) => {
|
|
||||||
// IMPORTANT: localStorage/authStore logic relies on the browser.
|
|
||||||
// This check prevents errors during SSR or prerendering.
|
|
||||||
if (!browser) {
|
|
||||||
// On the server, we cannot reliably check auth state stored in localStorage.
|
|
||||||
// You might implement server-side session checking here later if needed.
|
|
||||||
// For now, we allow server rendering to proceed, the client-side check
|
|
||||||
// or a subsequent navigation check will handle redirection if necessary.
|
|
||||||
return {}; // Proceed with loading on server
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the current auth state synchronously
|
|
||||||
const authState = get(authStore);
|
|
||||||
|
|
||||||
console.log('(app) layout load: Checking auth state', authState);
|
|
||||||
|
|
||||||
// If not authenticated in the browser, redirect to login
|
|
||||||
if (!authState.isAuthenticated) {
|
|
||||||
console.log('(app) layout load: User not authenticated, redirecting to login.');
|
|
||||||
// Construct the redirect URL, preserving the original path the user tried to access
|
|
||||||
const redirectTo = `/login?redirectTo=${encodeURIComponent(url.pathname + url.search)}`;
|
|
||||||
throw redirect(307, redirectTo); // Use 307 Temporary Redirect
|
|
||||||
}
|
|
||||||
|
|
||||||
// If authenticated, allow the layout and page to load.
|
|
||||||
// We could return user data here if needed by the layout/pages.
|
|
||||||
console.log('(app) layout load: User authenticated, proceeding.');
|
|
||||||
return {
|
|
||||||
user: authState.user // Optionally pass user data to the layout/pages
|
|
||||||
};
|
|
||||||
};
|
|
@ -1,187 +0,0 @@
|
|||||||
<!-- src/routes/(app)/dashboard/+page.svelte -->
|
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { apiClient, ApiClientError } from '$lib/apiClient';
|
|
||||||
import type { GroupPublic } from '$lib/schemas/group';
|
|
||||||
import type { PageData } from './$types'; // Import generated type for page data
|
|
||||||
|
|
||||||
// Receive groups data from the +page.ts load function
|
|
||||||
export let data: PageData; // Contains { groups: GroupPublic[], error?: string | null }
|
|
||||||
|
|
||||||
// Local reactive state for the list (to allow adding without full page reload)
|
|
||||||
let displayedGroups: GroupPublic[] = [];
|
|
||||||
let loadError: string | null = null;
|
|
||||||
|
|
||||||
// State for the creation form
|
|
||||||
let newGroupName = '';
|
|
||||||
let isCreating = false;
|
|
||||||
let createError: string | null = null;
|
|
||||||
|
|
||||||
// Initialize local state when component mounts or data changes
|
|
||||||
$: {
|
|
||||||
// $: block ensures this runs whenever 'data' prop changes
|
|
||||||
console.log('Dashboard page: Data prop updated', data);
|
|
||||||
displayedGroups = data.groups ?? []; // Update local list from load data
|
|
||||||
loadError = data.error ?? null; // Update load error message
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreateGroup() {
|
|
||||||
if (!newGroupName.trim()) {
|
|
||||||
createError = 'Group name cannot be empty.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
isCreating = true;
|
|
||||||
createError = null;
|
|
||||||
console.log(`Creating group: ${newGroupName}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const newGroupData = { name: newGroupName.trim() };
|
|
||||||
const createdGroup = await apiClient.post<GroupPublic>('/v1/groups', newGroupData);
|
|
||||||
|
|
||||||
console.log('Group creation successful:', createdGroup);
|
|
||||||
|
|
||||||
// Add the new group to the local reactive list
|
|
||||||
displayedGroups = [...displayedGroups, createdGroup];
|
|
||||||
|
|
||||||
newGroupName = ''; // Clear the input form
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Group creation failed:', err);
|
|
||||||
if (err instanceof ApiClientError) {
|
|
||||||
let detail = 'An unknown API error occurred.';
|
|
||||||
if (err.errorData && typeof err.errorData === 'object' && 'detail' in err.errorData) {
|
|
||||||
// detail = (<{ detail: string }>err.errorData).detail;
|
|
||||||
}
|
|
||||||
createError = `Failed to create group (${err.status}): ${detail}`;
|
|
||||||
} else if (err instanceof Error) {
|
|
||||||
createError = `Error: ${err.message}`;
|
|
||||||
} else {
|
|
||||||
createError = 'An unexpected error occurred.';
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
isCreating = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="space-y-8">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h1 class="text-3xl font-bold text-gray-800">Your Groups</h1>
|
|
||||||
<!-- Link to create list page -->
|
|
||||||
<a
|
|
||||||
href="/lists/new"
|
|
||||||
class="rounded bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700"
|
|
||||||
>
|
|
||||||
+ Create New List
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Group Creation Section -->
|
|
||||||
<div class="rounded bg-white p-6 shadow">
|
|
||||||
<h2 class="mb-4 text-xl font-semibold text-gray-700">Create New Group</h2>
|
|
||||||
<form on:submit|preventDefault={handleCreateGroup} class="flex flex-col gap-4 sm:flex-row">
|
|
||||||
<div class="flex-grow">
|
|
||||||
<label for="new-group-name" class="sr-only">Group Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="new-group-name"
|
|
||||||
bind:value={newGroupName}
|
|
||||||
placeholder="Enter group name..."
|
|
||||||
required
|
|
||||||
class="w-full rounded border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-70"
|
|
||||||
disabled={isCreating}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="rounded bg-blue-600 px-4 py-2 font-medium whitespace-nowrap text-white transition duration-150 ease-in-out hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
disabled={isCreating}
|
|
||||||
>
|
|
||||||
{isCreating ? 'Creating...' : 'Create Group'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{#if createError}
|
|
||||||
<p class="mt-3 text-sm text-red-600">{createError}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded bg-white p-6 shadow">
|
|
||||||
<h2 class="mb-4 text-xl font-semibold text-gray-700">My Lists</h2>
|
|
||||||
{#if loadError}
|
|
||||||
<div class="rounded border border-red-400 bg-red-100 p-4 text-red-700" role="alert">
|
|
||||||
<p class="font-bold">Error Loading Data</p>
|
|
||||||
<p>{loadError}</p>
|
|
||||||
</div>
|
|
||||||
{:else if !data.lists || data.lists.length === 0}
|
|
||||||
<p class="text-gray-500">You haven't created any lists yet.</p>
|
|
||||||
{:else}
|
|
||||||
<ul class="space-y-3">
|
|
||||||
{#each data.lists as list (list.id)}
|
|
||||||
<li
|
|
||||||
class="rounded border border-gray-200 p-4 transition duration-150 ease-in-out hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<!-- Make name a link -->
|
|
||||||
<a
|
|
||||||
href="/lists/{list.id}"
|
|
||||||
class="font-medium text-gray-800 hover:text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
{list.name}
|
|
||||||
</a>
|
|
||||||
<!-- ... (shared/personal tags) ... -->
|
|
||||||
</div>
|
|
||||||
<div class="flex space-x-3">
|
|
||||||
<!-- Link to Edit Page -->
|
|
||||||
<a href="/lists/{list.id}/edit" class="text-sm text-yellow-600 hover:underline"
|
|
||||||
>Edit</a
|
|
||||||
>
|
|
||||||
<!-- Add Delete button later -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- ... (description, updated date) ... -->
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Group List Section -->
|
|
||||||
<div class="rounded bg-white p-6 shadow">
|
|
||||||
<h2 class="mb-4 text-xl font-semibold text-gray-700">My Groups</h2>
|
|
||||||
|
|
||||||
{#if loadError}
|
|
||||||
<!-- Display error from load function -->
|
|
||||||
<div class="rounded border border-red-400 bg-red-100 p-4 text-red-700" role="alert">
|
|
||||||
<p class="font-bold">Error Loading Groups</p>
|
|
||||||
<p>{loadError}</p>
|
|
||||||
</div>
|
|
||||||
{:else if displayedGroups.length === 0}
|
|
||||||
<!-- Message when no groups and no load error -->
|
|
||||||
<p class="text-gray-500">You are not a member of any groups yet. Create one above!</p>
|
|
||||||
{:else}
|
|
||||||
<!-- Display the list of groups -->
|
|
||||||
<ul class="space-y-3">
|
|
||||||
{#each displayedGroups as group (group.id)}
|
|
||||||
<li
|
|
||||||
class="rounded border border-gray-200 p-4 transition duration-150 ease-in-out hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="font-medium text-gray-800">{group.name}</span>
|
|
||||||
<!-- Add link to group details page later -->
|
|
||||||
<a
|
|
||||||
href="/groups/{group.id}"
|
|
||||||
class="text-sm text-blue-600 hover:underline"
|
|
||||||
aria-label="View details for {group.name}"
|
|
||||||
>
|
|
||||||
View
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-gray-500">
|
|
||||||
ID: {group.id} | Created: {new Date(group.created_at).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
@ -1,51 +0,0 @@
|
|||||||
// src/routes/(app)/dashboard/+page.ts
|
|
||||||
import { error } from '@sveltejs/kit';
|
|
||||||
import { apiClient, ApiClientError } from '$lib/apiClient';
|
|
||||||
import type { GroupPublic } from '$lib/schemas/group'; // Import the Group type
|
|
||||||
import type { PageLoad } from './$types'; // SvelteKit's type for load functions
|
|
||||||
import type { ListPublic } from '$lib/schemas/list';
|
|
||||||
|
|
||||||
// Define the expected shape of the data returned by this load function
|
|
||||||
export interface DashboardLoadData {
|
|
||||||
groups: GroupPublic[];
|
|
||||||
error?: string | null; // Optional error message property
|
|
||||||
}
|
|
||||||
|
|
||||||
export const load: PageLoad<DashboardLoadData> = async ({ fetch }) => {
|
|
||||||
// Note: SvelteKit's 'fetch' is recommended inside load functions
|
|
||||||
// as it handles credentials and relative paths better during SSR/CSR.
|
|
||||||
// However, our apiClient uses the global fetch but includes auth logic.
|
|
||||||
// For consistency, we can continue using apiClient here.
|
|
||||||
console.log('Dashboard page load: Fetching groups...');
|
|
||||||
try {
|
|
||||||
const groups = await apiClient.get<GroupPublic[]>('/v1/groups'); // apiClient adds auth header
|
|
||||||
const lists = await apiClient.get<ListPublic[]>('/v1/lists'); // apiClient adds auth header
|
|
||||||
console.log('Dashboard page load: Groups fetched successfully', groups);
|
|
||||||
return {
|
|
||||||
groups: groups ?? [], // Return empty array if API returns null/undefined
|
|
||||||
lists: lists ?? [],
|
|
||||||
error: null
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Dashboard page load: Failed to fetch groups:', err);
|
|
||||||
let errorMessage = 'Failed to load groups.';
|
|
||||||
if (err instanceof ApiClientError) {
|
|
||||||
// Specific API error handling (authStore's 401 handling should have run)
|
|
||||||
errorMessage = `Failed to load groups (Status: ${err.status}). Please try again later.`;
|
|
||||||
// If it was a 401, the layout guard should ideally redirect before this load runs,
|
|
||||||
// but handle defensively.
|
|
||||||
if (err.status === 401) {
|
|
||||||
errorMessage = "Your session may have expired. Please log in again."
|
|
||||||
// Redirect could also happen here, but layout guard is primary place
|
|
||||||
// throw redirect(307, '/login?sessionExpired=true');
|
|
||||||
}
|
|
||||||
} else if (err instanceof Error) {
|
|
||||||
errorMessage = `Network or client error: ${err.message}`;
|
|
||||||
}
|
|
||||||
// Return empty list and the error message
|
|
||||||
return {
|
|
||||||
groups: [],
|
|
||||||
error: errorMessage
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
@ -1,189 +0,0 @@
|
|||||||
<!-- src/routes/(app)/groups/[groupId]/+page.svelte -->
|
|
||||||
<script lang="ts">
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
import { authStore } from '$lib/stores/authStore';
|
|
||||||
import { apiClient, ApiClientError } from '$lib/apiClient';
|
|
||||||
import { goto } from '$app/navigation'; // Import goto for redirect
|
|
||||||
import type { InviteCodePublic } from '$lib/schemas/invite';
|
|
||||||
import type { Message } from '$lib/schemas/message'; // For leave response
|
|
||||||
import type { PageData } from './$types';
|
|
||||||
|
|
||||||
export let data: PageData;
|
|
||||||
|
|
||||||
// Invite generation state
|
|
||||||
let isOwner = false;
|
|
||||||
let isLoadingInvite = false;
|
|
||||||
let inviteCode: string | null = null;
|
|
||||||
let inviteExpiry: string | null = null;
|
|
||||||
let inviteError: string | null = null;
|
|
||||||
|
|
||||||
// --- Leave Group State ---
|
|
||||||
let isLeaving = false;
|
|
||||||
let leaveError: string | null = null;
|
|
||||||
// --- End Leave Group State ---
|
|
||||||
|
|
||||||
// Determine ownership and reset state
|
|
||||||
$: {
|
|
||||||
if ($authStore.user && data.group) {
|
|
||||||
isOwner = $authStore.user.id === data.group.created_by_id;
|
|
||||||
console.log(
|
|
||||||
`User ${$authStore.user.id}, Owner ${data.group.created_by_id}, Is Owner: ${isOwner}`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
isOwner = false;
|
|
||||||
}
|
|
||||||
// Reset state if group changes
|
|
||||||
inviteCode = null;
|
|
||||||
inviteExpiry = null;
|
|
||||||
inviteError = null;
|
|
||||||
leaveError = null; // Reset leave error too
|
|
||||||
isLeaving = false; // Reset leaving state
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateInvite() {
|
|
||||||
// ... (keep existing generateInvite function) ...
|
|
||||||
if (!isOwner || !data.group) return;
|
|
||||||
isLoadingInvite = true;
|
|
||||||
inviteCode = null;
|
|
||||||
inviteExpiry = null;
|
|
||||||
inviteError = null;
|
|
||||||
try {
|
|
||||||
const result = await apiClient.post<InviteCodePublic>(
|
|
||||||
`/v1/groups/${data.group.id}/invites`,
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
inviteCode = result.code;
|
|
||||||
inviteExpiry = new Date(result.expires_at).toLocaleString();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Invite generation failed:', err);
|
|
||||||
if (err instanceof ApiClientError) {
|
|
||||||
/* ... error handling ... */
|
|
||||||
} else if (err instanceof Error) {
|
|
||||||
/* ... */
|
|
||||||
} else {
|
|
||||||
/* ... */
|
|
||||||
}
|
|
||||||
// Simplified error handling for brevity, keep your previous detailed one
|
|
||||||
inviteError = err instanceof Error ? err.message : 'Failed to generate invite';
|
|
||||||
} finally {
|
|
||||||
isLoadingInvite = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyInviteCode() {
|
|
||||||
// ... (keep existing copyInviteCode function) ...
|
|
||||||
if (!inviteCode) return;
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(inviteCode);
|
|
||||||
alert('Invite code copied to clipboard!');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to copy text: ', err);
|
|
||||||
alert('Failed to copy code. Please copy manually.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Handle Leave Group ---
|
|
||||||
async function handleLeaveGroup() {
|
|
||||||
if (!data.group) return; // Should always have group data here
|
|
||||||
|
|
||||||
// Confirmation Dialog
|
|
||||||
const confirmationMessage = isOwner
|
|
||||||
? `Are you sure you want to leave the group "${data.group.name}"? Check if another owner exists or if you are the last member, as this might be restricted.`
|
|
||||||
: `Are you sure you want to leave the group "${data.group.name}"?`;
|
|
||||||
|
|
||||||
if (!confirm(confirmationMessage)) {
|
|
||||||
return; // User cancelled
|
|
||||||
}
|
|
||||||
|
|
||||||
isLeaving = true;
|
|
||||||
leaveError = null;
|
|
||||||
console.log(`Attempting to leave group ${data.group.id}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await apiClient.delete<Message>(`/v1/groups/${data.group.id}/leave`);
|
|
||||||
console.log('Leave group successful:', result);
|
|
||||||
|
|
||||||
// Redirect to dashboard on success
|
|
||||||
await goto('/dashboard?leftGroup=true'); // Add query param for optional feedback
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Leave group failed:', err);
|
|
||||||
if (err instanceof ApiClientError) {
|
|
||||||
let detail = 'Failed to leave the group.';
|
|
||||||
if (err.errorData && typeof err.errorData === 'object' && 'detail' in err.errorData) {
|
|
||||||
// detail = (<{ detail: string }>err.errorData).detail;
|
|
||||||
}
|
|
||||||
// Use backend detail directly if available, otherwise generic message
|
|
||||||
leaveError = `Error (${err.status}): ${detail}`;
|
|
||||||
} else if (err instanceof Error) {
|
|
||||||
leaveError = `Error: ${err.message}`;
|
|
||||||
} else {
|
|
||||||
leaveError = 'An unexpected error occurred.';
|
|
||||||
}
|
|
||||||
isLeaving = false; // Ensure loading state is reset on error
|
|
||||||
}
|
|
||||||
// No finally needed here as success results in navigation away
|
|
||||||
}
|
|
||||||
// --- End Handle Leave Group ---
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if data.group}
|
|
||||||
<div class="space-y-6">
|
|
||||||
<h1 class="text-3xl font-bold text-gray-800">Group: {data.group.name}</h1>
|
|
||||||
<p class="text-sm text-gray-500">
|
|
||||||
ID: {data.group.id} | Created: {new Date(data.group.created_at).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Member List Section -->
|
|
||||||
<div class="rounded bg-white p-6 shadow">
|
|
||||||
<!-- ... (keep existing member list code) ... -->
|
|
||||||
<h2 class="mb-4 text-xl font-semibold text-gray-700">Members</h2>
|
|
||||||
{#if data.group.members && data.group.members.length > 0}
|
|
||||||
<ul class="space-y-2">
|
|
||||||
{#each data.group.members as member (member.id)}
|
|
||||||
<li class="flex items-center justify-between rounded p-2 hover:bg-gray-100">
|
|
||||||
<span class="text-gray-800">{member.name || member.email}</span>
|
|
||||||
<span class="text-xs text-gray-500">ID: {member.id}</span>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{:else}
|
|
||||||
<p class="text-gray-500">No members found (or data not loaded).</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Invite Section (Owner Only) -->
|
|
||||||
{#if isOwner}
|
|
||||||
<div class="rounded bg-white p-6 shadow">
|
|
||||||
<!-- ... (keep existing invite generation code) ... -->
|
|
||||||
<h2 class="mb-4 text-xl font-semibold text-gray-700">Invite Members</h2>
|
|
||||||
<!-- ... button and invite display ... -->
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Group Actions Section -->
|
|
||||||
<div class="mt-6 rounded border border-dashed border-red-300 bg-white p-6 shadow">
|
|
||||||
<h2 class="mb-4 text-xl font-semibold text-red-700">Group Actions</h2>
|
|
||||||
{#if leaveError}
|
|
||||||
<p class="mb-3 text-sm text-red-600">{leaveError}</p>
|
|
||||||
{/if}
|
|
||||||
<button
|
|
||||||
on:click={handleLeaveGroup}
|
|
||||||
disabled={isLeaving}
|
|
||||||
class="rounded bg-red-600 px-4 py-2 font-medium text-white transition hover:bg-red-700 focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isLeaving ? 'Leaving...' : 'Leave Group'}
|
|
||||||
</button>
|
|
||||||
{#if isOwner}
|
|
||||||
<p class="mt-2 text-xs text-gray-500">Owners may have restrictions on leaving.</p>
|
|
||||||
{/if}
|
|
||||||
<!-- Add Delete Group button for owner later -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Back Link -->
|
|
||||||
<div class="mt-6 border-t pt-6">
|
|
||||||
<a href="/dashboard" class="text-sm text-blue-600 hover:underline">← Back to Dashboard</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="text-center text-red-500">Group data could not be loaded.</p>
|
|
||||||
{/if}
|
|
@ -1,55 +0,0 @@
|
|||||||
// src/routes/(app)/groups/[groupId]/+page.ts
|
|
||||||
import { error } from '@sveltejs/kit';
|
|
||||||
import { apiClient, ApiClientError } from '$lib/apiClient';
|
|
||||||
import type { GroupPublic } from '$lib/schemas/group';
|
|
||||||
import type { PageLoad } from './$types'; // SvelteKit's type for load functions
|
|
||||||
|
|
||||||
// Define the expected shape of the data returned
|
|
||||||
export interface GroupDetailPageLoadData {
|
|
||||||
group: GroupPublic; // The fetched group data
|
|
||||||
}
|
|
||||||
|
|
||||||
export const load: PageLoad<GroupDetailPageLoadData> = async ({ params, fetch }) => {
|
|
||||||
const groupId = params.groupId; // Get groupId from the URL parameter
|
|
||||||
console.log(`Group Detail page load: Fetching data for group ID: ${groupId}`);
|
|
||||||
|
|
||||||
// Basic validation (optional but good)
|
|
||||||
if (!groupId || isNaN(parseInt(groupId, 10))) {
|
|
||||||
throw error(400, 'Invalid Group ID'); // Use SvelteKit's error helper
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Fetch the specific group details using the apiClient
|
|
||||||
// The backend endpoint GET /api/v1/groups/{group_id} should include members
|
|
||||||
const groupData = await apiClient.get<GroupPublic>(`/v1/groups/${groupId}`);
|
|
||||||
|
|
||||||
if (!groupData) {
|
|
||||||
// Should not happen if API call was successful, but check defensively
|
|
||||||
throw error(404, 'Group not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Group Detail page load: Data fetched successfully', groupData);
|
|
||||||
return {
|
|
||||||
group: groupData
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Group Detail page load: Failed to fetch group ${groupId}:`, err);
|
|
||||||
if (err instanceof ApiClientError) {
|
|
||||||
if (err.status === 404) {
|
|
||||||
throw error(404, 'Group not found');
|
|
||||||
}
|
|
||||||
if (err.status === 403) {
|
|
||||||
// User is authenticated (layout guard passed) but not member of this group
|
|
||||||
throw error(403, 'Forbidden: You are not a member of this group');
|
|
||||||
}
|
|
||||||
// For other API errors (like 500)
|
|
||||||
throw error(err.status || 500, `API Error: ${err.message}`);
|
|
||||||
} else if (err instanceof Error) {
|
|
||||||
// Network or other client errors
|
|
||||||
throw error(500, `Failed to load group data: ${err.message}`);
|
|
||||||
} else {
|
|
||||||
// Unknown error
|
|
||||||
throw error(500, 'An unexpected error occurred while loading group data.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
@ -1,731 +0,0 @@
|
|||||||
<!-- src/routes/(app)/lists/[listId]/+page.svelte -->
|
|
||||||
<script lang="ts">
|
|
||||||
// Svelte/SvelteKit Imports
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
import { onMount, onDestroy } from 'svelte';
|
|
||||||
import type { PageData } from './$types';
|
|
||||||
|
|
||||||
// Component Imports
|
|
||||||
import ItemDisplay from '$lib/components/ItemDisplay.svelte';
|
|
||||||
import ImageOcrInput from '$lib/components/ImageOcrInput.svelte';
|
|
||||||
import OcrReview from '$lib/components/OcrReview.svelte'; // Import Review Component
|
|
||||||
|
|
||||||
// Utility/Store Imports
|
|
||||||
import { apiClient, ApiClientError } from '$lib/apiClient';
|
|
||||||
import { authStore } from '$lib/stores/authStore';
|
|
||||||
import { writable, get } from 'svelte/store';
|
|
||||||
|
|
||||||
// Schema Imports
|
|
||||||
import type { ItemPublic, ItemCreate, ItemUpdate } from '$lib/schemas/item';
|
|
||||||
import type { ListDetail, ListStatus } from '$lib/schemas/list';
|
|
||||||
import type { OcrExtractResponse } from '$lib/schemas/ocr';
|
|
||||||
import type { Message } from '$lib/schemas/message';
|
|
||||||
|
|
||||||
// --- DB and Sync Imports ---
|
|
||||||
import {
|
|
||||||
getListFromDb,
|
|
||||||
putListToDb,
|
|
||||||
putItemToDb,
|
|
||||||
deleteItemFromDb,
|
|
||||||
addSyncAction
|
|
||||||
} from '$lib/db';
|
|
||||||
import { syncStatus, syncError, processSyncQueue, triggerSync } from '$lib/syncService';
|
|
||||||
import { browser } from '$app/environment';
|
|
||||||
// --- End DB and Sync Imports ---
|
|
||||||
|
|
||||||
// --- Props ---
|
|
||||||
export let data: PageData; // Contains initial { list: ListDetail } from server/cache/load
|
|
||||||
|
|
||||||
// --- Local Reactive State ---
|
|
||||||
// Use a writable store locally to manage the list and items for easier updates
|
|
||||||
// Initialize with data from SSR/load function as fallback
|
|
||||||
const localListStore = writable<ListDetail | null>(data.list);
|
|
||||||
|
|
||||||
// --- Add Item State ---
|
|
||||||
let newItemName = '';
|
|
||||||
let newItemQuantity = '';
|
|
||||||
let isAddingItem = false;
|
|
||||||
let addItemError: string | null = null;
|
|
||||||
|
|
||||||
// --- General Item Error Display ---
|
|
||||||
let itemUpdateError: string | null = null;
|
|
||||||
let itemErrorTimeout: ReturnType<typeof setTimeout>;
|
|
||||||
|
|
||||||
// --- Polling State ---
|
|
||||||
let pollIntervalId: ReturnType<typeof setInterval> | null = null;
|
|
||||||
let lastKnownStatus: {
|
|
||||||
// Ensure this stores Date objects or null
|
|
||||||
list_updated_at: Date;
|
|
||||||
latest_item_updated_at: Date | null;
|
|
||||||
item_count: number;
|
|
||||||
} | null = null;
|
|
||||||
let isRefreshing = false;
|
|
||||||
const POLLING_INTERVAL_MS = 15000; // Poll every 15 seconds
|
|
||||||
|
|
||||||
// --- OCR State ---
|
|
||||||
let showOcrModal = false;
|
|
||||||
let isProcessingOcr = false; // Loading state for API call
|
|
||||||
let ocrError: string | null = null; // Error during API call
|
|
||||||
let showOcrReview = false; // Controls review modal visibility
|
|
||||||
let ocrResults: string[] = []; // Stores results from OCR API
|
|
||||||
let isConfirmingOcrItems = false; // Loading state for adding items after review
|
|
||||||
let confirmOcrError: string | null = null; // Error during final add after review
|
|
||||||
// --- End OCR State ---
|
|
||||||
|
|
||||||
// --- Lifecycle ---
|
|
||||||
onMount(() => {
|
|
||||||
let listId: number | null = null;
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
listId = parseInt($page.params.listId, 10);
|
|
||||||
} catch {
|
|
||||||
/* ignore parsing error */
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!listId) {
|
|
||||||
console.error('List Detail Mount: Invalid or missing listId in params.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Load from IndexedDB first
|
|
||||||
if (browser) {
|
|
||||||
console.log('List Detail Mount: Loading from IndexedDB for list', listId);
|
|
||||||
const listFromDb = await getListFromDb(listId);
|
|
||||||
if (listFromDb) {
|
|
||||||
console.log('List Detail Mount: Found list in DB', listFromDb);
|
|
||||||
localListStore.set(listFromDb);
|
|
||||||
initializePollingStatus(listFromDb);
|
|
||||||
} else {
|
|
||||||
console.log('List Detail Mount: List not found in DB, using SSR/load data.');
|
|
||||||
localListStore.set(data.list);
|
|
||||||
initializePollingStatus(data.list);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. If online, fetch fresh data in background
|
|
||||||
if (navigator.onLine) {
|
|
||||||
console.log('List Detail Mount: Online, fetching fresh data...');
|
|
||||||
fetchAndUpdateList(listId); // Don't await
|
|
||||||
processSyncQueue(); // Don't await
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Start polling
|
|
||||||
startPolling();
|
|
||||||
} else {
|
|
||||||
localListStore.set(data.list);
|
|
||||||
initializePollingStatus(data.list);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
stopPolling();
|
|
||||||
clearTimeout(itemErrorTimeout);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper to fetch from API and update DB + Store
|
|
||||||
async function fetchAndUpdateList(listId: number) {
|
|
||||||
// Don't trigger multiple refreshes concurrently
|
|
||||||
if (isRefreshing) return;
|
|
||||||
|
|
||||||
isRefreshing = true; // Show refresh indicator
|
|
||||||
console.log('List Detail: Fetching fresh data for list', listId);
|
|
||||||
try {
|
|
||||||
// Fetch the entire list detail (including items) from the API
|
|
||||||
const freshList = await apiClient.get<ListDetail>(`/v1/lists/${listId}`);
|
|
||||||
|
|
||||||
// Update IndexedDB with the latest data
|
|
||||||
await putListToDb(freshList);
|
|
||||||
|
|
||||||
// Update the local Svelte store, which triggers UI updates
|
|
||||||
localListStore.set(freshList);
|
|
||||||
|
|
||||||
// Reset the polling status based on this fresh data
|
|
||||||
// (This ensures the next poll compares against the latest fetched state)
|
|
||||||
initializePollingStatus(freshList);
|
|
||||||
|
|
||||||
console.log('List Detail: Fetched and updated list', listId);
|
|
||||||
clearItemError(); // Clear any lingering item errors after a successful refresh
|
|
||||||
} catch (err) {
|
|
||||||
console.error('List Detail: Failed to fetch fresh list data', err);
|
|
||||||
// Display an error message to the user via the existing error handling mechanism
|
|
||||||
handleItemUpdateError(
|
|
||||||
new CustomEvent('updateError', { detail: 'Failed to refresh list data.' })
|
|
||||||
);
|
|
||||||
// Note: If the error was 401/403, the apiClient or layout guard should handle logout/redirect
|
|
||||||
} finally {
|
|
||||||
isRefreshing = false; // Hide refresh indicator
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Polling Logic ---
|
|
||||||
function startPolling() {
|
|
||||||
stopPolling();
|
|
||||||
const currentList = get(localListStore);
|
|
||||||
if (!currentList) return;
|
|
||||||
console.log(
|
|
||||||
`Polling: Starting polling for list ${currentList.id} every ${POLLING_INTERVAL_MS}ms`
|
|
||||||
);
|
|
||||||
pollIntervalId = setInterval(checkListStatus, POLLING_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopPolling() {
|
|
||||||
if (pollIntervalId) {
|
|
||||||
clearInterval(pollIntervalId);
|
|
||||||
pollIntervalId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkListStatus() {
|
|
||||||
const currentList = get(localListStore);
|
|
||||||
if (!currentList || isRefreshing || !lastKnownStatus || !navigator.onLine) {
|
|
||||||
if (!navigator.onLine) console.log('Polling: Offline, skipping status check.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log(`Polling: Checking status for list ${currentList.id}`);
|
|
||||||
try {
|
|
||||||
const currentStatus = await apiClient.get<ListStatus>(`/v1/lists/${currentList.id}/status`);
|
|
||||||
const currentListUpdatedAt = new Date(currentStatus.list_updated_at);
|
|
||||||
const currentLatestItemUpdatedAt = currentStatus.latest_item_updated_at
|
|
||||||
? new Date(currentStatus.latest_item_updated_at)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const listChanged =
|
|
||||||
currentListUpdatedAt.getTime() !== lastKnownStatus.list_updated_at.getTime();
|
|
||||||
const itemsChanged =
|
|
||||||
currentLatestItemUpdatedAt?.getTime() !==
|
|
||||||
lastKnownStatus.latest_item_updated_at?.getTime() ||
|
|
||||||
currentStatus.item_count !== lastKnownStatus.item_count;
|
|
||||||
|
|
||||||
if (listChanged || itemsChanged) {
|
|
||||||
console.log('Polling: Change detected!', { listChanged, itemsChanged });
|
|
||||||
await refreshListData();
|
|
||||||
// Update known status AFTER successful refresh
|
|
||||||
lastKnownStatus = {
|
|
||||||
list_updated_at: currentListUpdatedAt,
|
|
||||||
latest_item_updated_at: currentLatestItemUpdatedAt,
|
|
||||||
item_count: currentStatus.item_count
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
console.log('Polling: No changes detected.');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Polling: Failed to fetch list status:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshListData() {
|
|
||||||
const listId = get(localListStore)?.id;
|
|
||||||
if (!listId) return;
|
|
||||||
isRefreshing = true;
|
|
||||||
console.log(`Polling: Refreshing full data for list ${listId}`);
|
|
||||||
try {
|
|
||||||
const freshList = await apiClient.get<ListDetail>(`/v1/lists/${listId}`);
|
|
||||||
await putListToDb(freshList);
|
|
||||||
localListStore.set(freshList);
|
|
||||||
// No need to re-init polling status here, checkListStatus updates it after refresh
|
|
||||||
console.log('Polling: List data refreshed successfully.');
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Polling: Failed to refresh list data for ${listId}:`, err);
|
|
||||||
handleItemUpdateError(
|
|
||||||
new CustomEvent('updateError', { detail: 'Failed to refresh list data.' })
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
isRefreshing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function initializePollingStatus(listData: ListDetail | null) {
|
|
||||||
if (!listData) {
|
|
||||||
lastKnownStatus = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const listUpdatedAt = new Date(listData.updated_at);
|
|
||||||
let latestItemUpdate: Date | null = null;
|
|
||||||
if (listData.items && listData.items.length > 0) {
|
|
||||||
const latestDateString = listData.items.reduce(
|
|
||||||
(latest, item) => (item.updated_at > latest ? item.updated_at : latest),
|
|
||||||
listData.items[0].updated_at
|
|
||||||
);
|
|
||||||
latestItemUpdate = new Date(latestDateString);
|
|
||||||
}
|
|
||||||
lastKnownStatus = {
|
|
||||||
list_updated_at: listUpdatedAt,
|
|
||||||
latest_item_updated_at: latestItemUpdate,
|
|
||||||
item_count: listData.items?.length ?? 0
|
|
||||||
};
|
|
||||||
console.log('Polling: Initial/Reset status set', lastKnownStatus);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Polling Init: Error parsing dates', e);
|
|
||||||
lastKnownStatus = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Event Handlers from ItemDisplay ---
|
|
||||||
/** Handles the itemUpdated event from ItemDisplay */
|
|
||||||
function handleItemUpdated(event: CustomEvent<ItemPublic>) {
|
|
||||||
const updatedItem = event.detail;
|
|
||||||
console.log('Parent received itemUpdated:', updatedItem);
|
|
||||||
// Update store for UI
|
|
||||||
localListStore.update((currentList) => {
|
|
||||||
if (!currentList) return null;
|
|
||||||
const index = currentList.items.findIndex((i) => i.id === updatedItem.id);
|
|
||||||
if (index !== -1) {
|
|
||||||
currentList.items[index] = updatedItem;
|
|
||||||
}
|
|
||||||
return { ...currentList, items: [...currentList.items] }; // Return new object
|
|
||||||
});
|
|
||||||
clearItemError();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Handles the itemDeleted event from ItemDisplay */
|
|
||||||
function handleItemDeleted(event: CustomEvent<number>) {
|
|
||||||
const deletedItemId = event.detail;
|
|
||||||
console.log('Parent received itemDeleted:', deletedItemId);
|
|
||||||
// Update store for UI
|
|
||||||
localListStore.update((currentList) => {
|
|
||||||
if (!currentList) return null;
|
|
||||||
return {
|
|
||||||
...currentList,
|
|
||||||
items: currentList.items.filter((item) => item.id !== deletedItemId)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
clearItemError();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Handles the updateError event from ItemDisplay */
|
|
||||||
function handleItemUpdateError(event: CustomEvent<string>) {
|
|
||||||
const errorMsg = event.detail;
|
|
||||||
console.log('Parent received updateError:', errorMsg);
|
|
||||||
itemUpdateError = errorMsg;
|
|
||||||
clearTimeout(itemErrorTimeout);
|
|
||||||
itemErrorTimeout = setTimeout(() => {
|
|
||||||
itemUpdateError = null;
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clears the general item update error message */
|
|
||||||
function clearItemError() {
|
|
||||||
itemUpdateError = null;
|
|
||||||
clearTimeout(itemErrorTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Add Item Logic (Single Item) ---
|
|
||||||
/** Handles submission of the Add Item form */
|
|
||||||
async function handleAddItem() {
|
|
||||||
const currentList = get(localListStore);
|
|
||||||
if (!newItemName.trim() || !currentList) {
|
|
||||||
addItemError = 'Item name cannot be empty.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isAddingItem) return;
|
|
||||||
|
|
||||||
isAddingItem = true;
|
|
||||||
addItemError = null;
|
|
||||||
clearItemError();
|
|
||||||
|
|
||||||
// 1. Optimistic UI Update with Temporary ID
|
|
||||||
const tempId = `temp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
||||||
const currentUserId = get(authStore).user?.id;
|
|
||||||
if (!currentUserId) {
|
|
||||||
addItemError = 'Cannot add item: User not identified.';
|
|
||||||
isAddingItem = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const optimisticItem: ItemPublic = {
|
|
||||||
// Use temporary string ID for optimistic UI
|
|
||||||
id: tempId as any, // Cast needed as DB expects number, but temp is string
|
|
||||||
list_id: currentList.id,
|
|
||||||
name: newItemName.trim(),
|
|
||||||
quantity: newItemQuantity.trim() || null,
|
|
||||||
is_complete: false,
|
|
||||||
price: null,
|
|
||||||
added_by_id: currentUserId,
|
|
||||||
completed_by_id: null,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
updated_at: new Date().toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
localListStore.update((list) =>
|
|
||||||
list ? { ...list, items: [...list.items, optimisticItem] } : null
|
|
||||||
);
|
|
||||||
// Skip adding temp item to IndexedDB for simplicity in MVP
|
|
||||||
|
|
||||||
// 2. Queue Sync Action
|
|
||||||
const actionPayload: ItemCreate = {
|
|
||||||
name: newItemName.trim(),
|
|
||||||
quantity: newItemQuantity.trim() || undefined
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
await addSyncAction({
|
|
||||||
type: 'create_item',
|
|
||||||
payload: { listId: currentList.id, data: actionPayload },
|
|
||||||
timestamp: Date.now(),
|
|
||||||
tempId: tempId // Include tempId for potential mapping later
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Trigger sync if online
|
|
||||||
if (browser && navigator.onLine) processSyncQueue();
|
|
||||||
|
|
||||||
// 4. Clear form
|
|
||||||
newItemName = '';
|
|
||||||
newItemQuantity = '';
|
|
||||||
} catch (dbError) {
|
|
||||||
console.error('Failed to queue add item action:', dbError);
|
|
||||||
addItemError = 'Failed to save item for offline sync.';
|
|
||||||
// Revert optimistic UI update
|
|
||||||
localListStore.update((list) =>
|
|
||||||
list ? { ...list, items: list.items.filter((i) => String(i.id) !== tempId) } : null
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
isAddingItem = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- OCR Handling ---
|
|
||||||
function openOcrModal() {
|
|
||||||
ocrError = null;
|
|
||||||
confirmOcrError = null;
|
|
||||||
showOcrModal = true;
|
|
||||||
}
|
|
||||||
function closeOcrModal() {
|
|
||||||
showOcrModal = false;
|
|
||||||
}
|
|
||||||
function closeOcrReview() {
|
|
||||||
showOcrReview = false;
|
|
||||||
ocrResults = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Handles image selection from the modal, uploads it, and shows review modal */
|
|
||||||
async function handleImageSelected(event: CustomEvent<File>) {
|
|
||||||
const imageFile = event.detail;
|
|
||||||
closeOcrModal();
|
|
||||||
isProcessingOcr = true;
|
|
||||||
ocrError = null;
|
|
||||||
confirmOcrError = null;
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('image_file', imageFile);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await apiClient.post<OcrExtractResponse>('/v1/ocr/extract-items', formData);
|
|
||||||
console.log('OCR Extraction successful:', result);
|
|
||||||
if (result.extracted_items && result.extracted_items.length > 0) {
|
|
||||||
ocrResults = result.extracted_items;
|
|
||||||
showOcrReview = true; // Show the review modal
|
|
||||||
} else {
|
|
||||||
ocrError = 'OCR processing finished, but no items were extracted.';
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('OCR failed:', err);
|
|
||||||
if (err instanceof ApiClientError) {
|
|
||||||
let detail = 'Failed to process image for items.';
|
|
||||||
if (err.errorData && typeof err.errorData === 'object' && 'detail' in err.errorData) {
|
|
||||||
detail = (err.errorData as { detail: string }).detail;
|
|
||||||
}
|
|
||||||
if (err.status === 413) {
|
|
||||||
detail = `Image file too large.`;
|
|
||||||
}
|
|
||||||
if (err.status === 400) {
|
|
||||||
detail = `Invalid image file type or request.`;
|
|
||||||
}
|
|
||||||
ocrError = `OCR Error (${err.status}): ${detail}`;
|
|
||||||
} else if (err instanceof Error) {
|
|
||||||
ocrError = `OCR Network/Client Error: ${err.message}`;
|
|
||||||
} else {
|
|
||||||
ocrError = 'An unexpected OCR error occurred.';
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
isProcessingOcr = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Handles confirmation from the OCR Review modal */
|
|
||||||
async function handleOcrConfirm(event: CustomEvent<string[]>) {
|
|
||||||
const itemNamesToAdd = event.detail;
|
|
||||||
closeOcrReview();
|
|
||||||
|
|
||||||
if (!itemNamesToAdd || itemNamesToAdd.length === 0) {
|
|
||||||
console.log('OCR Confirm: No items selected to add.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isConfirmingOcrItems = true;
|
|
||||||
confirmOcrError = null;
|
|
||||||
let successCount = 0;
|
|
||||||
let failCount = 0;
|
|
||||||
const currentList = get(localListStore); // Get current list state
|
|
||||||
const currentUserId = get(authStore).user?.id;
|
|
||||||
|
|
||||||
if (!currentList || !currentUserId) {
|
|
||||||
confirmOcrError = 'Cannot add items: list or user data missing.';
|
|
||||||
isConfirmingOcrItems = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`OCR Confirm: Attempting to add ${itemNamesToAdd.length} items...`);
|
|
||||||
|
|
||||||
// Process items sequentially for clearer feedback/error handling in MVP
|
|
||||||
for (const name of itemNamesToAdd) {
|
|
||||||
if (!name.trim()) continue; // Skip empty names
|
|
||||||
|
|
||||||
const tempId = `temp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
||||||
// Optimistic UI update
|
|
||||||
const optimisticItem: ItemPublic = {
|
|
||||||
id: tempId as any,
|
|
||||||
list_id: currentList.id,
|
|
||||||
name: name.trim(),
|
|
||||||
quantity: null,
|
|
||||||
is_complete: false,
|
|
||||||
price: null,
|
|
||||||
added_by_id: currentUserId,
|
|
||||||
completed_by_id: null,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
updated_at: new Date().toISOString()
|
|
||||||
};
|
|
||||||
localListStore.update((list) =>
|
|
||||||
list ? { ...list, items: [...list.items, optimisticItem] } : null
|
|
||||||
);
|
|
||||||
|
|
||||||
// Queue Sync Action
|
|
||||||
const actionPayload: ItemCreate = { name: name.trim(), quantity: undefined };
|
|
||||||
try {
|
|
||||||
await addSyncAction({
|
|
||||||
type: 'create_item',
|
|
||||||
payload: { listId: currentList.id, data: actionPayload },
|
|
||||||
timestamp: Date.now(),
|
|
||||||
tempId: tempId
|
|
||||||
});
|
|
||||||
successCount++;
|
|
||||||
} catch (dbError) {
|
|
||||||
console.error(`Failed to queue item '${name}':`, dbError);
|
|
||||||
failCount++;
|
|
||||||
// Revert optimistic UI update for this specific item
|
|
||||||
localListStore.update((list) =>
|
|
||||||
list ? { ...list, items: list.items.filter((i) => String(i.id) !== tempId) } : null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger sync if online
|
|
||||||
if (browser && navigator.onLine) processSyncQueue();
|
|
||||||
|
|
||||||
isConfirmingOcrItems = false;
|
|
||||||
|
|
||||||
// Provide feedback
|
|
||||||
if (failCount > 0) {
|
|
||||||
confirmOcrError = `Added ${successCount} items. Failed to queue ${failCount} items for sync.`;
|
|
||||||
} else {
|
|
||||||
console.log(`Successfully queued ${successCount} items from OCR.`);
|
|
||||||
// Optionally show a temporary success toast/message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Template -->
|
|
||||||
{#if $localListStore}
|
|
||||||
{@const list = $localListStore}
|
|
||||||
<!-- Create local const for easier access in template -->
|
|
||||||
<div class="space-y-6">
|
|
||||||
<!-- Sync Status Indicator -->
|
|
||||||
{#if $syncStatus === 'syncing'}
|
|
||||||
<div
|
|
||||||
class="fixed bottom-4 right-4 z-50 animate-pulse rounded bg-blue-100 p-3 text-sm text-blue-700 shadow"
|
|
||||||
role="status"
|
|
||||||
>
|
|
||||||
Syncing changes...
|
|
||||||
</div>
|
|
||||||
{:else if $syncStatus === 'error' && $syncError}
|
|
||||||
<div
|
|
||||||
class="fixed bottom-4 right-4 z-50 rounded bg-red-100 p-3 text-sm text-red-700 shadow"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
Sync Error: {$syncError}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- List Header -->
|
|
||||||
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-gray-200 pb-4">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-bold text-gray-800">{list.name}</h1>
|
|
||||||
{#if list.description}
|
|
||||||
<p class="mt-1 text-base text-gray-600">{list.description}</p>
|
|
||||||
{/if}
|
|
||||||
<p class="mt-1 text-xs text-gray-500">
|
|
||||||
ID: {list.id} |
|
|
||||||
{#if list.group_id}
|
|
||||||
<span class="font-medium text-purple-600">Shared</span> |
|
|
||||||
{:else}
|
|
||||||
<span class="font-medium text-gray-600">Personal</span> |
|
|
||||||
{/if}
|
|
||||||
Status: {list.is_complete ? 'Complete' : 'In Progress'} | Updated: {new Date(
|
|
||||||
list.updated_at
|
|
||||||
).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-shrink-0 items-center space-x-2">
|
|
||||||
<!-- Action Buttons -->
|
|
||||||
{#if isRefreshing}
|
|
||||||
<span class="animate-pulse text-sm text-blue-600">Refreshing...</span>
|
|
||||||
{/if}
|
|
||||||
<!-- OCR Button with Progress Indication -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
on:click={openOcrModal}
|
|
||||||
disabled={isProcessingOcr || isConfirmingOcrItems}
|
|
||||||
class="inline-flex items-center rounded bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{#if isProcessingOcr}
|
|
||||||
<svg
|
|
||||||
class="mr-2 h-4 w-4 animate-spin text-white"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
><circle
|
|
||||||
class="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="4"
|
|
||||||
></circle><path
|
|
||||||
class="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path></svg
|
|
||||||
>
|
|
||||||
Processing...
|
|
||||||
{:else if isConfirmingOcrItems}
|
|
||||||
<svg
|
|
||||||
class="mr-2 h-4 w-4 animate-spin text-white"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
><circle
|
|
||||||
class="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="4"
|
|
||||||
></circle><path
|
|
||||||
class="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path></svg
|
|
||||||
>
|
|
||||||
Adding Items...
|
|
||||||
{:else}
|
|
||||||
📷 Add via Photo
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
<a
|
|
||||||
href="/lists/{list.id}/edit"
|
|
||||||
class="rounded bg-yellow-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-yellow-600 focus:outline-none focus:ring-2 focus:ring-yellow-400 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
Edit List Details
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{#if ocrError || confirmOcrError}
|
|
||||||
<!-- Display OCR/Confirm errors -->
|
|
||||||
<div class="rounded border border-red-400 bg-red-100 p-3 text-sm text-red-700" role="alert">
|
|
||||||
{ocrError || confirmOcrError}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Add New Item Form Section -->
|
|
||||||
<div class="rounded bg-white p-4 shadow">
|
|
||||||
<h2 class="mb-3 text-lg font-semibold text-gray-700">Add New Item</h2>
|
|
||||||
<form
|
|
||||||
on:submit|preventDefault={handleAddItem}
|
|
||||||
class="flex flex-col gap-3 sm:flex-row sm:items-end"
|
|
||||||
>
|
|
||||||
<div class="flex-grow">
|
|
||||||
<label for="new-item-name" class="sr-only">Item Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="new-item-name"
|
|
||||||
placeholder="Item name (required)"
|
|
||||||
required
|
|
||||||
bind:value={newItemName}
|
|
||||||
class="w-full rounded border border-gray-300 p-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-70"
|
|
||||||
disabled={isAddingItem}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="sm:w-1/4">
|
|
||||||
<label for="new-item-qty" class="sr-only">Quantity</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="new-item-qty"
|
|
||||||
placeholder="Quantity (opt.)"
|
|
||||||
bind:value={newItemQuantity}
|
|
||||||
class="w-full rounded border border-gray-300 p-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-70"
|
|
||||||
disabled={isAddingItem}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="whitespace-nowrap rounded bg-blue-600 px-4 py-2 font-medium text-white shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
disabled={isAddingItem}
|
|
||||||
>
|
|
||||||
{isAddingItem ? 'Adding...' : 'Add Item'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{#if addItemError}
|
|
||||||
<p class="mt-2 text-sm text-red-600">{addItemError}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Item List Section -->
|
|
||||||
<div class="rounded bg-white p-6 shadow">
|
|
||||||
<h2 class="mb-4 text-xl font-semibold text-gray-700">Items ({list.items?.length ?? 0})</h2>
|
|
||||||
{#if itemUpdateError}
|
|
||||||
<!-- Display errors bubbled up from items -->
|
|
||||||
<div
|
|
||||||
class="mb-4 rounded border border-red-400 bg-red-100 p-3 text-sm text-red-700"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
{itemUpdateError}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if list.items && list.items.length > 0}
|
|
||||||
<ul class="space-y-2">
|
|
||||||
<!-- Use {#key} block to help Svelte efficiently update the list when items are added/removed/reordered -->
|
|
||||||
{#each list.items as item (item.id)}
|
|
||||||
<ItemDisplay
|
|
||||||
{item}
|
|
||||||
on:itemUpdated={handleItemUpdated}
|
|
||||||
on:itemDeleted={handleItemDeleted}
|
|
||||||
on:updateError={handleItemUpdateError}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{:else}
|
|
||||||
<p class="py-4 text-center text-gray-500">This list is empty. Add items above!</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Back Link -->
|
|
||||||
<div class="mt-6 border-t border-gray-200 pt-6">
|
|
||||||
<a href="/dashboard" class="text-sm text-blue-600 hover:underline">← Back to Dashboard</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<!-- Fallback if list data is somehow null/undefined after load function -->
|
|
||||||
<p class="text-center text-gray-500">Loading list data...</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- OCR Input Modal -->
|
|
||||||
{#if showOcrModal}
|
|
||||||
<ImageOcrInput on:imageSelected={handleImageSelected} on:cancel={closeOcrModal} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- OCR Review Modal -->
|
|
||||||
{#if showOcrReview}
|
|
||||||
<OcrReview
|
|
||||||
initialItems={ocrResults}
|
|
||||||
on:confirm={handleOcrConfirm}
|
|
||||||
on:cancel={closeOcrReview}
|
|
||||||
bind:isLoading={isConfirmingOcrItems}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
@ -1,53 +0,0 @@
|
|||||||
// src/routes/(app)/lists/[listId]/+page.ts
|
|
||||||
import { error } from '@sveltejs/kit';
|
|
||||||
import { apiClient, ApiClientError } from '$lib/apiClient';
|
|
||||||
import type { ListDetail } from '$lib/schemas/list';
|
|
||||||
// --- Use the correct generated type ---
|
|
||||||
import type { PageLoad } from './$types'; // This type includes correctly typed 'params'
|
|
||||||
|
|
||||||
export interface ListDetailPageLoadData {
|
|
||||||
list: ListDetail;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const load: PageLoad<ListDetailPageLoadData> = async ({ params, fetch }) => {
|
|
||||||
const listId = params.listId;
|
|
||||||
console.log(`List Detail page load: Fetching data for list ID: ${listId}`);
|
|
||||||
|
|
||||||
if (!listId || isNaN(parseInt(listId, 10))) {
|
|
||||||
throw error(400, 'Invalid List ID');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// Fetch the specific list details (expecting items to be included)
|
|
||||||
// The backend GET /api/v1/lists/{list_id} should return ListDetail schema
|
|
||||||
const listData = await apiClient.get<ListDetail>(`/v1/lists/${listId}`);
|
|
||||||
|
|
||||||
if (!listData) {
|
|
||||||
// Should not happen if API call was successful, but check defensively
|
|
||||||
throw error(404, 'List not found (API returned no data)');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('List Detail page load: Data fetched successfully', listData);
|
|
||||||
return {
|
|
||||||
list: listData
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`List Detail page load: Failed to fetch list ${listId}:`, err);
|
|
||||||
if (err instanceof ApiClientError) {
|
|
||||||
if (err.status === 404) {
|
|
||||||
throw error(404, 'List not found');
|
|
||||||
}
|
|
||||||
if (err.status === 403) {
|
|
||||||
// User is authenticated (layout guard passed) but not member/creator
|
|
||||||
throw error(403, 'Forbidden: You do not have permission to view this list');
|
|
||||||
}
|
|
||||||
// For other API errors (like 500)
|
|
||||||
throw error(err.status || 500, `API Error: ${err.message}`);
|
|
||||||
} else if (err instanceof Error) {
|
|
||||||
// Network or other client errors
|
|
||||||
throw error(500, `Failed to load list data: ${err.message}`);
|
|
||||||
} else {
|
|
||||||
// Unknown error
|
|
||||||
throw error(500, 'An unexpected error occurred while loading list data.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
@ -1,16 +0,0 @@
|
|||||||
<!-- src/routes/(app)/lists/[listId]/edit/+page.svelte -->
|
|
||||||
<script lang="ts">
|
|
||||||
import ListForm from '$lib/components/ListForm.svelte';
|
|
||||||
import type { PageData } from './$types'; // Type for { list, groups, error }
|
|
||||||
|
|
||||||
export let data: PageData;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="mx-auto max-w-xl">
|
|
||||||
<a href="/dashboard" class="mb-4 inline-block text-sm text-blue-600 hover:underline"
|
|
||||||
>← Back to Dashboard</a
|
|
||||||
>
|
|
||||||
<!-- Pass the fetched list, groups, and potential group load error -->
|
|
||||||
<!-- The 'list' prop tells ListForm it's in edit mode -->
|
|
||||||
<ListForm list={data.list} groups={data.groups} apiError={data.error} />
|
|
||||||
</div>
|
|
@ -1,75 +0,0 @@
|
|||||||
// src/routes/(app)/lists/[listId]/edit/+page.ts
|
|
||||||
import { error } from '@sveltejs/kit';
|
|
||||||
import { apiClient, ApiClientError } from '$lib/apiClient';
|
|
||||||
import type { GroupPublic } from '$lib/schemas/group';
|
|
||||||
import type { ListPublic } from '$lib/schemas/list'; // Use ListPublic or ListDetail
|
|
||||||
import type { PageLoad } from './$types';
|
|
||||||
|
|
||||||
export interface EditListPageLoadData {
|
|
||||||
list: ListPublic; // Or ListDetail if needed
|
|
||||||
groups: GroupPublic[];
|
|
||||||
error?: string | null; // For group loading errors
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the specific list to edit AND the user's groups for the dropdown
|
|
||||||
export const load: PageLoad<EditListPageLoadData> = async ({ params, fetch }) => {
|
|
||||||
const listId = params.listId;
|
|
||||||
console.log(`Edit List page load: Fetching list ${listId} and groups...`);
|
|
||||||
|
|
||||||
if (!listId || isNaN(parseInt(listId, 10))) {
|
|
||||||
throw error(400, 'Invalid List ID');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Fetch list details and groups in parallel
|
|
||||||
// Use apiClient for automatic auth handling
|
|
||||||
const [listResult, groupsResult] = await Promise.allSettled([
|
|
||||||
apiClient.get<ListPublic>(`/v1/lists/${listId}`), // Fetch specific list
|
|
||||||
apiClient.get<GroupPublic[]>('/v1/groups') // Fetch groups for dropdown
|
|
||||||
]);
|
|
||||||
|
|
||||||
let listData: ListPublic;
|
|
||||||
let groupsData: GroupPublic[] = [];
|
|
||||||
let groupsError: string | null = null;
|
|
||||||
|
|
||||||
// Process list result
|
|
||||||
if (listResult.status === 'fulfilled' && listResult.value) {
|
|
||||||
listData = listResult.value;
|
|
||||||
} else {
|
|
||||||
// Handle list fetch failure
|
|
||||||
const reason = listResult.status === 'rejected' ? listResult.reason : new Error('List data missing');
|
|
||||||
console.error(`Edit List page load: Failed to fetch list ${listId}:`, reason);
|
|
||||||
if (reason instanceof ApiClientError) {
|
|
||||||
if (reason.status === 404) throw error(404, 'List not found');
|
|
||||||
if (reason.status === 403) throw error(403, 'Forbidden: You cannot edit this list');
|
|
||||||
throw error(reason.status || 500, `API Error loading list: ${reason.message}`);
|
|
||||||
}
|
|
||||||
throw error(500, `Failed to load list data: ${reason instanceof Error ? reason.message : 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process groups result (non-critical, form can work without it)
|
|
||||||
if (groupsResult.status === 'fulfilled' && groupsResult.value) {
|
|
||||||
groupsData = groupsResult.value;
|
|
||||||
} else {
|
|
||||||
const reason = groupsResult.status === 'rejected' ? groupsResult.reason : new Error('Groups data missing');
|
|
||||||
console.error('Edit List page load: Failed to fetch groups:', reason);
|
|
||||||
groupsError = `Failed to load groups for sharing options: ${reason instanceof Error ? reason.message : 'Unknown error'}`;
|
|
||||||
// Don't throw error here, just pass the message to the component
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
list: listData,
|
|
||||||
groups: groupsData,
|
|
||||||
error: groupsError // Pass group loading error to the page
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
// Catch errors thrown by Promise.allSettled handling or initial setup
|
|
||||||
console.error(`Edit List page load: Unexpected error for list ${listId}:`, err);
|
|
||||||
// Check if it's a SvelteKit error object before re-throwing
|
|
||||||
if (err instanceof Error && 'status' in err && typeof err.status === 'number') {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
throw error(500, `An unexpected error occurred: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
};
|
|
@ -1,13 +0,0 @@
|
|||||||
<!-- src/routes/(app)/lists/new/+page.svelte -->
|
|
||||||
<script lang="ts">
|
|
||||||
import ListForm from '$lib/components/ListForm.svelte';
|
|
||||||
import type { PageData } from './$types'; // Type for { groups, error }
|
|
||||||
|
|
||||||
export let data: PageData;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="mx-auto max-w-xl">
|
|
||||||
<!-- Pass groups and potential load error to the form component -->
|
|
||||||
<!-- 'list' prop is omitted/null, so ListForm knows it's in create mode -->
|
|
||||||
<ListForm groups={data.groups} apiError={data.error} />
|
|
||||||
</div>
|
|
@ -1,32 +0,0 @@
|
|||||||
// src/routes/(app)/lists/new/+page.ts
|
|
||||||
import { apiClient, ApiClientError } from '$lib/apiClient';
|
|
||||||
import type { GroupPublic } from '$lib/schemas/group';
|
|
||||||
import type { PageLoad } from './$types';
|
|
||||||
|
|
||||||
export interface NewListPageLoadData {
|
|
||||||
groups: GroupPublic[];
|
|
||||||
error?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch groups needed for the dropdown in the form
|
|
||||||
export const load: PageLoad<NewListPageLoadData> = async ({ fetch }) => {
|
|
||||||
console.log('New List page load: Fetching groups...');
|
|
||||||
try {
|
|
||||||
const groups = await apiClient.get<GroupPublic[]>('/v1/groups');
|
|
||||||
return {
|
|
||||||
groups: groups ?? [],
|
|
||||||
error: null
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
console.error('New List page load: Failed to fetch groups:', err);
|
|
||||||
let errorMessage = 'Failed to load group data for sharing options.';
|
|
||||||
// Handle specific errors if needed (e.g., 401 handled globally)
|
|
||||||
if (err instanceof Error) {
|
|
||||||
errorMessage = `Error loading groups: ${err.message}`;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
groups: [],
|
|
||||||
error: errorMessage
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
@ -1,56 +0,0 @@
|
|||||||
<!-- src/routes/+layout.svelte -->
|
|
||||||
<script lang="ts">
|
|
||||||
import '../app.css';
|
|
||||||
import { authStore, logout as performLogout } from '$lib/stores/authStore'; // Import store and logout action
|
|
||||||
import { goto } from '$app/navigation'; // Import goto for logout redirect
|
|
||||||
import { page } from '$app/stores'; // To check current route
|
|
||||||
|
|
||||||
async function handleLogout() {
|
|
||||||
console.log('Logging out from root layout...');
|
|
||||||
performLogout();
|
|
||||||
await goto('/login');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex min-h-screen flex-col bg-gray-50">
|
|
||||||
<!-- Only show the main header if NOT inside the authenticated (app) section -->
|
|
||||||
{#if !$page.route.id?.startsWith('/(app)')}
|
|
||||||
<header class="bg-gradient-to-r from-blue-600 to-indigo-700 p-4 text-white shadow-md">
|
|
||||||
<div class="container mx-auto flex items-center justify-between">
|
|
||||||
<a href="/" class="text-xl font-bold hover:text-blue-200">Shared Lists App</a>
|
|
||||||
|
|
||||||
<nav class="flex items-center space-x-4">
|
|
||||||
{#if $authStore.isAuthenticated && $authStore.user}
|
|
||||||
<!-- Show if logged in -->
|
|
||||||
<span class="text-sm">{$authStore.user.name || $authStore.user.email}</span>
|
|
||||||
<a href="/dashboard" class="text-sm hover:text-blue-200 hover:underline">Dashboard</a>
|
|
||||||
<button
|
|
||||||
on:click={handleLogout}
|
|
||||||
class="rounded bg-red-500 px-3 py-1 text-sm font-medium hover:bg-red-600 focus:ring-2 focus:ring-red-400 focus:ring-offset-2 focus:ring-offset-blue-700 focus:outline-none"
|
|
||||||
>
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<!-- Show if logged out -->
|
|
||||||
<a href="/" class="hover:text-blue-200 hover:underline">Home</a>
|
|
||||||
<a href="/login" class="hover:text-blue-200 hover:underline">Login</a>
|
|
||||||
<a href="/signup" class="hover:text-blue-200 hover:underline">Sign Up</a>
|
|
||||||
{/if}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Main Content Area - Renders layout/page based on route -->
|
|
||||||
<!-- The (app) layout will take over rendering its own header when inside that group -->
|
|
||||||
<main class="container mx-auto flex-grow p-4 md:p-8">
|
|
||||||
<slot />
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Only show the main footer if NOT inside the authenticated (app) section -->
|
|
||||||
{#if !$page.route.id?.startsWith('/(app)')}
|
|
||||||
<footer class="mt-auto bg-gray-200 p-4 text-center text-sm text-gray-600">
|
|
||||||
<p>© {new Date().getFullYear()} Shared Lists App. All rights reserved.</p>
|
|
||||||
</footer>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
@ -1,100 +0,0 @@
|
|||||||
<!-- src/routes/+page.svelte -->
|
|
||||||
<script lang="ts">
|
|
||||||
// Imports
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { apiClient, ApiClientError } from '$lib/apiClient'; // Use $lib alias
|
|
||||||
import type { HealthStatus } from '$lib/schemas/health'; // Ensure this path is correct for your project structure
|
|
||||||
|
|
||||||
// Component State
|
|
||||||
let apiStatus = 'Checking...';
|
|
||||||
let dbStatus = 'Checking...';
|
|
||||||
let errorMessage: string | null = null;
|
|
||||||
|
|
||||||
// Fetch API health on component mount
|
|
||||||
onMount(async () => {
|
|
||||||
console.log('Home page mounted, checking API health...');
|
|
||||||
try {
|
|
||||||
// Specify the expected return type using the generic
|
|
||||||
const health = await apiClient.get<HealthStatus>('/v1/health'); // Path relative to BASE_URL
|
|
||||||
|
|
||||||
console.log('API Health Response:', health);
|
|
||||||
// Use nullish coalescing (??) in case status is optional or null
|
|
||||||
apiStatus = health.status ?? 'ok';
|
|
||||||
dbStatus = health.database;
|
|
||||||
errorMessage = null; // Clear any previous error
|
|
||||||
} catch (err) {
|
|
||||||
console.error('API Health Check Failed:', err);
|
|
||||||
apiStatus = 'Error';
|
|
||||||
dbStatus = 'Error';
|
|
||||||
|
|
||||||
// Handle different error types
|
|
||||||
if (err instanceof ApiClientError) {
|
|
||||||
// Start with the basic error message
|
|
||||||
errorMessage = `API Error (${err.status}): ${err.message}`;
|
|
||||||
// Append detail from backend if available (using 'as' for type assertion)
|
|
||||||
if (
|
|
||||||
err.errorData &&
|
|
||||||
typeof err.errorData === 'object' &&
|
|
||||||
err.errorData !== null &&
|
|
||||||
'detail' in err.errorData
|
|
||||||
) {
|
|
||||||
errorMessage += ` - Detail: ${(err.errorData as { detail: string }).detail}`;
|
|
||||||
}
|
|
||||||
} else if (err instanceof Error) {
|
|
||||||
// Handle network errors or other generic errors
|
|
||||||
errorMessage = `Error: ${err.message}`;
|
|
||||||
} else {
|
|
||||||
// Fallback for unknown errors
|
|
||||||
errorMessage = 'An unknown error occurred.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- HTML Structure -->
|
|
||||||
<div class="space-y-6 text-center">
|
|
||||||
<!-- Welcome Section -->
|
|
||||||
<div>
|
|
||||||
<h2 class="mb-4 text-3xl font-semibold text-gray-800">Welcome to Shared Lists!</h2>
|
|
||||||
<p class="text-lg text-gray-600">
|
|
||||||
Your go-to app for managing household shopping lists, capturing items via OCR, and splitting
|
|
||||||
costs easily.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- API Status Section -->
|
|
||||||
<div class="mx-auto max-w-sm rounded border border-gray-300 bg-white p-4 shadow-sm">
|
|
||||||
<h3 class="mb-3 text-lg font-medium text-gray-700">System Status</h3>
|
|
||||||
{#if errorMessage}
|
|
||||||
<p class="mb-2 rounded bg-red-100 p-2 text-sm text-red-600">{errorMessage}</p>
|
|
||||||
{/if}
|
|
||||||
<p class="text-gray-700">
|
|
||||||
API Reachable:
|
|
||||||
<span class="font-semibold {apiStatus === 'ok' ? 'text-green-600' : 'text-red-600'}">
|
|
||||||
{apiStatus}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p class="text-gray-700">
|
|
||||||
Database Connection:
|
|
||||||
<span class="font-semibold {dbStatus === 'connected' ? 'text-green-600' : 'text-red-600'}">
|
|
||||||
{dbStatus}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Call to Action Section -->
|
|
||||||
<div class="mt-8">
|
|
||||||
<a
|
|
||||||
href="/signup"
|
|
||||||
class="mr-4 rounded bg-blue-600 px-6 py-2 font-medium text-white transition duration-150 ease-in-out hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
|
|
||||||
>
|
|
||||||
Get Started
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/features"
|
|
||||||
class="rounded bg-gray-300 px-6 py-2 font-medium text-gray-800 transition duration-150 ease-in-out hover:bg-gray-400 focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:outline-none"
|
|
||||||
>
|
|
||||||
Learn More
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
@ -1,138 +0,0 @@
|
|||||||
<!-- src/routes/join/+page.svelte -->
|
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { apiClient, ApiClientError } from '$lib/apiClient';
|
|
||||||
import type { Message } from '$lib/schemas/message';
|
|
||||||
import type { PageData } from './$types'; // Type for data from load function
|
|
||||||
|
|
||||||
// Receive data from the +page.ts load function
|
|
||||||
export let data: PageData; // Contains { codeFromUrl?: string | null }
|
|
||||||
|
|
||||||
// Form state
|
|
||||||
let inviteCode = '';
|
|
||||||
let isLoading = false;
|
|
||||||
let errorMessage: string | null = null;
|
|
||||||
let successMessage: string | null = null;
|
|
||||||
|
|
||||||
// Pre-fill input if code is present in URL on component mount
|
|
||||||
onMount(() => {
|
|
||||||
if (data.codeFromUrl && !inviteCode) {
|
|
||||||
inviteCode = data.codeFromUrl;
|
|
||||||
console.log('Join page mounted: Pre-filled code from URL:', inviteCode);
|
|
||||||
// Optional: Remove code from URL history for cleaner look
|
|
||||||
// history.replaceState(null, '', '/join');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleJoinGroup() {
|
|
||||||
if (!inviteCode.trim()) {
|
|
||||||
errorMessage = 'Please enter an invite code.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading = true;
|
|
||||||
errorMessage = null;
|
|
||||||
successMessage = null;
|
|
||||||
console.log(`Attempting to join group with code: ${inviteCode}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Backend expects POST /api/v1/invites/accept with body: { "code": "..." }
|
|
||||||
const requestBody = { code: inviteCode.trim() };
|
|
||||||
const result = await apiClient.post<Message>('/v1/invites/accept', requestBody);
|
|
||||||
|
|
||||||
console.log('Join group successful:', result);
|
|
||||||
|
|
||||||
// Set success message briefly before redirecting
|
|
||||||
successMessage = result.detail || 'Successfully joined the group!';
|
|
||||||
|
|
||||||
// Redirect to dashboard after a short delay to show the message
|
|
||||||
// Alternatively, redirect immediately.
|
|
||||||
setTimeout(async () => {
|
|
||||||
await goto('/dashboard'); // Redirect to dashboard where group list will refresh
|
|
||||||
}, 1500); // 1.5 second delay
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Join group failed:', err);
|
|
||||||
if (err instanceof ApiClientError) {
|
|
||||||
// Extract detail message from backend error response
|
|
||||||
let detail = 'Failed to join group.';
|
|
||||||
if (err.errorData && typeof err.errorData === 'object' && 'detail' in err.errorData) {
|
|
||||||
// detail = (<{ detail: string }>err.errorData).detail;
|
|
||||||
}
|
|
||||||
// Customize message based on common errors from backend
|
|
||||||
if (err.status === 404) {
|
|
||||||
errorMessage = 'Invite code is invalid, expired, or already used.';
|
|
||||||
} else if (detail.includes('already a member')) {
|
|
||||||
// Check if backend detail indicates this
|
|
||||||
errorMessage = detail; // Use backend message like "You are already a member..."
|
|
||||||
} else {
|
|
||||||
errorMessage = `Error (${err.status}): ${detail}`;
|
|
||||||
}
|
|
||||||
} else if (err instanceof Error) {
|
|
||||||
errorMessage = `Error: ${err.message}`;
|
|
||||||
} else {
|
|
||||||
errorMessage = 'An unexpected error occurred.';
|
|
||||||
}
|
|
||||||
// Clear input on error? Optional.
|
|
||||||
// inviteCode = '';
|
|
||||||
} finally {
|
|
||||||
isLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="mx-auto max-w-md rounded border border-gray-200 bg-white p-8 shadow-md">
|
|
||||||
<h1 class="mb-6 text-center text-2xl font-semibold text-gray-700">Join a Group</h1>
|
|
||||||
|
|
||||||
<form on:submit|preventDefault={handleJoinGroup} class="space-y-4">
|
|
||||||
{#if successMessage}
|
|
||||||
<div
|
|
||||||
class="mb-4 rounded border border-green-400 bg-green-100 p-3 text-center text-sm text-green-700"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
{successMessage} Redirecting...
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if errorMessage}
|
|
||||||
<div
|
|
||||||
class="mb-4 rounded border border-red-400 bg-red-100 p-3 text-center text-sm text-red-700"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
{errorMessage}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="invite-code" class="mb-1 block text-sm font-medium text-gray-600"
|
|
||||||
>Invite Code</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="invite-code"
|
|
||||||
bind:value={inviteCode}
|
|
||||||
placeholder="Enter code shared with you..."
|
|
||||||
required
|
|
||||||
class="w-full rounded border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-70"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="w-full rounded bg-blue-600 px-4 py-2 font-medium text-white transition duration-150 ease-in-out hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
disabled={isLoading || !!successMessage}
|
|
||||||
>
|
|
||||||
{#if isLoading}
|
|
||||||
Joining...
|
|
||||||
{:else if successMessage}
|
|
||||||
Joined!
|
|
||||||
{:else}
|
|
||||||
Join Group
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p class="mt-4 text-center text-sm text-gray-600">
|
|
||||||
<a href="/dashboard" class="font-medium text-blue-600 hover:underline">← Back to Dashboard</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
@ -1,19 +0,0 @@
|
|||||||
// src/routes/join/+page.ts
|
|
||||||
import type { PageLoad } from './$types';
|
|
||||||
|
|
||||||
// Define the shape of data passed to the page component
|
|
||||||
export interface JoinPageLoadData {
|
|
||||||
codeFromUrl?: string | null; // Code extracted from URL, if present
|
|
||||||
}
|
|
||||||
|
|
||||||
export const load: PageLoad<JoinPageLoadData> = ({ url }) => {
|
|
||||||
// Check if a 'code' query parameter exists in the URL
|
|
||||||
const code = url.searchParams.get('code');
|
|
||||||
|
|
||||||
console.log(`Join page load: Checking for code in URL. Found: ${code}`);
|
|
||||||
|
|
||||||
// Return the code (or null if not found) so the page component can access it
|
|
||||||
return {
|
|
||||||
codeFromUrl: code
|
|
||||||
};
|
|
||||||
};
|
|
@ -1,150 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { page } from '$app/stores'; // To read query parameters
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { apiClient, ApiClientError } from '$lib/apiClient';
|
|
||||||
import { login as setAuthState } from '$lib/stores/authStore'; // Rename import for clarity
|
|
||||||
import type { Token } from '$lib/schemas/auth';
|
|
||||||
import type { UserPublic } from '$lib/schemas/user'; // Or wherever UserPublic is defined
|
|
||||||
|
|
||||||
let email = '';
|
|
||||||
let password = '';
|
|
||||||
let isLoading = false;
|
|
||||||
let errorMessage: string | null = null;
|
|
||||||
let signupSuccessMessage: string | null = null;
|
|
||||||
|
|
||||||
// Check for signup success message on mount
|
|
||||||
onMount(() => {
|
|
||||||
if ($page.url.searchParams.get('signedUp') === 'true') {
|
|
||||||
signupSuccessMessage = 'Signup successful! Please log in.';
|
|
||||||
// Optional: Remove the query param from URL history for cleaner UX
|
|
||||||
// history.replaceState(null, '', '/login');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleLogin() {
|
|
||||||
isLoading = true;
|
|
||||||
errorMessage = null;
|
|
||||||
signupSuccessMessage = null; // Clear signup message on new attempt
|
|
||||||
console.log('Attempting login...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Prepare form data for OAuth2PasswordRequestForm (backend expects x-www-form-urlencoded)
|
|
||||||
const loginFormData = new URLSearchParams();
|
|
||||||
loginFormData.append('username', email); // Key must be 'username'
|
|
||||||
loginFormData.append('password', password);
|
|
||||||
|
|
||||||
// 2. Call the API login endpoint
|
|
||||||
const tokenResponse = await apiClient.post<Token>('/v1/auth/login', loginFormData, {
|
|
||||||
headers: {
|
|
||||||
// Must set Content-Type for form data
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Fetch user data using the new token (apiClient will add header)
|
|
||||||
// Store token *temporarily* just for the next call, before setting the store.
|
|
||||||
// This is slightly tricky. A better way might be to have login endpoint return user data.
|
|
||||||
// Let's assume apiClient is updated to use the token *after* this call by setting the store.
|
|
||||||
// Alternative: Modify backend login to return user data + token.
|
|
||||||
// For now, let's update the store *first* and then fetch user.
|
|
||||||
|
|
||||||
// ---> TEMPORARY TOKEN HANDLING FOR /users/me CALL <---
|
|
||||||
const tempToken = tokenResponse.access_token;
|
|
||||||
// Make the /users/me call *with the specific token* before fully setting auth state
|
|
||||||
const userResponse = await apiClient.get<UserPublic>('/v1/users/me', {
|
|
||||||
headers: { Authorization: `Bearer ${tempToken}` }
|
|
||||||
});
|
|
||||||
// --- END TEMPORARY TOKEN HANDLING ---
|
|
||||||
|
|
||||||
// 4. Update the auth store (this makes subsequent apiClient calls authenticated)
|
|
||||||
setAuthState(tokenResponse.access_token, userResponse);
|
|
||||||
|
|
||||||
console.log('Login successful, user:', userResponse);
|
|
||||||
|
|
||||||
// 5. Redirect to dashboard or protected area
|
|
||||||
// Check if there was a redirect query parameter? e.g., ?redirectTo=/some/page
|
|
||||||
const redirectTo = $page.url.searchParams.get('redirectTo') || '/dashboard'; // Default redirect
|
|
||||||
await goto(redirectTo);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Login failed:', err);
|
|
||||||
if (err instanceof ApiClientError) {
|
|
||||||
if (err.status === 401) {
|
|
||||||
// The global handler in apiClient already called logout(), just show message
|
|
||||||
errorMessage = 'Login failed: Invalid email or password.';
|
|
||||||
} else {
|
|
||||||
let detail = 'An unknown API error occurred during login.';
|
|
||||||
if (err.errorData && typeof err.errorData === 'object' && 'detail' in err.errorData) {
|
|
||||||
// detail = (<{ detail: string }>err.errorData).detail;
|
|
||||||
}
|
|
||||||
errorMessage = `Login error (${err.status}): ${detail}`;
|
|
||||||
}
|
|
||||||
} else if (err instanceof Error) {
|
|
||||||
errorMessage = `Network error: ${err.message}`;
|
|
||||||
} else {
|
|
||||||
errorMessage = 'An unexpected error occurred during login.';
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
isLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="mx-auto max-w-md rounded border border-gray-200 bg-white p-8 shadow-md">
|
|
||||||
<h1 class="mb-6 text-center text-2xl font-semibold text-gray-700">Log In</h1>
|
|
||||||
|
|
||||||
<form on:submit|preventDefault={handleLogin} class="space-y-4">
|
|
||||||
{#if signupSuccessMessage}
|
|
||||||
<div
|
|
||||||
class="mb-4 rounded border border-green-400 bg-green-100 p-3 text-center text-sm text-green-700"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
{signupSuccessMessage}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if errorMessage}
|
|
||||||
<div
|
|
||||||
class="mb-4 rounded border border-red-400 bg-red-100 p-3 text-center text-sm text-red-700"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
{errorMessage}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="email" class="mb-1 block text-sm font-medium text-gray-600">Email</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
id="email"
|
|
||||||
bind:value={email}
|
|
||||||
required
|
|
||||||
class="w-full rounded border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="password" class="mb-1 block text-sm font-medium text-gray-600">Password</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="password"
|
|
||||||
bind:value={password}
|
|
||||||
required
|
|
||||||
class="w-full rounded border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="w-full rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{isLoading ? 'Logging in...' : 'Log In'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<p class="mt-4 text-center text-sm text-gray-600">
|
|
||||||
Don't have an account?
|
|
||||||
<a href="/signup" class="font-medium text-blue-600 hover:underline">Sign Up</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
@ -1,118 +0,0 @@
|
|||||||
<!-- src/routes/signup/+page.svelte -->
|
|
||||||
<script lang="ts">
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { apiClient, ApiClientError } from '$lib/apiClient';
|
|
||||||
import type { UserPublic } from '$lib/schemas/user'; // Or import from where you defined it
|
|
||||||
|
|
||||||
let name = '';
|
|
||||||
let email = '';
|
|
||||||
let password = '';
|
|
||||||
let isLoading = false;
|
|
||||||
let errorMessage: string | null = null;
|
|
||||||
|
|
||||||
async function handleSignup() {
|
|
||||||
isLoading = true;
|
|
||||||
errorMessage = null;
|
|
||||||
console.log('Attempting signup...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// API expects: { email, password, name? }
|
|
||||||
const signupData = { email, password, name: name || undefined }; // Send name only if provided
|
|
||||||
const createdUser = await apiClient.post<UserPublic>('/v1/auth/signup', signupData);
|
|
||||||
|
|
||||||
console.log('Signup successful:', createdUser);
|
|
||||||
|
|
||||||
// Option 1: Redirect to login page with a success message
|
|
||||||
await goto('/login?signedUp=true');
|
|
||||||
|
|
||||||
// Option 2: Log user in directly (more complex, requires login call)
|
|
||||||
// requires importing login action from store & handling potential post-signup login errors
|
|
||||||
// const loginFormData = new URLSearchParams();
|
|
||||||
// loginFormData.append('username', email);
|
|
||||||
// loginFormData.append('password', password);
|
|
||||||
// const tokenResponse = await apiClient.post<Token>('/v1/auth/login', loginFormData, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
|
|
||||||
// storeLogin(tokenResponse.access_token, createdUser); // Use the user data from signup response
|
|
||||||
// await goto('/dashboard');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Signup failed:', err);
|
|
||||||
if (err instanceof ApiClientError) {
|
|
||||||
// Extract detail message from backend if available
|
|
||||||
let detail = 'An unknown API error occurred during signup.';
|
|
||||||
if (err.errorData && typeof err.errorData === 'object' && 'detail' in err.errorData) {
|
|
||||||
// detail = (<{ detail: string }>err.errorData).detail; // Type assertion
|
|
||||||
}
|
|
||||||
errorMessage = `Signup failed (${err.status}): ${detail}`;
|
|
||||||
} else if (err instanceof Error) {
|
|
||||||
errorMessage = `Error: ${err.message}`;
|
|
||||||
} else {
|
|
||||||
errorMessage = 'An unexpected error occurred.';
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
isLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="mx-auto max-w-md rounded border border-gray-200 bg-white p-8 shadow-md">
|
|
||||||
<h1 class="mb-6 text-center text-2xl font-semibold text-gray-700">Create Account</h1>
|
|
||||||
|
|
||||||
<form on:submit|preventDefault={handleSignup} class="space-y-4">
|
|
||||||
{#if errorMessage}
|
|
||||||
<div
|
|
||||||
class="mb-4 rounded border border-red-400 bg-red-100 p-3 text-center text-sm text-red-700"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
{errorMessage}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="name" class="mb-1 block text-sm font-medium text-gray-600">Name (Optional)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="name"
|
|
||||||
bind:value={name}
|
|
||||||
class="w-full rounded border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="email" class="mb-1 block text-sm font-medium text-gray-600">Email</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
id="email"
|
|
||||||
bind:value={email}
|
|
||||||
required
|
|
||||||
class="w-full rounded border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="password" class="mb-1 block text-sm font-medium text-gray-600">Password</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="password"
|
|
||||||
bind:value={password}
|
|
||||||
required
|
|
||||||
minlength="6"
|
|
||||||
class="w-full rounded border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="w-full rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{isLoading ? 'Creating Account...' : 'Sign Up'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p class="mt-4 text-center text-sm text-gray-600">
|
|
||||||
Already have an account?
|
|
||||||
<a href="/login" class="font-medium text-blue-600 hover:underline">Log In</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|