weeee💃
This commit is contained in:
commit
442e503526
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
6
.prettierignore
Normal file
6
.prettierignore
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# Package Managers
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
|
bun.lock
|
||||||
|
bun.lockb
|
15
.prettierrc
Normal file
15
.prettierrc
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"useTabs": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.svelte",
|
||||||
|
"options": {
|
||||||
|
"parser": "svelte"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
38
README.md
Normal file
38
README.md
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# sv
|
||||||
|
|
||||||
|
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!
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# create a new project in the current directory
|
||||||
|
npx sv create
|
||||||
|
|
||||||
|
# create a new project in my-app
|
||||||
|
npx sv create my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# or start the server and open the app in a new browser tab
|
||||||
|
npm run dev -- --open
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To create a production version of your app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
0
database.types.ts
Normal file
0
database.types.ts
Normal file
37
eslint.config.js
Normal file
37
eslint.config.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import prettier from 'eslint-config-prettier';
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import { includeIgnoreFile } from '@eslint/compat';
|
||||||
|
import svelte from 'eslint-plugin-svelte';
|
||||||
|
import globals from 'globals';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import ts from 'typescript-eslint';
|
||||||
|
import svelteConfig from './svelte.config.js';
|
||||||
|
|
||||||
|
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
||||||
|
|
||||||
|
export default ts.config(
|
||||||
|
includeIgnoreFile(gitignorePath),
|
||||||
|
js.configs.recommended,
|
||||||
|
...ts.configs.recommended,
|
||||||
|
...svelte.configs.recommended,
|
||||||
|
prettier,
|
||||||
|
...svelte.configs.prettier,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: { ...globals.browser, ...globals.node }
|
||||||
|
},
|
||||||
|
rules: { 'no-undef': 'off' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||||
|
ignores: ['eslint.config.js', 'svelte.config.js'],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
extraFileExtensions: ['.svelte'],
|
||||||
|
parser: ts.parser,
|
||||||
|
svelteConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
4016
package-lock.json
generated
Normal file
4016
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
package.json
Normal file
38
package.json
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "doeit",
|
||||||
|
"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": {
|
||||||
|
"@eslint/compat": "^1.2.5",
|
||||||
|
"@eslint/js": "^9.18.0",
|
||||||
|
"@sveltejs/adapter-node": "^5.2.12",
|
||||||
|
"@sveltejs/kit": "^2.16.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
|
"eslint": "^9.18.0",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-svelte": "^3.0.0",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"prettier-plugin-svelte": "^3.3.3",
|
||||||
|
"supabase": "^2.20.5",
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"svelte-check": "^4.0.0",
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"typescript-eslint": "^8.20.0",
|
||||||
|
"vite": "^6.2.5"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/supabase-js": "^2.49.4"
|
||||||
|
}
|
||||||
|
}
|
1467
src/app.css
Normal file
1467
src/app.css
Normal file
File diff suppressed because it is too large
Load Diff
13
src/app.d.ts
vendored
Normal file
13
src/app.d.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// 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 {};
|
12
src/app.html
Normal file
12
src/app.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
|
<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>
|
139
src/lib/components/AppShell.svelte
Normal file
139
src/lib/components/AppShell.svelte
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
<!-- AppShell.svelte -->
|
||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
export let title = 'My App';
|
||||||
|
|
||||||
|
// Navigation items
|
||||||
|
const navItems = [
|
||||||
|
{ href: '/', label: 'Home' },
|
||||||
|
{ href: '/about', label: 'About' },
|
||||||
|
{ href: '/contact', label: 'Contact' }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="app-shell">
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="container flex justify-between items-center">
|
||||||
|
<h1 class="app-title">{title}</h1>
|
||||||
|
<nav class="main-nav">
|
||||||
|
<ul class="nav-list">
|
||||||
|
{#each navItems as item}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href={item.href} class="nav-link" class:active={$page.url.pathname === item.href}>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="app-main">
|
||||||
|
<div class="container">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="app-footer">
|
||||||
|
<div class="container">
|
||||||
|
<p class="text-center">© {new Date().getFullYear()} {title}</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.app-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
background-color: var(--primary);
|
||||||
|
border-bottom: var(--border);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
padding: 1rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: var(--dark);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: var(--border);
|
||||||
|
background-color: var(--light);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: all var(--transition-speed) var(--transition-ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
background-color: var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active {
|
||||||
|
background-color: var(--secondary);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer {
|
||||||
|
background-color: var(--light);
|
||||||
|
border-top: var(--border);
|
||||||
|
padding: 1rem;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.nav-list {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background-color: var(--light);
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: var(--border);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list.open {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
0
src/lib/components/Dashboard.svelte
Normal file
0
src/lib/components/Dashboard.svelte
Normal file
160
src/lib/components/LoginPage.svelte
Normal file
160
src/lib/components/LoginPage.svelte
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
// --- Props ---
|
||||||
|
/** Is an external login process currently running? */
|
||||||
|
export let isLoading: boolean = false;
|
||||||
|
/** Error message received from the server/auth process */
|
||||||
|
export let serverError: string | null = null;
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
let email = '';
|
||||||
|
let password = '';
|
||||||
|
|
||||||
|
// --- Events ---
|
||||||
|
// Define the event type for better type safety
|
||||||
|
const dispatch = createEventDispatcher<{ login: { email: string; password: string } }>();
|
||||||
|
|
||||||
|
// --- Handlers ---
|
||||||
|
function handleLoginAttempt() {
|
||||||
|
// Basic check (can add more complex client-side validation if needed)
|
||||||
|
if (!email || !password) {
|
||||||
|
// Relying on HTML5 required attribute for now
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Don't proceed if already loading
|
||||||
|
if (isLoading) return;
|
||||||
|
|
||||||
|
// Emit the event with credentials
|
||||||
|
dispatch('login', { email, password });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="container login-container">
|
||||||
|
<div class="card login-card">
|
||||||
|
<form on:submit|preventDefault={handleLoginAttempt}>
|
||||||
|
<div class="card-header text-center">
|
||||||
|
<h2 id="login-heading" class="mb-0" style="border:none; padding:0;">Welcome Back!</h2>
|
||||||
|
<p class="opacity-80 text-sm">Log in to manage your household</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
{#if serverError}
|
||||||
|
<div class="alert alert-error mb-3" role="alert">
|
||||||
|
<div class="alert-content">
|
||||||
|
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-alert-triangle" /></svg>
|
||||||
|
{serverError}
|
||||||
|
</div>
|
||||||
|
<!-- Optional: Add close button if alerts are made dismissible -->
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email" class="form-label">Email Address</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
class="form-input"
|
||||||
|
bind:value={email}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
required
|
||||||
|
aria-describedby={serverError ? 'login-error' : undefined}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password" class="form-label">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
class="form-input"
|
||||||
|
bind:value={password}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
aria-describedby={serverError ? 'login-error' : undefined}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group text-right text-sm">
|
||||||
|
<a href="#" class="forgot-password-link">Forgot Password?</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer">
|
||||||
|
<button type="submit" class="btn w-full" disabled={isLoading}>
|
||||||
|
{#if isLoading}
|
||||||
|
<span class="spinner-dots-sm" role="status"
|
||||||
|
><span /><span /><span /><span class="sr-only">Loading...</span></span
|
||||||
|
>
|
||||||
|
Signing In...
|
||||||
|
{:else}
|
||||||
|
Log In
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="mt-3 text-center text-sm signup-prompt">
|
||||||
|
<span>Don't have an account?</span>
|
||||||
|
<a href="#" class="signup-link ml-1">Sign Up</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.login-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start; /* Align to top */
|
||||||
|
min-height: 80vh; /* Adjust as needed */
|
||||||
|
padding-top: 5vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 450px; /* Limit width */
|
||||||
|
/* Ensure card background is solid */
|
||||||
|
background-color: var(--light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h2::after {
|
||||||
|
display: none; /* Remove underline from default heading */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle link styling */
|
||||||
|
.forgot-password-link,
|
||||||
|
.signup-link {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: bold;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: border-color var(--transition-speed-fast) ease-out;
|
||||||
|
}
|
||||||
|
.forgot-password-link:hover,
|
||||||
|
.signup-link:hover {
|
||||||
|
color: var(--dark);
|
||||||
|
border-bottom-color: var(--secondary);
|
||||||
|
}
|
||||||
|
.forgot-password-link:focus-visible,
|
||||||
|
.signup-link:focus-visible {
|
||||||
|
outline: var(--focus-outline);
|
||||||
|
outline-offset: var(--focus-outline-offset);
|
||||||
|
border-bottom-color: transparent; /* Avoid double underline */
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-prompt {
|
||||||
|
color: var(--dark);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure button content centers correctly with spinner */
|
||||||
|
.btn {
|
||||||
|
position: relative; /* Needed if spinner used absolute positioning */
|
||||||
|
}
|
||||||
|
.btn .spinner-dots-sm {
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 0.5em;
|
||||||
|
}
|
||||||
|
</style>
|
1
src/lib/index.ts
Normal file
1
src/lib/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
151
src/lib/stores.ts
Normal file
151
src/lib/stores.ts
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
// src/lib/stores.ts
|
||||||
|
import { writable, type Writable } from 'svelte/store';
|
||||||
|
|
||||||
|
// --- Re-declare or Import Types ---
|
||||||
|
// (Ensure these match the definitions in your components)
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
initials: string;
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShoppingItem {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
completed: boolean;
|
||||||
|
// isSwiped is view-specific, likely doesn't belong in the store model
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chore model for store (without isSwiped)
|
||||||
|
interface Chore {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
completed: boolean;
|
||||||
|
assignedTo: User | null;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
// Helper for storing chores (maps User object to ID)
|
||||||
|
interface StorableChore extends Omit<Chore, 'assignedTo'> {
|
||||||
|
assignedToId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group model for store
|
||||||
|
interface Group {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
members: User[];
|
||||||
|
}
|
||||||
|
// Helper for storing groups (maps User array to ID array)
|
||||||
|
interface StorableGroup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
memberIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper: Get data from LocalStorage ---
|
||||||
|
function loadFromLocalStorage<T>(
|
||||||
|
key: string,
|
||||||
|
defaultValue: T,
|
||||||
|
reviver?: (key: string, value: unknown) => unknown
|
||||||
|
): T {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return defaultValue; // Cannot access localStorage on server
|
||||||
|
}
|
||||||
|
const saved = localStorage.getItem(key);
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(saved, reviver);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to parse ${key} from localStorage`, e);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper: Persist store data to LocalStorage ---
|
||||||
|
function persistStore<T>(
|
||||||
|
store: Writable<T>,
|
||||||
|
key: string,
|
||||||
|
replacer?: (key: string, value: unknown) => unknown
|
||||||
|
) {
|
||||||
|
if (typeof window === 'undefined') return; // Skip persistence on server
|
||||||
|
|
||||||
|
// Update localStorage on store change
|
||||||
|
store.subscribe((value) => {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value, replacer));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Static User Data (Should ideally come from auth/API) ---
|
||||||
|
// (Keep this consistent with other components or centralize it)
|
||||||
|
const availableUsers: User[] = [
|
||||||
|
{ id: 'user1', initials: 'JD', name: 'Jane Doe', color: '#ffb26b' },
|
||||||
|
{ id: 'user2', initials: 'AP', name: 'Alex P.', color: '#a0e7a0' },
|
||||||
|
{ id: 'user3', initials: 'MS', name: 'Mike Smith', color: '#54c7ff' },
|
||||||
|
{ id: 'user4', initials: 'LS', name: 'Lisa Simpson', color: '#ffd56b' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Stores ---
|
||||||
|
|
||||||
|
// Shopping List Store
|
||||||
|
const initialShoppingItems = loadFromLocalStorage<ShoppingItem[]>('shoppingListItems', []);
|
||||||
|
export const shoppingItemsStore = writable<ShoppingItem[]>(initialShoppingItems);
|
||||||
|
persistStore(shoppingItemsStore, 'shoppingListItems');
|
||||||
|
|
||||||
|
// Chores Store (with revival logic)
|
||||||
|
const loadedStorableChores = loadFromLocalStorage<StorableChore[]>('choresList', []);
|
||||||
|
const initialChores = loadedStorableChores.map((sc) => ({
|
||||||
|
id: sc.id,
|
||||||
|
text: sc.text,
|
||||||
|
completed: sc.completed,
|
||||||
|
category: sc.category,
|
||||||
|
assignedTo: sc.assignedToId ? availableUsers.find((u) => u.id === sc.assignedToId) || null : null
|
||||||
|
// isSwiped is intentionally omitted from the store model
|
||||||
|
}));
|
||||||
|
export const choresStore = writable<Chore[]>(initialChores);
|
||||||
|
// Persist helper (maps User back to ID)
|
||||||
|
persistStore(choresStore, 'choresList', (key, value) => {
|
||||||
|
if (key === 'assignedTo' && value) {
|
||||||
|
return (value as User).id; // Store only the ID
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
// Need custom mapping logic when saving, standard replacer isn't enough.
|
||||||
|
// Better approach for complex objects: manually stringify in subscribe
|
||||||
|
choresStore.subscribe((chores) => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const storableChores = chores.map((c) => ({
|
||||||
|
...c,
|
||||||
|
assignedToId: c.assignedTo ? c.assignedTo.id : null,
|
||||||
|
assignedTo: undefined // Remove object before saving
|
||||||
|
}));
|
||||||
|
localStorage.setItem('choresList', JSON.stringify(storableChores));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Groups Store (with revival logic)
|
||||||
|
const loadedStorableGroups = loadFromLocalStorage<StorableGroup[]>('groupsList', []);
|
||||||
|
const initialGroups = loadedStorableGroups.map((sg) => ({
|
||||||
|
id: sg.id,
|
||||||
|
name: sg.name,
|
||||||
|
members: sg.memberIds
|
||||||
|
.map((id) => availableUsers.find((u) => u.id === id))
|
||||||
|
.filter((user) => user !== undefined) as User[]
|
||||||
|
}));
|
||||||
|
export const groupsStore = writable<Group[]>(initialGroups);
|
||||||
|
// Persist helper (maps User array back to ID array)
|
||||||
|
groupsStore.subscribe((groups) => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const storableGroups = groups.map((g) => ({
|
||||||
|
id: g.id,
|
||||||
|
name: g.name,
|
||||||
|
memberIds: g.members.map((m) => m.id)
|
||||||
|
}));
|
||||||
|
localStorage.setItem('groupsList', JSON.stringify(storableGroups));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// You might also want a store for the current user if you have authentication
|
||||||
|
// export const currentUserStore = writable<User | null>(null);
|
12
src/lib/supabaseClient.ts
Normal file
12
src/lib/supabaseClient.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
// Remove the Database import: import type { Database } from './database.types'
|
||||||
|
|
||||||
|
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||||
|
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
if (!supabaseUrl || !supabaseAnonKey) {
|
||||||
|
throw new Error('Supabase URL and Anon Key must be provided in environment variables.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize without the Database generic
|
||||||
|
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
378
src/lib/supabaseService.ts
Normal file
378
src/lib/supabaseService.ts
Normal file
@ -0,0 +1,378 @@
|
|||||||
|
import { supabase } from './supabaseClient';
|
||||||
|
|
||||||
|
// --- Define aliases for easier use ---
|
||||||
|
type DbShoppingItemInsert = Database['public']['Tables']['shopping_items']['Insert'];
|
||||||
|
type DbShoppingItemUpdate = Database['public']['Tables']['shopping_items']['Update'];
|
||||||
|
type DbShoppingItem = Database['public']['Tables']['shopping_items']['Row'];
|
||||||
|
|
||||||
|
type DbChoreInsert = Database['public']['Tables']['chores']['Insert'];
|
||||||
|
type DbChoreUpdate = Database['public']['Tables']['chores']['Update'];
|
||||||
|
type DbChore = Database['public']['Tables']['chores']['Row'];
|
||||||
|
type DbProfile = Database['public']['Tables']['profiles']['Row']; // Assuming profiles table
|
||||||
|
|
||||||
|
type DbGroupInsert = Database['public']['Tables']['groups']['Insert'];
|
||||||
|
type DbGroupUpdate = Database['public']['Tables']['groups']['Update'];
|
||||||
|
type DbGroup = Database['public']['Tables']['groups']['Row'];
|
||||||
|
type DbGroupMemberInsert = Database['public']['Tables']['group_members']['Insert'];
|
||||||
|
|
||||||
|
// Combined type for Chore with Profile data
|
||||||
|
export interface ChoreWithProfile extends Omit<DbChore, 'assigned_to_user_id'> {
|
||||||
|
assigned_to: DbProfile | null; // Replace FK with nested profile object
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combined type for Group with Members (including profile data)
|
||||||
|
export interface GroupWithMembers extends DbGroup {
|
||||||
|
members: DbProfile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Error Handling Helper ===
|
||||||
|
async function handleSupabaseResponse<T>(response: { data: T | null; error: any }): Promise<T> {
|
||||||
|
if (response.error) {
|
||||||
|
console.error('Supabase Error:', response.error.message);
|
||||||
|
throw new Error(response.error.message); // Re-throw for service layer handling
|
||||||
|
}
|
||||||
|
if (response.data === null) {
|
||||||
|
// This might happen on single() select if not found, or other cases
|
||||||
|
throw new Error('No data returned from Supabase.');
|
||||||
|
}
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
async function handleSupabaseVoidResponse(response: { error: any }): Promise<void> {
|
||||||
|
if (response.error) {
|
||||||
|
console.error('Supabase Error:', response.error.message);
|
||||||
|
throw new Error(response.error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Shopping List Service ===
|
||||||
|
|
||||||
|
export const shoppingListService = {
|
||||||
|
async getAll(): Promise<DbShoppingItem[]> {
|
||||||
|
// Assumes RLS handles user filtering if needed
|
||||||
|
const response = await supabase
|
||||||
|
.from('shopping_items')
|
||||||
|
.select('*')
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
return handleSupabaseResponse(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async add(itemData: Pick<DbShoppingItemInsert, 'text'>): Promise<DbShoppingItem> {
|
||||||
|
// Add user_id here if your schema requires it, get from auth state
|
||||||
|
const response = await supabase
|
||||||
|
.from('shopping_items')
|
||||||
|
.insert({ text: itemData.text /*, user_id: userId */ })
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
return handleSupabaseResponse(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id: string, updates: DbShoppingItemUpdate): Promise<DbShoppingItem> {
|
||||||
|
const response = await supabase
|
||||||
|
.from('shopping_items')
|
||||||
|
.update(updates)
|
||||||
|
.eq('id', id)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
return handleSupabaseResponse(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async toggleComplete(id: string, completed: boolean): Promise<DbShoppingItem> {
|
||||||
|
return this.update(id, { completed });
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
const response = await supabase.from('shopping_items').delete().eq('id', id);
|
||||||
|
return handleSupabaseVoidResponse(response);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Chores Service ===
|
||||||
|
|
||||||
|
export const choreService = {
|
||||||
|
async getAll(): Promise<ChoreWithProfile[]> {
|
||||||
|
// Fetch chores and join the assigned profile data
|
||||||
|
const response = await supabase
|
||||||
|
.from('chores')
|
||||||
|
.select(
|
||||||
|
`
|
||||||
|
*,
|
||||||
|
assigned_to:profiles!assigned_to_user_id (*)
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
// Need to handle the structured response
|
||||||
|
if (response.error) {
|
||||||
|
console.error('Supabase Error:', response.error.message);
|
||||||
|
throw new Error(response.error.message);
|
||||||
|
}
|
||||||
|
// Type assertion needed because Supabase type gen might not perfectly match nested select
|
||||||
|
return ((response.data as any[]) || []) as ChoreWithProfile[];
|
||||||
|
},
|
||||||
|
|
||||||
|
async add(choreData: {
|
||||||
|
text: string;
|
||||||
|
category: string;
|
||||||
|
assignedToUserId: string | null;
|
||||||
|
}): Promise<ChoreWithProfile> {
|
||||||
|
const response = await supabase
|
||||||
|
.from('chores')
|
||||||
|
.insert({
|
||||||
|
text: choreData.text,
|
||||||
|
category: choreData.category,
|
||||||
|
assigned_to_user_id: choreData.assignedToUserId
|
||||||
|
})
|
||||||
|
.select(`*, assigned_to:profiles!assigned_to_user_id (*)`)
|
||||||
|
.single();
|
||||||
|
// Type assertion needed
|
||||||
|
return handleSupabaseResponse(response as any) as ChoreWithProfile;
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
updates: {
|
||||||
|
text?: string;
|
||||||
|
category?: string;
|
||||||
|
assignedToUserId?: string | null;
|
||||||
|
completed?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<ChoreWithProfile> {
|
||||||
|
// Prepare update object, mapping assignedToUserId
|
||||||
|
const dbUpdates: DbChoreUpdate = {
|
||||||
|
...(updates.text && { text: updates.text }),
|
||||||
|
...(updates.category && { category: updates.category }),
|
||||||
|
...(updates.completed !== undefined && { completed: updates.completed }),
|
||||||
|
// Handle null explicitly for foreign keys if needed
|
||||||
|
assigned_to_user_id:
|
||||||
|
updates.assignedToUserId === undefined ? undefined : updates.assignedToUserId
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await supabase
|
||||||
|
.from('chores')
|
||||||
|
.update(dbUpdates)
|
||||||
|
.eq('id', id)
|
||||||
|
.select(`*, assigned_to:profiles!assigned_to_user_id (*)`)
|
||||||
|
.single();
|
||||||
|
// Type assertion needed
|
||||||
|
return handleSupabaseResponse(response as any) as ChoreWithProfile;
|
||||||
|
},
|
||||||
|
|
||||||
|
async toggleComplete(id: string, completed: boolean): Promise<ChoreWithProfile> {
|
||||||
|
return this.update(id, { completed });
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
const response = await supabase.from('chores').delete().eq('id', id);
|
||||||
|
return handleSupabaseVoidResponse(response);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Groups Service ===
|
||||||
|
|
||||||
|
export const groupService = {
|
||||||
|
async getAll(): Promise<GroupWithMembers[]> {
|
||||||
|
// Fetch groups and join members via the join table, then join profiles
|
||||||
|
const response = await supabase
|
||||||
|
.from('groups')
|
||||||
|
.select(
|
||||||
|
`
|
||||||
|
*,
|
||||||
|
group_members (
|
||||||
|
user_id,
|
||||||
|
profiles (*)
|
||||||
|
)
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.order('name');
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
console.error('Supabase Error:', response.error.message);
|
||||||
|
throw new Error(response.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manually map the nested structure to the desired GroupWithMembers[] format
|
||||||
|
const groups = (response.data || []).map((group: any) => ({
|
||||||
|
...group,
|
||||||
|
members: group.group_members // Access nested data
|
||||||
|
.map((gm: any) => gm.profiles) // Extract the profile from each member entry
|
||||||
|
.filter((profile: any) => profile !== null) // Filter out potential null profiles if data inconsistent
|
||||||
|
}));
|
||||||
|
|
||||||
|
return groups as GroupWithMembers[];
|
||||||
|
},
|
||||||
|
|
||||||
|
// Adding a group with members requires multiple steps
|
||||||
|
async add(groupData: { name: string; memberIds: string[] }): Promise<GroupWithMembers> {
|
||||||
|
// 1. Insert the group
|
||||||
|
const groupResponse = await supabase
|
||||||
|
.from('groups')
|
||||||
|
.insert({ name: groupData.name })
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
const newGroup = await handleSupabaseResponse(groupResponse);
|
||||||
|
|
||||||
|
// 2. If successful and members exist, insert into join table
|
||||||
|
if (newGroup && groupData.memberIds.length > 0) {
|
||||||
|
const membersToInsert: DbGroupMemberInsert[] = groupData.memberIds.map((userId) => ({
|
||||||
|
group_id: newGroup.id,
|
||||||
|
user_id: userId
|
||||||
|
}));
|
||||||
|
const membersResponse = await supabase.from('group_members').insert(membersToInsert);
|
||||||
|
// Check for errors on member insert, but proceed to return group anyway for now
|
||||||
|
if (membersResponse.error) {
|
||||||
|
console.error('Supabase Error adding members:', membersResponse.error.message);
|
||||||
|
// Decide how to handle partial failure: throw error? log and continue?
|
||||||
|
// For simplicity, we'll continue and return the group, members might be incomplete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fetch the newly created group with its members to return consistent type
|
||||||
|
return this.getById(newGroup.id); // Assumes getById exists (implement below)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Updating involves updating name and managing member changes (add/remove)
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
updates: { name?: string; memberIds?: string[] }
|
||||||
|
): Promise<GroupWithMembers> {
|
||||||
|
// 1. Update group name if provided
|
||||||
|
if (updates.name) {
|
||||||
|
const nameUpdateResponse = await supabase
|
||||||
|
.from('groups')
|
||||||
|
.update({ name: updates.name })
|
||||||
|
.eq('id', id);
|
||||||
|
await handleSupabaseVoidResponse(nameUpdateResponse); // Check for error
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Handle member updates if provided
|
||||||
|
if (updates.memberIds) {
|
||||||
|
const newMemberIds = updates.memberIds;
|
||||||
|
// Get current members
|
||||||
|
const currentMembersResponse = await supabase
|
||||||
|
.from('group_members')
|
||||||
|
.select('user_id')
|
||||||
|
.eq('group_id', id);
|
||||||
|
const currentMemberData = await handleSupabaseResponse(currentMembersResponse);
|
||||||
|
const currentMemberIds = currentMemberData.map((m) => m.user_id);
|
||||||
|
|
||||||
|
// Calculate differences
|
||||||
|
const membersToAdd = newMemberIds.filter((userId) => !currentMemberIds.includes(userId));
|
||||||
|
const membersToRemove = currentMemberIds.filter((userId) => !newMemberIds.includes(userId));
|
||||||
|
|
||||||
|
// Add new members
|
||||||
|
if (membersToAdd.length > 0) {
|
||||||
|
const membersToInsert: DbGroupMemberInsert[] = membersToAdd.map((userId) => ({
|
||||||
|
group_id: id,
|
||||||
|
user_id: userId
|
||||||
|
}));
|
||||||
|
const addResponse = await supabase.from('group_members').insert(membersToInsert);
|
||||||
|
await handleSupabaseVoidResponse(addResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove old members
|
||||||
|
if (membersToRemove.length > 0) {
|
||||||
|
const removeResponse = await supabase
|
||||||
|
.from('group_members')
|
||||||
|
.delete()
|
||||||
|
.eq('group_id', id)
|
||||||
|
.in('user_id', membersToRemove);
|
||||||
|
await handleSupabaseVoidResponse(removeResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fetch the updated group with members to return
|
||||||
|
return this.getById(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
// 1. Delete memberships first (due to foreign key constraints)
|
||||||
|
const membersDeleteResponse = await supabase.from('group_members').delete().eq('group_id', id);
|
||||||
|
await handleSupabaseVoidResponse(membersDeleteResponse); // Check error
|
||||||
|
|
||||||
|
// 2. Delete the group itself
|
||||||
|
const groupDeleteResponse = await supabase.from('groups').delete().eq('id', id);
|
||||||
|
await handleSupabaseVoidResponse(groupDeleteResponse); // Check error
|
||||||
|
},
|
||||||
|
|
||||||
|
// Helper to get a single group with members (used after add/update)
|
||||||
|
async getById(id: string): Promise<GroupWithMembers> {
|
||||||
|
const response = await supabase
|
||||||
|
.from('groups')
|
||||||
|
.select(`*, group_members (user_id, profiles (*))`)
|
||||||
|
.eq('id', id)
|
||||||
|
.single(); // Expect only one
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
console.error('Supabase Error:', response.error.message);
|
||||||
|
throw new Error(response.error.message);
|
||||||
|
}
|
||||||
|
if (!response.data) {
|
||||||
|
throw new Error(`Group with id ${id} not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const group: any = response.data;
|
||||||
|
const result: GroupWithMembers = {
|
||||||
|
...group,
|
||||||
|
members: group.group_members
|
||||||
|
.map((gm: any) => gm.profiles)
|
||||||
|
.filter((profile: any) => profile !== null)
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Profile/User Service ===
|
||||||
|
|
||||||
|
export const profileService = {
|
||||||
|
// Gets all profiles - useful for assigning users
|
||||||
|
async getAll(): Promise<DbProfile[]> {
|
||||||
|
const response = await supabase.from('profiles').select('*').order('name');
|
||||||
|
return handleSupabaseResponse(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Gets the profile for the currently logged-in user
|
||||||
|
async getCurrentUserProfile(): Promise<DbProfile | null> {
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
error: authError
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (authError) {
|
||||||
|
console.error('Auth Error getting user:', authError.message);
|
||||||
|
return null; // Not logged in or error
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
return null; // Should not happen if error is null, but check anyway
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await supabase.from('profiles').select('*').eq('id', user.id).single();
|
||||||
|
|
||||||
|
// Don't throw error if profile not found, just return null
|
||||||
|
if (response.error && response.error.code !== 'PGRST116') {
|
||||||
|
// PGRST116 = Range not satisfiable (expected for single() with no result)
|
||||||
|
console.error('Supabase Error fetching profile:', response.error.message);
|
||||||
|
throw new Error(response.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Add function to update profile if needed (name, initials, color)
|
||||||
|
async updateCurrentProfile(
|
||||||
|
updates: Partial<Pick<DbProfile, 'name' | 'initials' | 'color'>>
|
||||||
|
): Promise<DbProfile> {
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
error: authError
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (authError || !user) throw new Error('User not authenticated.');
|
||||||
|
|
||||||
|
const response = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.update(updates)
|
||||||
|
.eq('id', user.id)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
return handleSupabaseResponse(response);
|
||||||
|
}
|
||||||
|
};
|
183
src/lib/types.ts
Normal file
183
src/lib/types.ts
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
// src/lib/components/types.ts
|
||||||
|
|
||||||
|
// --- Enum Type Definitions (Inferred) ---
|
||||||
|
// Replace with your actual enum values if different
|
||||||
|
|
||||||
|
export type ChoreStatus = 'pending' | 'completed' | 'skipped' | 'overdue'; // Example values
|
||||||
|
export type ChoreFrequency = 'daily' | 'weekly' | 'monthly' | 'yearly'; // Example values
|
||||||
|
export type SplitType = 'equal' | 'exact' | 'percentage'; // Example values
|
||||||
|
export type SettlementActivityType = 'payment' | 'refund' | 'adjustment' | 'marked_paid'; // Example values
|
||||||
|
export type UserRole = 'owner' | 'admin' | 'member'; // Example values
|
||||||
|
|
||||||
|
// --- Table Interface Definitions ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corresponds to the 'public.chore_assignments' table
|
||||||
|
*/
|
||||||
|
export interface ChoreAssignment {
|
||||||
|
id: number;
|
||||||
|
chore_id: number;
|
||||||
|
assigned_user_id: string | null; // uuid
|
||||||
|
due_date: string; // date (ISO 8601 format: YYYY-MM-DD)
|
||||||
|
completion_status: ChoreStatus;
|
||||||
|
completed_at: string | null; // timestamp with time zone (ISO 8601 format)
|
||||||
|
assigned_at: string; // timestamp with time zone (ISO 8601 format)
|
||||||
|
updated_at: string; // timestamp with time zone (ISO 8601 format)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corresponds to the 'public.chores' table
|
||||||
|
*/
|
||||||
|
export interface Chore {
|
||||||
|
id: number;
|
||||||
|
group_id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
created_by_id: string; // uuid
|
||||||
|
created_at: string; // timestamp with time zone (ISO 8601 format)
|
||||||
|
updated_at: string; // timestamp with time zone (ISO 8601 format)
|
||||||
|
is_recurring: boolean;
|
||||||
|
frequency_type: ChoreFrequency | null;
|
||||||
|
frequency_value: number | null;
|
||||||
|
next_due_date: string | null; // date (ISO 8601 format: YYYY-MM-DD)
|
||||||
|
last_completed_at: string | null; // timestamp with time zone (ISO 8601 format)
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corresponds to the 'public.expense_records' table
|
||||||
|
*/
|
||||||
|
export interface ExpenseRecord {
|
||||||
|
id: number;
|
||||||
|
list_id: number;
|
||||||
|
calculated_at: string; // timestamp with time zone (ISO 8601 format)
|
||||||
|
calculated_by_id: string; // uuid
|
||||||
|
total_amount: number; // numeric(10, 2) - use number for simplicity, consider string for precision if needed
|
||||||
|
participants: string[]; // uuid[]
|
||||||
|
split_type: SplitType;
|
||||||
|
is_settled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corresponds to the 'public.expense_shares' table
|
||||||
|
*/
|
||||||
|
export interface ExpenseShare {
|
||||||
|
id: number;
|
||||||
|
expense_record_id: number;
|
||||||
|
user_id: string; // uuid
|
||||||
|
amount_owed: number; // numeric(10, 2) - use number for simplicity, consider string for precision if needed
|
||||||
|
is_paid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corresponds to the 'public.groups' table
|
||||||
|
*/
|
||||||
|
export interface Group {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
created_by_id: string; // uuid
|
||||||
|
created_at: string; // timestamp with time zone (ISO 8601 format)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corresponds to the 'public.invites' table
|
||||||
|
*/
|
||||||
|
export interface Invite {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
group_id: number;
|
||||||
|
created_by_id: string; // uuid
|
||||||
|
created_at: string; // timestamp with time zone (ISO 8601 format)
|
||||||
|
expires_at: string; // timestamp with time zone (ISO 8601 format)
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corresponds to the 'public.items' table
|
||||||
|
*/
|
||||||
|
export interface Item {
|
||||||
|
id: number;
|
||||||
|
list_id: number;
|
||||||
|
name: string;
|
||||||
|
quantity: string | null;
|
||||||
|
is_complete: boolean;
|
||||||
|
price: number | null; // numeric(10, 2) - use number for simplicity, consider string for precision if needed
|
||||||
|
price_added_by_id: string | null; // uuid
|
||||||
|
price_added_at: string | null; // timestamp with time zone (ISO 8601 format)
|
||||||
|
added_by_id: string; // uuid
|
||||||
|
completed_by_id: string | null; // uuid
|
||||||
|
created_at: string; // timestamp with time zone (ISO 8601 format)
|
||||||
|
updated_at: string; // timestamp with time zone (ISO 8601 format)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corresponds to the 'public.lists' table
|
||||||
|
* Added optional 'items' for card display example
|
||||||
|
*/
|
||||||
|
export interface List {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
created_by_id: string; // uuid
|
||||||
|
group_id: number | null;
|
||||||
|
is_complete: boolean;
|
||||||
|
created_at: string; // timestamp with time zone (ISO 8601 format)
|
||||||
|
updated_at: string; // timestamp with time zone (ISO 8601 format)
|
||||||
|
items?: Item[]; // Optional: Include items if needed for card display
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corresponds to the 'public.profiles' table
|
||||||
|
* Often linked to auth.users
|
||||||
|
*/
|
||||||
|
export interface Profile {
|
||||||
|
id: string; // uuid (usually matches auth.users.id)
|
||||||
|
name: string | null;
|
||||||
|
created_at: string; // timestamp with time zone (ISO 8601 format)
|
||||||
|
updated_at: string; // timestamp with time zone (ISO 8601 format)
|
||||||
|
// You might add other profile fields here like avatar_url, etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corresponds to the 'public.settlement_activities' table
|
||||||
|
*/
|
||||||
|
export interface SettlementActivity {
|
||||||
|
id: number;
|
||||||
|
expense_record_id: number;
|
||||||
|
payer_user_id: string; // uuid
|
||||||
|
affected_user_id: string; // uuid
|
||||||
|
activity_type: SettlementActivityType;
|
||||||
|
timestamp: string; // timestamp with time zone (ISO 8601 format)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corresponds to the 'public.user_groups' table
|
||||||
|
* Represents the relationship between users and groups (membership)
|
||||||
|
*/
|
||||||
|
export interface UserGroup {
|
||||||
|
id: number;
|
||||||
|
user_id: string; // uuid
|
||||||
|
group_id: number;
|
||||||
|
role: UserRole;
|
||||||
|
joined_at: string; // timestamp with time zone (ISO 8601 format)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Optional: Combined Types (Example) ---
|
||||||
|
// Sometimes it's useful to have types that include related data
|
||||||
|
|
||||||
|
export interface ChoreAssignmentWithChore extends ChoreAssignment {
|
||||||
|
chores: Chore | null; // Assuming 'chores' is the relationship name in your query
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChoreAssignmentWithDetails extends ChoreAssignment {
|
||||||
|
chores: Chore | null;
|
||||||
|
assigned_user: Profile | null; // Assuming 'profiles' table linked via assigned_user_id
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemWithDetails extends Item {
|
||||||
|
added_by: Profile | null; // Assuming 'profiles' linked via added_by_id
|
||||||
|
completed_by: Profile | null; // Assuming 'profiles' linked via completed_by_id
|
||||||
|
price_added_by: Profile | null; // Assuming 'profiles' linked via price_added_by_id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add other combined types as needed for your specific data fetching patterns
|
11
src/routes/(auth)/+layout.svelte
Normal file
11
src/routes/(auth)/+layout.svelte
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<script>
|
||||||
|
import '../../app.css';
|
||||||
|
import AppShell from '$lib/components/AppShell.svelte';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div></div>
|
||||||
|
<AppShell title="mitlist">
|
||||||
|
{@render children?.()}
|
||||||
|
</AppShell>
|
512
src/routes/(auth)/chores/+page.svelte
Normal file
512
src/routes/(auth)/chores/+page.svelte
Normal file
@ -0,0 +1,512 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
// If types are in a separate file:
|
||||||
|
// import type { Chore, User } from '../types';
|
||||||
|
|
||||||
|
// --- Define Types directly if not using a separate file ---
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
initials: string;
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Chore {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
completed: boolean;
|
||||||
|
assignedTo: User | null;
|
||||||
|
category: string;
|
||||||
|
isSwiped: boolean;
|
||||||
|
}
|
||||||
|
// --- End Type Definitions ---
|
||||||
|
|
||||||
|
// --- Static Data (Example Users & Categories) ---
|
||||||
|
const users: User[] = [
|
||||||
|
{ id: 'user1', initials: 'JD', name: 'Jane Doe', color: '#ffb26b' }, // --secondary
|
||||||
|
{ id: 'user2', initials: 'AP', name: 'Alex P.', color: '#a0e7a0' }, // --success
|
||||||
|
{ id: 'user3', initials: 'MS', name: 'Mike Smith', color: '#54c7ff' } // --secondary-accent
|
||||||
|
];
|
||||||
|
|
||||||
|
const categories: string[] = ['Kitchen', 'Bathroom', 'Living Room', 'Yard', 'Pets', 'General'];
|
||||||
|
|
||||||
|
// --- Component State ---
|
||||||
|
let chores: Chore[] = [];
|
||||||
|
let isModalOpen = false;
|
||||||
|
let modalMode: 'add' | 'edit' = 'add';
|
||||||
|
let choreToEdit: Chore | null = null;
|
||||||
|
|
||||||
|
// Form State
|
||||||
|
let inputText = '';
|
||||||
|
let selectedCategory = categories[0]; // Default category
|
||||||
|
let selectedUserId: string | null = null; // Bound to select value, null = unassigned
|
||||||
|
|
||||||
|
// --- Swipe State ---
|
||||||
|
let touchStartX = 0;
|
||||||
|
let currentSwipedChoreId: string | null = null;
|
||||||
|
const SWIPE_THRESHOLD = -60;
|
||||||
|
|
||||||
|
// --- Lifecycle & Persistence ---
|
||||||
|
onMount(() => {
|
||||||
|
const savedChores = localStorage.getItem('choresList');
|
||||||
|
if (savedChores) {
|
||||||
|
try {
|
||||||
|
// Revive user objects from stored IDs
|
||||||
|
let loadedChores = JSON.parse(savedChores) as (Omit<Chore, 'assignedTo'> & {
|
||||||
|
assignedToId: string | null;
|
||||||
|
})[];
|
||||||
|
chores = loadedChores.map((c) => ({
|
||||||
|
...c,
|
||||||
|
assignedTo: c.assignedToId ? users.find((u) => u.id === c.assignedToId) || null : null,
|
||||||
|
isSwiped: false // Reset swipe state on load
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse chores from localStorage', e);
|
||||||
|
loadDefaultChores(); // Load defaults if parsing fails
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
loadDefaultChores();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', handleGlobalClick);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', handleGlobalClick);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadDefaultChores() {
|
||||||
|
chores = [
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
text: 'Wash Dishes',
|
||||||
|
completed: false,
|
||||||
|
assignedTo: users[0],
|
||||||
|
category: 'Kitchen',
|
||||||
|
isSwiped: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
text: 'Take out Trash',
|
||||||
|
completed: false,
|
||||||
|
assignedTo: null,
|
||||||
|
category: 'General',
|
||||||
|
isSwiped: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
text: 'Clean Bathroom Mirror',
|
||||||
|
completed: true,
|
||||||
|
assignedTo: users[1],
|
||||||
|
category: 'Bathroom',
|
||||||
|
isSwiped: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
text: 'Walk the Dog',
|
||||||
|
completed: false,
|
||||||
|
assignedTo: users[2],
|
||||||
|
category: 'Pets',
|
||||||
|
isSwiped: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save changes to localStorage whenever chores array updates
|
||||||
|
$: {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
// Ensure localStorage is available
|
||||||
|
// Store only the user ID to avoid circular references/large objects
|
||||||
|
const storableChores = chores.map((c) => ({
|
||||||
|
...c,
|
||||||
|
assignedToId: c.assignedTo ? c.assignedTo.id : null,
|
||||||
|
assignedTo: undefined // Remove the object before storing
|
||||||
|
}));
|
||||||
|
localStorage.setItem('choresList', JSON.stringify(storableChores));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Modal Functions ---
|
||||||
|
function openAddModal() {
|
||||||
|
closeAllSwipes();
|
||||||
|
modalMode = 'add';
|
||||||
|
choreToEdit = null;
|
||||||
|
inputText = '';
|
||||||
|
selectedCategory = categories[0];
|
||||||
|
selectedUserId = null;
|
||||||
|
isModalOpen = true;
|
||||||
|
setTimeout(() => document.getElementById('choreInput')?.focus(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(chore: Chore) {
|
||||||
|
closeAllSwipes();
|
||||||
|
modalMode = 'edit';
|
||||||
|
choreToEdit = chore;
|
||||||
|
inputText = chore.text;
|
||||||
|
selectedCategory = chore.category;
|
||||||
|
selectedUserId = chore.assignedTo ? chore.assignedTo.id : null;
|
||||||
|
isModalOpen = true;
|
||||||
|
setTimeout(() => document.getElementById('choreInput')?.focus(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
isModalOpen = false;
|
||||||
|
choreToEdit = null;
|
||||||
|
// Reset form fields (optional, could leave them)
|
||||||
|
// inputText = '';
|
||||||
|
// selectedCategory = categories[0];
|
||||||
|
// selectedUserId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleModalSubmit() {
|
||||||
|
const trimmedText = inputText.trim();
|
||||||
|
if (!trimmedText || !selectedCategory) return;
|
||||||
|
|
||||||
|
const assignedUser = users.find((u) => u.id === selectedUserId) || null;
|
||||||
|
|
||||||
|
if (modalMode === 'add') {
|
||||||
|
addChore(trimmedText, selectedCategory, assignedUser);
|
||||||
|
} else if (modalMode === 'edit' && choreToEdit) {
|
||||||
|
updateChore(choreToEdit.id, trimmedText, selectedCategory, assignedUser);
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleModalKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
handleModalSubmit();
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Chore Functions ---
|
||||||
|
function addChore(text: string, category: string, assignedTo: User | null) {
|
||||||
|
const newChore: Chore = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
text,
|
||||||
|
category,
|
||||||
|
assignedTo,
|
||||||
|
completed: false,
|
||||||
|
isSwiped: false
|
||||||
|
};
|
||||||
|
chores = [...chores, newChore];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleComplete(id: string) {
|
||||||
|
closeAllSwipes();
|
||||||
|
chores = chores.map((chore) =>
|
||||||
|
chore.id === id ? { ...chore, completed: !chore.completed } : chore
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteChore(id: string) {
|
||||||
|
chores = chores.filter((chore) => chore.id !== id);
|
||||||
|
closeModal(); // Close modal if deleting from edit form
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChore(
|
||||||
|
id: string,
|
||||||
|
newText: string,
|
||||||
|
newCategory: string,
|
||||||
|
newAssignedTo: User | null
|
||||||
|
) {
|
||||||
|
chores = chores.map((chore) =>
|
||||||
|
chore.id === id
|
||||||
|
? { ...chore, text: newText, category: newCategory, assignedTo: newAssignedTo }
|
||||||
|
: chore
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Swipe Handlers (Identical logic to Shopping List, adjusted names) ---
|
||||||
|
function handleTouchStart(event: TouchEvent, choreId: string) {
|
||||||
|
if (event.touches.length !== 1) return;
|
||||||
|
if (currentSwipedChoreId && currentSwipedChoreId !== choreId) {
|
||||||
|
closeSwipe(currentSwipedChoreId);
|
||||||
|
}
|
||||||
|
touchStartX = event.touches[0].clientX;
|
||||||
|
currentSwipedChoreId = choreId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchMove(event: TouchEvent) {
|
||||||
|
if (!currentSwipedChoreId || event.touches.length !== 1) return;
|
||||||
|
// Optional visual feedback during move can be added here
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchEnd(event: TouchEvent) {
|
||||||
|
if (!currentSwipedChoreId || event.changedTouches.length !== 1) return;
|
||||||
|
const touchEndX = event.changedTouches[0].clientX;
|
||||||
|
const deltaX = touchEndX - touchStartX;
|
||||||
|
const choreIndex = chores.findIndex((c) => c.id === currentSwipedChoreId);
|
||||||
|
if (choreIndex === -1) return;
|
||||||
|
|
||||||
|
if (deltaX < SWIPE_THRESHOLD) {
|
||||||
|
chores[choreIndex].isSwiped = true;
|
||||||
|
} else {
|
||||||
|
chores[choreIndex].isSwiped = false;
|
||||||
|
currentSwipedChoreId = null;
|
||||||
|
}
|
||||||
|
chores = chores; // Trigger reactivity
|
||||||
|
touchStartX = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSwipe(choreId: string) {
|
||||||
|
const choreIndex = chores.findIndex((c) => c.id === choreId);
|
||||||
|
if (choreIndex !== -1 && chores[choreIndex].isSwiped) {
|
||||||
|
chores[choreIndex].isSwiped = false;
|
||||||
|
chores = chores;
|
||||||
|
if (currentSwipedChoreId === choreId) {
|
||||||
|
currentSwipedChoreId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAllSwipes() {
|
||||||
|
let changed = false;
|
||||||
|
chores.forEach((chore) => {
|
||||||
|
if (chore.isSwiped) {
|
||||||
|
chore.isSwiped = false;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (changed) {
|
||||||
|
chores = chores;
|
||||||
|
}
|
||||||
|
currentSwipedChoreId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGlobalClick(event: MouseEvent) {
|
||||||
|
const target = event.target as Element;
|
||||||
|
if (!target.closest('.list-item') && currentSwipedChoreId) {
|
||||||
|
closeAllSwipes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<h1 class="mb-4">Chores List</h1>
|
||||||
|
|
||||||
|
<section class="card mb-4" aria-labelledby="chores-list-heading">
|
||||||
|
<div class="card-header flex justify-between items-center flex-wrap">
|
||||||
|
<h3 id="chores-list-heading" class="mb-0 md:mb-0">Assigned Chores</h3>
|
||||||
|
<button class="btn btn-sm mt-1 md:mt-0" on:click={openAddModal}>
|
||||||
|
<svg class="icon icon-sm" aria-hidden="true"><use xlink:href="#icon-plus" /></svg>
|
||||||
|
Add Chore
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
{#if chores.length === 0}
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div
|
||||||
|
class="empty-state-card"
|
||||||
|
style="padding: 1.5rem 1rem; box-shadow: none; border: none; background: none;"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="icon icon-lg"
|
||||||
|
style="width: 40px; height: 40px; margin-bottom: 0.5rem;"
|
||||||
|
aria-hidden="true"><use xlink:href="#icon-clipboard" /></svg
|
||||||
|
>
|
||||||
|
<h3 style="margin-bottom: 0.5rem;">No Chores!</h3>
|
||||||
|
<p class="mb-3">Looks like everything's done (or not added yet!).</p>
|
||||||
|
<button class="btn btn-sm" on:click={openAddModal}>
|
||||||
|
<svg class="icon icon-sm" aria-hidden="true"><use xlink:href="#icon-plus" /></svg>
|
||||||
|
Add First Chore
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Chores List -->
|
||||||
|
<ul class="item-list" aria-labelledby="chores-list-heading">
|
||||||
|
{#each chores as chore (chore.id)}
|
||||||
|
<li
|
||||||
|
class="list-item"
|
||||||
|
class:completed={chore.completed}
|
||||||
|
class:is-swiped={chore.isSwiped}
|
||||||
|
on:touchstart={(e) => handleTouchStart(e, chore.id)}
|
||||||
|
on:touchmove={handleTouchMove}
|
||||||
|
on:touchend={handleTouchEnd}
|
||||||
|
style="touch-action: pan-y;"
|
||||||
|
>
|
||||||
|
<!-- Main Content Area -->
|
||||||
|
<div class="list-item-content">
|
||||||
|
<div class="list-item-main">
|
||||||
|
<label
|
||||||
|
class="checkbox-label mb-0 flex-shrink-0"
|
||||||
|
title={chore.completed ? 'Mark as incomplete' : 'Mark as complete'}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={chore.completed}
|
||||||
|
on:change={() => toggleComplete(chore.id)}
|
||||||
|
aria-label={chore.text}
|
||||||
|
/>
|
||||||
|
<span class="checkmark"></span>
|
||||||
|
</label>
|
||||||
|
<div class="item-text flex-grow">{chore.text}</div>
|
||||||
|
</div>
|
||||||
|
<!-- Details: Avatar & Badge -->
|
||||||
|
<div class="list-item-details">
|
||||||
|
{#if chore.assignedTo}
|
||||||
|
<div
|
||||||
|
class="avatar tooltip"
|
||||||
|
aria-label={`Assigned to ${chore.assignedTo.name}`}
|
||||||
|
style:background-color={chore.assignedTo.color || 'var(--secondary)'}
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
{chore.assignedTo.initials}
|
||||||
|
<span class="tooltip-text" role="tooltip"
|
||||||
|
>Assigned to {chore.assignedTo.name}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="avatar tooltip"
|
||||||
|
style="background-color: #eee; color: #999;"
|
||||||
|
aria-label="Unassigned"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
<span class="tooltip-text" role="tooltip">Unassigned</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="item-badge badge-accent">{chore.category}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Swipe Actions Area -->
|
||||||
|
<div class="swipe-actions" aria-hidden={!chore.isSwiped}>
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
title="Edit Chore"
|
||||||
|
on:click={() => openEditModal(chore)}
|
||||||
|
tabindex={chore.isSwiped ? 0 : -1}
|
||||||
|
>
|
||||||
|
<svg class="icon icon-sm" aria-hidden="true"><use xlink:href="#icon-edit" /></svg>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-danger"
|
||||||
|
title="Delete Chore"
|
||||||
|
on:click={() => deleteChore(chore.id)}
|
||||||
|
tabindex={chore.isSwiped ? 0 : -1}
|
||||||
|
>
|
||||||
|
<svg class="icon icon-sm" aria-hidden="true"><use xlink:href="#icon-trash" /></svg
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if chores.length > 0}
|
||||||
|
<div class="card-footer justify-end">
|
||||||
|
<span class="mr-2 text-sm opacity-80">
|
||||||
|
{chores.filter((c) => !c.completed).length} chores remaining
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Add/Edit Chore Modal -->
|
||||||
|
{#if isModalOpen}
|
||||||
|
<div
|
||||||
|
class="modal-backdrop open"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="modalChoreTitle"
|
||||||
|
on:click|self={closeModal}
|
||||||
|
on:keydown={handleModalKeydown}
|
||||||
|
>
|
||||||
|
<div class="modal-container">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="modalChoreTitle">{modalMode === 'add' ? 'Add New Chore' : 'Edit Chore'}</h3>
|
||||||
|
<button class="close-button" aria-label="Close modal" on:click={closeModal}>
|
||||||
|
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-close" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form on:submit|preventDefault={handleModalSubmit}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="choreInput" class="form-label">Chore Description</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="choreInput"
|
||||||
|
class="form-input"
|
||||||
|
bind:value={inputText}
|
||||||
|
placeholder="e.g., Clean the kitchen counter"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap form-row-wrap-mobile">
|
||||||
|
<div class="form-group flex-grow mr-0 md:mr-4 w-full md:w-auto">
|
||||||
|
<label for="categorySelect" class="form-label">Category</label>
|
||||||
|
<select id="categorySelect" class="form-input" bind:value={selectedCategory}>
|
||||||
|
{#each categories as category}
|
||||||
|
<option value={category}>{category}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group flex-grow w-full md:w-auto">
|
||||||
|
<label for="userSelect" class="form-label">Assign To</label>
|
||||||
|
<select id="userSelect" class="form-input" bind:value={selectedUserId}>
|
||||||
|
<option value={null}>-- Unassigned --</option>
|
||||||
|
{#each users as user (user.id)}
|
||||||
|
<option value={user.id}>{user.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-neutral" on:click={closeModal}>Cancel</button>
|
||||||
|
<button class="btn ml-2" on:click={handleModalSubmit}>
|
||||||
|
{modalMode === 'add' ? 'Add Chore' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
{#if modalMode === 'edit'}
|
||||||
|
<button
|
||||||
|
class="btn btn-danger ml-2"
|
||||||
|
title="Delete Chore"
|
||||||
|
on:click={() => deleteChore(choreToEdit!.id)}
|
||||||
|
>
|
||||||
|
<svg class="icon icon-sm" aria-hidden="true"><use xlink:href="#icon-trash" /></svg>
|
||||||
|
<!-- <span class="sr-only">Delete</span> -->
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Add any minor style overrides here if needed */
|
||||||
|
.list-item {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.modal-container {
|
||||||
|
z-index: 1001;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style for the unassigned avatar */
|
||||||
|
.avatar[aria-label='Unassigned'] {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure selects have minimum width on mobile */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
select.form-input {
|
||||||
|
min-width: 150px; /* Adjust as needed */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
167
src/routes/(auth)/dashboard/+page.svelte
Normal file
167
src/routes/(auth)/dashboard/+page.svelte
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { shoppingItemsStore, choresStore, groupsStore } from '$lib/stores';
|
||||||
|
// Optionally import User type if needed for user-specific features later
|
||||||
|
// import type { User } from '../types';
|
||||||
|
|
||||||
|
// --- Reactive Calculations ---
|
||||||
|
// Use the $ prefix for auto-subscription
|
||||||
|
|
||||||
|
// Shopping List Stats
|
||||||
|
$: totalShoppingItems = $shoppingItemsStore.length;
|
||||||
|
$: pendingShoppingItems = $shoppingItemsStore.filter((item) => !item.completed).length;
|
||||||
|
|
||||||
|
// Chores Stats
|
||||||
|
$: totalChores = $choresStore.length;
|
||||||
|
$: pendingChores = $choresStore.filter((chore) => !chore.completed).length;
|
||||||
|
// Example: Chores assigned to a specific user (replace 'user1' with dynamic current user ID)
|
||||||
|
// $: myPendingChores = $choresStore.filter(c => !c.completed && c.assignedTo?.id === 'user1').length;
|
||||||
|
|
||||||
|
// Groups Stats
|
||||||
|
$: totalGroups = $groupsStore.length;
|
||||||
|
|
||||||
|
// --- Navigation ---
|
||||||
|
// In a real app, use your router's navigation methods (e.g., goto from $app/navigation)
|
||||||
|
// These are simple hrefs assuming separate pages or basic routing.
|
||||||
|
const navShopping = '/lists'; // Adjust paths as needed
|
||||||
|
const navChores = '/chores';
|
||||||
|
const navGroups = '/groups';
|
||||||
|
// Optionally import User type if needed for user-specific features later
|
||||||
|
// import type { User } from '../types';
|
||||||
|
|
||||||
|
// --- Reactive Calculations ---
|
||||||
|
// Use the $ prefix for auto-subscription
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<h1 class="mb-4">Dashboard</h1>
|
||||||
|
|
||||||
|
<!-- Welcome/Greeting (Optional) -->
|
||||||
|
<p class="mb-4">Welcome back! Here's a quick overview of your household.</p>
|
||||||
|
|
||||||
|
<div class="dashboard-grid">
|
||||||
|
<!-- Stats Card -->
|
||||||
|
<section class="card mb-4" aria-labelledby="stats-heading">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 id="stats-heading" class="mb-0">Quick Stats</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body stats-body">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-value">{pendingShoppingItems} / {totalShoppingItems}</span>
|
||||||
|
<span class="stat-label">Shopping Items Pending</span>
|
||||||
|
</div>
|
||||||
|
<hr class="stat-divider" />
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-value">{pendingChores} / {totalChores}</span>
|
||||||
|
<span class="stat-label">Chores Pending</span>
|
||||||
|
</div>
|
||||||
|
<hr class="stat-divider" />
|
||||||
|
<!-- Example: User specific stat -->
|
||||||
|
<!-- <div class="stat-item">
|
||||||
|
<span class="stat-value">{myPendingChores}</span>
|
||||||
|
<span class="stat-label">Chores Assigned To You</span>
|
||||||
|
</div>
|
||||||
|
<hr class="stat-divider"> -->
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-value">{totalGroups}</span>
|
||||||
|
<span class="stat-label">Active Groups</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Quick Actions Card -->
|
||||||
|
<section class="card mb-4" aria-labelledby="actions-heading">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 id="actions-heading" class="mb-0">Quick Actions</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body actions-body">
|
||||||
|
<a href={navShopping} class="btn btn-secondary w-full mb-2">
|
||||||
|
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-clipboard" /></svg>
|
||||||
|
<!-- Use relevant icon -->
|
||||||
|
View Shopping List
|
||||||
|
</a>
|
||||||
|
<a href={navChores} class="btn btn-secondary w-full mb-2">
|
||||||
|
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-check" /></svg>
|
||||||
|
<!-- Use relevant icon -->
|
||||||
|
View Chores
|
||||||
|
</a>
|
||||||
|
<a href={navGroups} class="btn btn-secondary w-full">
|
||||||
|
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-user" /></svg>
|
||||||
|
<!-- Use relevant icon -->
|
||||||
|
Manage Groups
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Placeholder for other dashboard widgets like recent activity -->
|
||||||
|
<!-- <section class="card mb-4" aria-labelledby="activity-heading">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 id="activity-heading" class="mb-0">Recent Activity</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="opacity-70 italic">Activity feed coming soon...</p>
|
||||||
|
</div>
|
||||||
|
</section> -->
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: start; /* Align cards to the top */
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-body,
|
||||||
|
.actions-body {
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.stat-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--primary);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--dark);
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 2px dashed var(--secondary); /* Dashed divider */
|
||||||
|
margin: 1rem auto; /* Center divider */
|
||||||
|
width: 50%;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-body .btn {
|
||||||
|
text-align: left; /* Align button text left */
|
||||||
|
justify-content: flex-start; /* Align icon/text start */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure stats card doesn't get too tall if few items */
|
||||||
|
.card-body.stats-body {
|
||||||
|
/* display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-around; */
|
||||||
|
}
|
||||||
|
</style>
|
403
src/routes/(auth)/groups/+page.svelte
Normal file
403
src/routes/(auth)/groups/+page.svelte
Normal file
@ -0,0 +1,403 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
// import type { User, Group, StorableGroup } from '../types'; // If using external types file
|
||||||
|
|
||||||
|
// --- Define Types directly if not using a separate file ---
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
initials: string;
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
interface Group {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
members: User[];
|
||||||
|
}
|
||||||
|
interface StorableGroup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
memberIds: string[];
|
||||||
|
}
|
||||||
|
// --- End Type Definitions ---
|
||||||
|
|
||||||
|
// --- Static Data (Example Users - should ideally come from shared state/API) ---
|
||||||
|
const availableUsers: User[] = [
|
||||||
|
{ id: 'user1', initials: 'JD', name: 'Jane Doe', color: '#ffb26b' },
|
||||||
|
{ id: 'user2', initials: 'AP', name: 'Alex P.', color: '#a0e7a0' },
|
||||||
|
{ id: 'user3', initials: 'MS', name: 'Mike Smith', color: '#54c7ff' },
|
||||||
|
{ id: 'user4', initials: 'LS', name: 'Lisa Simpson', color: '#ffd56b' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Component State ---
|
||||||
|
let groups: Group[] = [];
|
||||||
|
let isModalOpen = false;
|
||||||
|
let modalMode: 'add' | 'edit' = 'add';
|
||||||
|
let groupToEdit: Group | null = null;
|
||||||
|
|
||||||
|
// Form State
|
||||||
|
let inputGroupName = '';
|
||||||
|
let selectedMemberIds: string[] = []; // For binding checkbox group
|
||||||
|
|
||||||
|
// --- Lifecycle & Persistence ---
|
||||||
|
onMount(() => {
|
||||||
|
loadGroups();
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadGroups() {
|
||||||
|
const savedGroups = localStorage.getItem('groupsList');
|
||||||
|
if (savedGroups) {
|
||||||
|
try {
|
||||||
|
const storedGroups: StorableGroup[] = JSON.parse(savedGroups);
|
||||||
|
// Map stored IDs back to full User objects
|
||||||
|
groups = storedGroups.map((sg) => ({
|
||||||
|
id: sg.id,
|
||||||
|
name: sg.name,
|
||||||
|
members: sg.memberIds
|
||||||
|
.map((id) => availableUsers.find((u) => u.id === id)) // Find user by ID
|
||||||
|
.filter((user) => user !== undefined) as User[] // Filter out undefined if user was deleted & cast
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse groups from localStorage', e);
|
||||||
|
loadDefaultGroups();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
loadDefaultGroups();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDefaultGroups() {
|
||||||
|
// Example default groups
|
||||||
|
groups = [
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: 'The Does',
|
||||||
|
members: [availableUsers[0], availableUsers[1]]
|
||||||
|
},
|
||||||
|
{ id: crypto.randomUUID(), name: 'Smith Residence', members: [availableUsers[2]] },
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: 'Apt 4B Crew',
|
||||||
|
members: [availableUsers[1], availableUsers[3]]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
saveGroups(); // Save defaults if loading for the first time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to save groups
|
||||||
|
function saveGroups() {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const storableGroups: StorableGroup[] = groups.map((g) => ({
|
||||||
|
id: g.id,
|
||||||
|
name: g.name,
|
||||||
|
memberIds: g.members.map((m) => m.id) // Extract only IDs for storage
|
||||||
|
}));
|
||||||
|
localStorage.setItem('groupsList', JSON.stringify(storableGroups));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save whenever the groups array changes
|
||||||
|
$: if (
|
||||||
|
groups.length > 0 ||
|
||||||
|
(typeof window !== 'undefined' && localStorage.getItem('groupsList'))
|
||||||
|
) {
|
||||||
|
saveGroups();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Modal Functions ---
|
||||||
|
function openAddModal() {
|
||||||
|
modalMode = 'add';
|
||||||
|
groupToEdit = null;
|
||||||
|
inputGroupName = '';
|
||||||
|
selectedMemberIds = []; // Reset selected members
|
||||||
|
isModalOpen = true;
|
||||||
|
setTimeout(() => document.getElementById('groupNameInput')?.focus(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(group: Group) {
|
||||||
|
modalMode = 'edit';
|
||||||
|
groupToEdit = group;
|
||||||
|
inputGroupName = group.name;
|
||||||
|
selectedMemberIds = group.members.map((m) => m.id); // Pre-select current members
|
||||||
|
isModalOpen = true;
|
||||||
|
setTimeout(() => document.getElementById('groupNameInput')?.focus(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
isModalOpen = false;
|
||||||
|
groupToEdit = null;
|
||||||
|
// Optionally clear form state
|
||||||
|
// inputGroupName = '';
|
||||||
|
// selectedMemberIds = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleModalSubmit() {
|
||||||
|
const trimmedName = inputGroupName.trim();
|
||||||
|
if (!trimmedName) return; // Require a name
|
||||||
|
|
||||||
|
// Map selected IDs back to User objects
|
||||||
|
const selectedMembers = selectedMemberIds
|
||||||
|
.map((id) => availableUsers.find((u) => u.id === id))
|
||||||
|
.filter((user) => user !== undefined) as User[];
|
||||||
|
|
||||||
|
if (modalMode === 'add') {
|
||||||
|
addGroup(trimmedName, selectedMembers);
|
||||||
|
} else if (modalMode === 'edit' && groupToEdit) {
|
||||||
|
updateGroup(groupToEdit.id, trimmedName, selectedMembers);
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleModalKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
handleModalSubmit();
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Group Functions ---
|
||||||
|
function addGroup(name: string, members: User[]) {
|
||||||
|
const newGroup: Group = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name,
|
||||||
|
members
|
||||||
|
};
|
||||||
|
groups = [...groups, newGroup];
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGroup(id: string, newName: string, newMembers: User[]) {
|
||||||
|
groups = groups.map((group) =>
|
||||||
|
group.id === id ? { ...group, name: newName, members: newMembers } : group
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteGroup(id: string) {
|
||||||
|
// Add a confirmation dialog for safety? For now, direct delete.
|
||||||
|
groups = groups.filter((group) => group.id !== id);
|
||||||
|
closeModal(); // Close modal if deleting from edit form
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<h1 class="mb-4">Groups & Households</h1>
|
||||||
|
|
||||||
|
<section class="mb-4" aria-labelledby="groups-list-heading">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header flex justify-between items-center flex-wrap">
|
||||||
|
<h3 id="groups-list-heading" class="mb-0 md:mb-0">Managed Groups</h3>
|
||||||
|
<button class="btn btn-sm mt-1 md:mt-0" on:click={openAddModal}>
|
||||||
|
<svg class="icon icon-sm" aria-hidden="true"><use xlink:href="#icon-plus" /></svg>
|
||||||
|
Create Group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
{#if groups.length === 0}
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div
|
||||||
|
class="empty-state-card"
|
||||||
|
style="padding: 1.5rem 1rem; box-shadow: none; border: none; background: none;"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="icon icon-lg"
|
||||||
|
style="width: 40px; height: 40px; margin-bottom: 0.5rem;"
|
||||||
|
aria-hidden="true"><use xlink:href="#icon-user" /></svg
|
||||||
|
>
|
||||||
|
<!-- Placeholder icon -->
|
||||||
|
<h3 style="margin-bottom: 0.5rem;">No Groups Found</h3>
|
||||||
|
<p class="mb-3">Create your first group or household.</p>
|
||||||
|
<button class="btn btn-sm" on:click={openAddModal}>
|
||||||
|
<svg class="icon icon-sm" aria-hidden="true"><use xlink:href="#icon-plus" /></svg>
|
||||||
|
Create First Group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Groups List -->
|
||||||
|
<div class="groups-grid">
|
||||||
|
{#each groups as group (group.id)}
|
||||||
|
<div class="group-item card">
|
||||||
|
<!-- Use card style for each group item -->
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex justify-between items-start mb-2">
|
||||||
|
<h4 class="group-name mb-0">{group.name}</h4>
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary btn-sm btn-icon-only"
|
||||||
|
title="Edit Group"
|
||||||
|
aria-label={`Edit group ${group.name}`}
|
||||||
|
on:click={() => openEditModal(group)}
|
||||||
|
>
|
||||||
|
<svg class="icon icon-sm" aria-hidden="true"
|
||||||
|
><use xlink:href="#icon-edit" /></svg
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="members-list flex flex-wrap items-center">
|
||||||
|
{#if group.members.length > 0}
|
||||||
|
{#each group.members as member (member.id)}
|
||||||
|
<div
|
||||||
|
class="avatar tooltip mr-1 mb-1"
|
||||||
|
aria-label={`Member: ${member.name}`}
|
||||||
|
style:background-color={member.color || 'var(--secondary)'}
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
{member.initials}
|
||||||
|
<span class="tooltip-text" role="tooltip">{member.name}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<span class="text-sm opacity-70 italic">No members assigned</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Optional Footer for actions like delete? -->
|
||||||
|
<!-- <div class="card-footer justify-end">
|
||||||
|
<button class="btn btn-danger btn-sm">Delete</button>
|
||||||
|
</div> -->
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Add/Edit Group Modal -->
|
||||||
|
{#if isModalOpen}
|
||||||
|
<div
|
||||||
|
class="modal-backdrop open"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="modalGroupTitle"
|
||||||
|
on:click|self={closeModal}
|
||||||
|
on:keydown={handleModalKeydown}
|
||||||
|
>
|
||||||
|
<div class="modal-container">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="modalGroupTitle">{modalMode === 'add' ? 'Create New Group' : 'Edit Group'}</h3>
|
||||||
|
<button class="close-button" aria-label="Close modal" on:click={closeModal}>
|
||||||
|
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-close" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form on:submit|preventDefault={handleModalSubmit}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="groupNameInput" class="form-label">Group Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="groupNameInput"
|
||||||
|
class="form-input"
|
||||||
|
bind:value={inputGroupName}
|
||||||
|
placeholder="e.g., Family Hub"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Select Members</label>
|
||||||
|
<div class="checkbox-group" role="group" aria-label="Select group members">
|
||||||
|
{#if availableUsers.length > 0}
|
||||||
|
{#each availableUsers as user (user.id)}
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" value={user.id} bind:group={selectedMemberIds} />
|
||||||
|
<span class="checkmark"></span>
|
||||||
|
<span class="ml-2 flex items-center">
|
||||||
|
<span
|
||||||
|
class="avatar avatar-sm mr-2"
|
||||||
|
style:background-color={user.color || 'var(--secondary)'}
|
||||||
|
>{user.initials}</span
|
||||||
|
>
|
||||||
|
{user.name}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm opacity-70">No users available to add.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<!-- End of form -->
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-neutral" on:click={closeModal}>Cancel</button>
|
||||||
|
<button class="btn ml-2" on:click={handleModalSubmit}>
|
||||||
|
{modalMode === 'add' ? 'Create Group' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
{#if modalMode === 'edit'}
|
||||||
|
<button
|
||||||
|
class="btn btn-danger ml-2"
|
||||||
|
title="Delete Group"
|
||||||
|
on:click={() => deleteGroup(groupToEdit!.id)}
|
||||||
|
>
|
||||||
|
<svg class="icon icon-sm" aria-hidden="true"><use xlink:href="#icon-trash" /></svg>
|
||||||
|
<!-- <span class="sr-only">Delete</span> -->
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.groups-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-item .card-body {
|
||||||
|
padding: 1rem; /* Slightly smaller padding for nested card */
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name {
|
||||||
|
font-size: 1.15rem; /* Slightly smaller heading for group name */
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--dark);
|
||||||
|
/* Optional: remove heading underline if desired */
|
||||||
|
/* border-bottom: none; padding-bottom: 0; */
|
||||||
|
}
|
||||||
|
.group-name::after {
|
||||||
|
/* display: none; */ /* Remove underline */
|
||||||
|
background-color: var(--secondary); /* Match theme */
|
||||||
|
height: 2px;
|
||||||
|
bottom: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.members-list {
|
||||||
|
min-height: 36px; /* Ensure consistent height even if empty */
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
flex-shrink: 0; /* Prevent avatars from shrinking too much */
|
||||||
|
}
|
||||||
|
.avatar-sm {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border-width: 1px;
|
||||||
|
box-shadow: var(--shadow-inset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure modal is above other elements */
|
||||||
|
.modal-backdrop {
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.modal-container {
|
||||||
|
z-index: 1001;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improve checkbox alignment within the modal */
|
||||||
|
.checkbox-group .checkbox-label {
|
||||||
|
padding-left: 2.2rem; /* Adjust if avatar makes it too tight */
|
||||||
|
min-height: 38px; /* Ensure space for avatar */
|
||||||
|
}
|
||||||
|
.checkbox-group .checkmark {
|
||||||
|
left: 0.25rem; /* Adjust checkmark position */
|
||||||
|
}
|
||||||
|
.checkbox-group .checkbox-label .ml-2 {
|
||||||
|
margin-left: 0.5rem; /* Space between checkmark and avatar/name span */
|
||||||
|
}
|
||||||
|
</style>
|
384
src/routes/(auth)/lists/+page.svelte
Normal file
384
src/routes/(auth)/lists/+page.svelte
Normal file
@ -0,0 +1,384 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
// --- Data Structure ---
|
||||||
|
interface ShoppingItem {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
completed: boolean;
|
||||||
|
isSwiped: boolean; // State for swipe interaction
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Component State ---
|
||||||
|
let items: ShoppingItem[] = [];
|
||||||
|
let isModalOpen = false;
|
||||||
|
let modalMode: 'add' | 'edit' = 'add';
|
||||||
|
let itemToEdit: ShoppingItem | null = null;
|
||||||
|
let inputText = ''; // For both add and edit input
|
||||||
|
|
||||||
|
// --- Swipe State ---
|
||||||
|
let touchStartX = 0;
|
||||||
|
let currentSwipedItemId: string | null = null;
|
||||||
|
const SWIPE_THRESHOLD = -60; // Pixels to swipe left to reveal actions
|
||||||
|
|
||||||
|
// --- Lifecycle ---
|
||||||
|
onMount(() => {
|
||||||
|
// Load items from local storage or API (example)
|
||||||
|
if (localStorage) {
|
||||||
|
const savedItems = localStorage.getItem('shoppingListItems');
|
||||||
|
}
|
||||||
|
const savedItems = '';
|
||||||
|
|
||||||
|
if (savedItems) {
|
||||||
|
items = JSON.parse(savedItems);
|
||||||
|
// Ensure isSwiped is reset on load
|
||||||
|
items.forEach((item) => (item.isSwiped = false));
|
||||||
|
} else {
|
||||||
|
// Add some default items if none saved
|
||||||
|
items = [
|
||||||
|
{ id: crypto.randomUUID(), text: 'Milk', completed: false, isSwiped: false },
|
||||||
|
{ id: crypto.randomUUID(), text: 'Bread (Whole Wheat)', completed: false, isSwiped: false },
|
||||||
|
{ id: crypto.randomUUID(), text: 'Eggs', completed: true, isSwiped: false }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add global listener to close swipe menus
|
||||||
|
document.addEventListener('click', handleGlobalClick);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', handleGlobalClick);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Persistence (Example) ---
|
||||||
|
$: {
|
||||||
|
if (items.length > 0 || localStorage.getItem('shoppingListItems')) {
|
||||||
|
console.log('Saving items:', items);
|
||||||
|
localStorage.setItem('shoppingListItems', JSON.stringify(items));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Modal Functions ---
|
||||||
|
function openAddModal() {
|
||||||
|
closeAllSwipes();
|
||||||
|
modalMode = 'add';
|
||||||
|
itemToEdit = null;
|
||||||
|
inputText = '';
|
||||||
|
isModalOpen = true;
|
||||||
|
// Simple focus - ideally trap focus later
|
||||||
|
setTimeout(() => document.getElementById('itemInput')?.focus(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(item: ShoppingItem) {
|
||||||
|
closeAllSwipes();
|
||||||
|
modalMode = 'edit';
|
||||||
|
itemToEdit = item;
|
||||||
|
inputText = item.text;
|
||||||
|
isModalOpen = true;
|
||||||
|
// Simple focus
|
||||||
|
setTimeout(() => document.getElementById('itemInput')?.focus(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
isModalOpen = false;
|
||||||
|
itemToEdit = null;
|
||||||
|
inputText = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleModalSubmit() {
|
||||||
|
const trimmedText = inputText.trim();
|
||||||
|
if (!trimmedText) return;
|
||||||
|
|
||||||
|
if (modalMode === 'add') {
|
||||||
|
addItem(trimmedText);
|
||||||
|
} else if (modalMode === 'edit' && itemToEdit) {
|
||||||
|
updateItem(itemToEdit.id, trimmedText);
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleModalKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
handleModalSubmit();
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Item Functions ---
|
||||||
|
function addItem(text: string) {
|
||||||
|
const newItem: ShoppingItem = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
text: text,
|
||||||
|
completed: false,
|
||||||
|
isSwiped: false
|
||||||
|
};
|
||||||
|
items = [...items, newItem];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleComplete(id: string) {
|
||||||
|
closeAllSwipes(); // Close swipe if checking the box
|
||||||
|
items = items.map((item) => (item.id === id ? { ...item, completed: !item.completed } : item));
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteItem(id: string) {
|
||||||
|
items = items.filter((item) => item.id !== id);
|
||||||
|
closeModal(); // Close modal if deleting from there
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateItem(id: string, newText: string) {
|
||||||
|
items = items.map((item) => (item.id === id ? { ...item, text: newText } : item));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Swipe Handlers ---
|
||||||
|
function handleTouchStart(event: TouchEvent, itemId: string) {
|
||||||
|
// Only process single touch
|
||||||
|
if (event.touches.length !== 1) return;
|
||||||
|
|
||||||
|
// Close any other currently swiped item
|
||||||
|
if (currentSwipedItemId && currentSwipedItemId !== itemId) {
|
||||||
|
closeSwipe(currentSwipedItemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
touchStartX = event.touches[0].clientX;
|
||||||
|
currentSwipedItemId = itemId; // Track which item is being interacted with
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchMove(event: TouchEvent) {
|
||||||
|
if (!currentSwipedItemId || event.touches.length !== 1) return;
|
||||||
|
// Optional: Add visual feedback during move (translate content)
|
||||||
|
// Requires more complex state or direct DOM manipulation (less Svelte-like)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchEnd(event: TouchEvent) {
|
||||||
|
if (!currentSwipedItemId || event.changedTouches.length !== 1) return;
|
||||||
|
|
||||||
|
const touchEndX = event.changedTouches[0].clientX;
|
||||||
|
const deltaX = touchEndX - touchStartX;
|
||||||
|
|
||||||
|
const itemIndex = items.findIndex((i) => i.id === currentSwipedItemId);
|
||||||
|
if (itemIndex === -1) return; // Should not happen
|
||||||
|
|
||||||
|
if (deltaX < SWIPE_THRESHOLD) {
|
||||||
|
// Swipe left complete - reveal actions
|
||||||
|
items[itemIndex].isSwiped = true;
|
||||||
|
} else {
|
||||||
|
// Swipe right or not far enough - hide actions
|
||||||
|
items[itemIndex].isSwiped = false;
|
||||||
|
currentSwipedItemId = null; // Clear tracker if swipe didn't reveal
|
||||||
|
}
|
||||||
|
|
||||||
|
items = items; // Trigger Svelte reactivity
|
||||||
|
touchStartX = 0; // Reset start position
|
||||||
|
// Don't reset currentSwipedItemId if it was successfully swiped open
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSwipe(itemId: string) {
|
||||||
|
const itemIndex = items.findIndex((i) => i.id === itemId);
|
||||||
|
if (itemIndex !== -1 && items[itemIndex].isSwiped) {
|
||||||
|
items[itemIndex].isSwiped = false;
|
||||||
|
items = items; // Trigger reactivity
|
||||||
|
if (currentSwipedItemId === itemId) {
|
||||||
|
currentSwipedItemId = null; // Clear tracker if this was the active swipe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAllSwipes() {
|
||||||
|
let changed = false;
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (item.isSwiped) {
|
||||||
|
item.isSwiped = false;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (changed) {
|
||||||
|
items = items; // Trigger update if any were closed
|
||||||
|
}
|
||||||
|
currentSwipedItemId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close swipe menus if clicking outside the list items
|
||||||
|
function handleGlobalClick(event: MouseEvent) {
|
||||||
|
const target = event.target as Element;
|
||||||
|
// Check if the click is outside any list item
|
||||||
|
if (!target.closest('.list-item') && currentSwipedItemId) {
|
||||||
|
closeAllSwipes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Apply base styles and container -->
|
||||||
|
<main class="container">
|
||||||
|
<h1 class="mb-4">Svelte Shopping List</h1>
|
||||||
|
|
||||||
|
<section class="card mb-4" aria-labelledby="shopping-list-heading">
|
||||||
|
<div class="card-header flex justify-between items-center flex-wrap">
|
||||||
|
<h3 id="shopping-list-heading" class="mb-0 md:mb-0">My List</h3>
|
||||||
|
<button class="btn btn-sm mt-1 md:mt-0" on:click={openAddModal}>
|
||||||
|
<svg class="icon icon-sm" aria-hidden="true"><use xlink:href="#icon-plus" /></svg>
|
||||||
|
Add Item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
{#if items.length === 0}
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div
|
||||||
|
class="empty-state-card"
|
||||||
|
style="padding: 1.5rem 1rem; box-shadow: none; border: none; background: none;"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="icon icon-lg"
|
||||||
|
style="width: 40px; height: 40px; margin-bottom: 0.5rem;"
|
||||||
|
aria-hidden="true"><use xlink:href="#icon-clipboard" /></svg
|
||||||
|
>
|
||||||
|
<h3 style="margin-bottom: 0.5rem;">List Empty!</h3>
|
||||||
|
<p class="mb-3">Add your first shopping item.</p>
|
||||||
|
<button class="btn btn-sm" on:click={openAddModal}>
|
||||||
|
<svg class="icon icon-sm" aria-hidden="true"><use xlink:href="#icon-plus" /></svg>
|
||||||
|
Add First Item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Item List -->
|
||||||
|
<ul class="item-list" aria-labelledby="shopping-list-heading">
|
||||||
|
{#each items as item (item.id)}
|
||||||
|
<li
|
||||||
|
class="list-item"
|
||||||
|
class:completed={item.completed}
|
||||||
|
class:is-swiped={item.isSwiped}
|
||||||
|
on:touchstart={(e) => handleTouchStart(e, item.id)}
|
||||||
|
on:touchmove={handleTouchMove}
|
||||||
|
on:touchend={handleTouchEnd}
|
||||||
|
style="touch-action: pan-y;"
|
||||||
|
>
|
||||||
|
<!-- Main Content Area -->
|
||||||
|
<div class="list-item-content">
|
||||||
|
<div class="list-item-main">
|
||||||
|
<label
|
||||||
|
class="checkbox-label mb-0 flex-shrink-0"
|
||||||
|
title={item.completed ? 'Mark as incomplete' : 'Mark as complete'}
|
||||||
|
>
|
||||||
|
<!-- Bind checked, but use on:change for the action to prevent closing swipe -->
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={item.completed}
|
||||||
|
on:change={() => toggleComplete(item.id)}
|
||||||
|
aria-label={item.text}
|
||||||
|
/>
|
||||||
|
<span class="checkmark"></span>
|
||||||
|
</label>
|
||||||
|
<div class="item-text flex-grow">{item.text}</div>
|
||||||
|
</div>
|
||||||
|
<!-- Removed list-item-details for simplicity -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Swipe Actions Area -->
|
||||||
|
<div class="swipe-actions" aria-hidden={!item.isSwiped}>
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
title="Edit Item"
|
||||||
|
on:click={() => openEditModal(item)}
|
||||||
|
tabindex={item.isSwiped ? 0 : -1}
|
||||||
|
>
|
||||||
|
<svg class="icon icon-sm" aria-hidden="true"><use xlink:href="#icon-edit" /></svg>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-danger"
|
||||||
|
title="Delete Item"
|
||||||
|
on:click={() => deleteItem(item.id)}
|
||||||
|
tabindex={item.isSwiped ? 0 : -1}
|
||||||
|
>
|
||||||
|
<svg class="icon icon-sm" aria-hidden="true"><use xlink:href="#icon-trash" /></svg
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if items.length > 0}
|
||||||
|
<div class="card-footer justify-end">
|
||||||
|
<span class="mr-2 text-sm opacity-80">
|
||||||
|
{items.filter((i) => !i.completed).length} items left
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Add/Edit Modal -->
|
||||||
|
{#if isModalOpen}
|
||||||
|
<div
|
||||||
|
class="modal-backdrop open"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="modalTitle"
|
||||||
|
on:click|self={closeModal}
|
||||||
|
on:keydown={handleModalKeydown}
|
||||||
|
>
|
||||||
|
<div class="modal-container">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="modalTitle">{modalMode === 'add' ? 'Add New Item' : 'Edit Item'}</h3>
|
||||||
|
<button class="close-button" aria-label="Close modal" on:click={closeModal}>
|
||||||
|
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-close" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form on:submit|preventDefault={handleModalSubmit}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="itemInput" class="form-label">Item Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="itemInput"
|
||||||
|
class="form-input"
|
||||||
|
bind:value={inputText}
|
||||||
|
placeholder="e.g., Apples"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-neutral" on:click={closeModal}>Cancel</button>
|
||||||
|
<button class="btn ml-2" on:click={handleModalSubmit}>
|
||||||
|
{modalMode === 'add' ? 'Add Item' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
{#if modalMode === 'edit'}
|
||||||
|
<button
|
||||||
|
class="btn btn-danger ml-2"
|
||||||
|
title="Delete Item"
|
||||||
|
on:click={() => deleteItem(itemToEdit!.id)}
|
||||||
|
>
|
||||||
|
<svg class="icon icon-sm" aria-hidden="true"><use xlink:href="#icon-trash" /></svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Minimal styles specific to this component if needed -->
|
||||||
|
<style>
|
||||||
|
/* Add any minor style overrides here if global.css isn't perfect */
|
||||||
|
/* Example: Ensure touch actions are handled correctly */
|
||||||
|
.list-item {
|
||||||
|
/* touch-action: pan-y; /* Already added inline for specificity */
|
||||||
|
/* Prevent text selection during swipe */
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure modal backdrop is above everything */
|
||||||
|
.modal-backdrop {
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.modal-container {
|
||||||
|
z-index: 1001;
|
||||||
|
}
|
||||||
|
</style>
|
9
src/routes/+layout.svelte
Normal file
9
src/routes/+layout.svelte
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<script>
|
||||||
|
import '../app.css';
|
||||||
|
import AppShell from '$lib/components/AppShell.svelte';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div></div>
|
||||||
|
{@render children?.()}
|
1
src/routes/+layout.ts
Normal file
1
src/routes/+layout.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const ssr = false;
|
7
src/routes/+page.ts
Normal file
7
src/routes/+page.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
// import { authService } from '$lib/services/api';
|
||||||
|
// import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
// export async function load() {
|
||||||
|
// const page = authService.isAuthenticated() ? '/landing' : '/login';
|
||||||
|
// redirect(307, page);
|
||||||
|
// }
|
46
src/routes/login/+page.svelte
Normal file
46
src/routes/login/+page.svelte
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import LoginPage from '$lib/components/LoginPage.svelte';
|
||||||
|
import { goto } from '$app/navigation'; // Example using SvelteKit navigation
|
||||||
|
|
||||||
|
let isLoading = false;
|
||||||
|
let errorMessage: string | null = null;
|
||||||
|
|
||||||
|
// --- Mock Authentication Function ---
|
||||||
|
// Replace this with your actual API call to your backend
|
||||||
|
async function attemptLogin(email: string, password: string): Promise<void> {
|
||||||
|
console.log('Attempting login for:', email);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (email === 'test@example.com' && password === 'password') {
|
||||||
|
console.log('Login successful (mock)');
|
||||||
|
resolve(); // Simulate success
|
||||||
|
} else {
|
||||||
|
console.log('Login failed (mock)');
|
||||||
|
reject(new Error('Invalid email or password.')); // Simulate failure
|
||||||
|
}
|
||||||
|
}, 1500); // Simulate network delay
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Event Handler ---
|
||||||
|
async function handleLoginRequest(event: CustomEvent<{ email: string; password: string }>) {
|
||||||
|
isLoading = true;
|
||||||
|
errorMessage = null;
|
||||||
|
const { email, password } = event.detail;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await attemptLogin(email, password);
|
||||||
|
// SUCCESS: Store user session/token (using your auth library/stores)
|
||||||
|
// then navigate to the dashboard or protected area
|
||||||
|
goto('/dashboard'); // Navigate after successful login
|
||||||
|
} catch (err: any) {
|
||||||
|
// FAILURE: Display error
|
||||||
|
errorMessage = err.message || 'An unknown login error occurred.';
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Pass loading state and error message down, listen for the login event -->
|
||||||
|
<LoginPage bind:isLoading serverError={errorMessage} on:login={handleLoginRequest} />
|
0
src/routes/signup/+page.svelte
Normal file
0
src/routes/signup/+page.svelte
Normal file
BIN
static/favicon.png
Normal file
BIN
static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.5 KiB |
9
svelte.config.js
Normal file
9
svelte.config.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-node';
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
kit: { adapter: adapter() }
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
|
//
|
||||||
|
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||||
|
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||||
|
}
|
6
vite.config.ts
Normal file
6
vite.config.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [sveltekit()]
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user