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
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
# Quasar core related directories
|
||||
.quasar
|
||||
/dist
|
||||
/quasar.config.*.temporary.compiled*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
# Cordova related directories and files
|
||||
/src-cordova/node_modules
|
||||
/src-cordova/platforms
|
||||
/src-cordova/plugins
|
||||
/src-cordova/www
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
# Capacitor related directories and files
|
||||
/src-capacitor/www
|
||||
/src-capacitor/node_modules
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
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).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
mitlist pwa
|
||||
|
||||
## Install the dependencies
|
||||
```bash
|
||||
# create a new project in the current directory
|
||||
npx sv create
|
||||
|
||||
# create a new project in my-app
|
||||
npx sv create my-app
|
||||
yarn
|
||||
# or
|
||||
npm install
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
### Start the app in development mode (hot-code reloading, error reporting, etc.)
|
||||
```bash
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
quasar dev
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
### Lint the files
|
||||
```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",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check . && eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.2.11",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"idb": "^8.0.2"
|
||||
}
|
||||
"name": "mitlist",
|
||||
"version": "0.0.1",
|
||||
"description": "mitlist pwa",
|
||||
"productName": "mitlist",
|
||||
"author": "Mohamad <Mohamad.elsena@edvring.de>",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint -c ./eslint.config.js \"./src*/**/*.{ts,js,cjs,mjs,vue}\"",
|
||||
"format": "prettier --write \"**/*.{js,ts,vue,scss,html,md,json}\" --ignore-path .gitignore",
|
||||
"test": "echo \"No test specified\" && exit 0",
|
||||
"dev": "quasar dev",
|
||||
"build": "quasar build",
|
||||
"postinstall": "quasar prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"@quasar/extras": "^1.16.4",
|
||||
"axios": "^1.2.1",
|
||||
"pinia": "^3.0.1",
|
||||
"quasar": "^2.16.0",
|
||||
"register-service-worker": "^1.7.2",
|
||||
"vue": "^3.4.18",
|
||||
"vue-i18n": "^11.0.0",
|
||||
"vue-router": "^4.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.14.0",
|
||||
"@intlify/unplugin-vue-i18n": "^4.0.0",
|
||||
"@quasar/app-vite": "^2.1.0",
|
||||
"@types/node": "^20.5.9",
|
||||
"@vue/eslint-config-prettier": "^10.1.0",
|
||||
"@vue/eslint-config-typescript": "^14.4.0",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-plugin-vue": "^9.30.0",
|
||||
"globals": "^15.12.0",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "~5.5.3",
|
||||
"vite-plugin-checker": "^0.9.0",
|
||||
"vue-tsc": "^2.0.29",
|
||||
"workbox-build": "^7.3.0",
|
||||
"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>
|