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