Svelte to Quasar

This commit is contained in:
Mohamad.Elsena 2025-05-08 15:02:09 +02:00
parent e3024ccd07
commit 7b2c5c9ebd
112 changed files with 14766 additions and 6490 deletions

7
fe/.editorconfig Normal file
View 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
View File

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

View File

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

View File

@ -1,6 +0,0 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb

View File

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

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

15
fe/.vscode/extensions.json vendored Normal file
View 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
View 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"
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.5 KiB

235
fe/quasar.config.ts Normal file
View 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: [],
},
};
});

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

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

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

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

7
fe/src/App.vue Normal file
View File

@ -0,0 +1,7 @@
<template>
<router-view />
</template>
<script setup lang="ts">
//
</script>

View File

@ -1,2 +0,0 @@
@import 'tailwindcss';
@plugin '@tailwindcss/forms';

13
fe/src/app.d.ts vendored
View File

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

View File

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

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

70
fe/src/boot/axios.ts Normal file
View 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
View 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);
});

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

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

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

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

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

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

@ -0,0 +1,5 @@
import enUS from './en-US';
export default {
'en-US': enUS
};

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@ -1,4 +0,0 @@
export interface Token {
access_token: string;
token_type: string;
}

View File

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

View File

@ -1,4 +0,0 @@
export interface HealthStatus {
status: string;
database: string;
}

View File

@ -1,5 +0,0 @@
export interface InviteCodePublic {
code: string;
expires_at: string; // Date as string from JSON
group_id: number;
}

View File

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

View File

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

View File

@ -1,3 +0,0 @@
export interface Message {
detail: string;
}

View File

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

View File

@ -1,6 +0,0 @@
export interface UserPublic {
id: number;
email: string;
name?: string | null;
created_at: string;
}

View File

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

View File

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More