weeee💃

This commit is contained in:
Mohamad.Elsena 2025-04-07 17:04:04 +02:00
commit 442e503526
34 changed files with 8264 additions and 0 deletions

23
.gitignore vendored Normal file
View 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-*

1
.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

6
.prettierignore Normal file
View File

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

15
.prettierrc Normal file
View 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
View 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
View File

37
eslint.config.js Normal file
View 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

File diff suppressed because it is too large Load Diff

38
package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

13
src/app.d.ts vendored Normal file
View 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
View 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>

View 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">&copy; {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>

View File

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

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

151
src/lib/stores.ts Normal file
View 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
View 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
View 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
View 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

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

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

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

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

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

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

@ -0,0 +1 @@
export const ssr = false;

7
src/routes/+page.ts Normal file
View 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);
// }

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

View File

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

9
svelte.config.js Normal file
View 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
View 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
View File

@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});