update ui and other frontend updates

This commit is contained in:
JCEEE
2026-07-29 09:41:52 +01:00
parent 04b83925e0
commit 57ef30d3a7
79 changed files with 7323 additions and 429 deletions
+39
View File
@@ -0,0 +1,39 @@
import { SERVER_IP, PROXY_PORT } from '../../../../config.ts';
const BASE_URL = typeof window === 'undefined'
? `http://${SERVER_IP}:${PROXY_PORT}`
: '';
async function memberFetch<T = unknown>(
method: string,
path: string,
token: string,
famId: string,
body?: unknown,
): Promise<T> {
const headers: Record<string, string> = {
'x-device-token': token,
'x-device-famid': famId,
};
if (body !== undefined) headers['Content-Type'] = 'application/json';
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || `${method} ${path} failed`);
return data as T;
}
export const memberApi = {
async toggleCompletion(token: string, famId: string, assignedChoreId: string, date: string) {
return memberFetch('POST', '/api/completions/toggle', token, famId, { assignedChoreId, date });
},
async myChores(token: string, famId: string) {
return memberFetch('POST', '/api/members/my-chores', token, famId);
},
async claimReward(token: string, famId: string, rewardId: string) {
return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`, token, famId);
},
};
@@ -0,0 +1,36 @@
<script lang="ts">
let { items }: { items: { title: string; content: any }[] } = $props();
let openIndex = $state<number | null>(null);
</script>
<div class="accordion">
{#each items as item, i}
<div class="accordion-item" class:open={openIndex === i}>
<button class="accordion-trigger" onclick={() => openIndex = openIndex === i ? null : i}>
<span>{item.title}</span>
<span class="accordion-arrow">{openIndex === i ? '▾' : '▸'}</span>
</button>
{#if openIndex === i}
<div class="accordion-body">
{@render item.content()}
</div>
{/if}
</div>
{/each}
</div>
<style>
.accordion { border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; }
.accordion-item { border-bottom: 1px solid #f3f4f6; }
.accordion-item:last-child { border-bottom: none; }
.accordion-trigger {
display: flex; justify-content: space-between; align-items: center;
width: 100%; padding: 0.7rem 1rem;
background: #fafafa; border: none;
font-size: 0.9rem; font-weight: 500; color: #374151;
cursor: pointer; text-align: left;
}
.accordion-trigger:hover { background: #f3f4f6; }
.accordion-arrow { font-size: 0.8rem; color: #9ca3af; }
.accordion-body { padding: 1rem; }
</style>
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
let { variant = 'primary', size = 'md', href, onclick, children, ...rest }: {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
href?: string;
onclick?: () => void;
children?: any;
[key: string]: unknown;
} = $props();
</script>
{#if href}
<a href={href} class="btn btn-{variant} btn-{size}" {...rest}>
{@render children?.()}
</a>
{:else}
<button class="btn btn-{variant} btn-{size}" {onclick} {...rest}>
{@render children?.()}
</button>
{/if}
<style>
.btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
text-decoration: none;
transition: background 0.15s, border-color 0.15s;
border: 1px solid transparent;
}
.btn-sm { padding: 0.3rem 0.6rem; font-size: 0.8rem; }
.btn-md { padding: 0.45rem 0.9rem; font-size: 0.85rem; }
.btn-lg { padding: 0.6rem 1.2rem; font-size: 0.95rem; }
.btn-primary { background: #4338ca; color: #fff; border-color: #4338ca; }
.btn-primary:hover { background: #3730a3; }
.btn-secondary { background: #f3f4f6; color: #374151; border-color: #d1d5db; }
.btn-secondary:hover { background: #e5e7eb; }
.btn-ghost { background: transparent; color: #6b7280; border-color: transparent; }
.btn-ghost:hover { background: #f3f4f6; }
.btn-danger { background: #dc2626; color: #fff; border-color: #dc2626; }
.btn-danger:hover { background: #b91c1c; }
</style>
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts">
let { cols = 1, title, accent, children }: { cols?: 1 | 2 | 3; title?: string; accent?: string; children?: any } = $props();
</script>
<div class="card" style="grid-column: span {cols}; {accent ? `--card-accent: ${accent}` : ''}" class:has-accent={!!accent}>
{#if title}
<div class="card-header">
<span class="card-title">{title}</span>
</div>
{/if}
<div class="card-body">
{@render children?.()}
</div>
</div>
<style>
.card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
overflow: hidden;
}
.card.has-accent { border-top: 3px solid var(--card-accent, #6366f1); }
.card-header {
padding: 0.75rem 1rem;
border-bottom: 1px solid #f3f4f6;
background: #fafafa;
}
.card-title { font-weight: 600; font-size: 0.9rem; color: #374151; }
.card-body { padding: 1rem; }
</style>
@@ -0,0 +1,21 @@
<script lang="ts">
let { children }: { children?: any } = $props();
</script>
<div class="card-grid">
{@render children?.()}
</div>
<style>
.card-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
@media (max-width: 900px) {
.card-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 600px) {
.card-grid { grid-template-columns: 1fr; }
}
</style>
+13
View File
@@ -0,0 +1,13 @@
<footer class="app-footer">
<span class="footer-text">FamChore</span>
</footer>
<style>
.app-footer {
padding: 1rem 1.5rem;
border-top: 1px solid #e5e7eb;
text-align: center;
font-size: 0.8rem;
color: #9ca3af;
}
</style>
+134
View File
@@ -0,0 +1,134 @@
<script lang="ts">
import { page } from '$app/state';
import { dashboardIcon, choresIcon, rewardsIcon, bonusesIcon, homeIcon, settingsIcon, logoutIcon, prefsIcon } from './icons';
let { famName = '', session = null, isParent = false, role = 'child' } = $props();
let collapsed = $state(false);
let famSlug = $derived(page.params.fam);
let memberName = $derived(session?.memberName || page.params.username || '');
function toggle() { collapsed = !collapsed; }
let showChildItems = $derived(!!memberName);
let navItems = $derived(isParent && memberName
? [
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon },
{ href: `/${famSlug}/${memberName}/chores`, label: 'Chores', icon: choresIcon },
{ href: `/${famSlug}/${memberName}/rewards`, label: 'Rewards', icon: rewardsIcon },
{ href: `/${famSlug}/${memberName}/bonuses`, label: 'Bonuses', icon: bonusesIcon },
{ href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon },
]
: showChildItems
? [
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon },
{ href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon },
]
: []
);
let footerItems = $derived([
{ href: `/${famSlug}`, label: famName, icon: homeIcon },
...(isParent && memberName ? [{ href: `/${famSlug}/${memberName}/settings`, label: 'Settings', icon: settingsIcon }] : []),
{ href: session ? '/logout' : '/login', label: session ? 'Log out' : 'Log in', icon: logoutIcon },
]);
</script>
<aside class="sidebar" class:collapsed>
<button class="toggle-btn" onclick={toggle}>
{collapsed ? '☰' : '✕'}
</button>
<div class="sidebar-header">
<span class="app-icon"></span>
{#if !collapsed}<span class="app-name">FamChore</span>{/if}
</div>
<nav class="sidebar-nav">
{#each navItems as item}
<a href={item.href} class="nav-item" class:active={page.url.pathname === item.href}>
{@html item.icon}
{#if !collapsed}<span class="nav-label">{item.label}</span>{/if}
</a>
{/each}
</nav>
<div class="sidebar-footer">
{#each footerItems as item}
<a href={item.href} class="nav-item">
{@html item.icon}
{#if !collapsed}<span class="nav-label">{item.label}</span>{/if}
</a>
{/each}
</div>
</aside>
<style>
.sidebar {
position: fixed;
top: 0; left: 0;
height: 100vh;
width: 220px;
background: #1e1b4b;
color: #e0e7ff;
display: flex;
flex-direction: column;
transition: width 0.2s;
z-index: 100;
overflow: hidden;
}
.sidebar.collapsed { width: 56px; }
.toggle-btn {
position: absolute;
top: 0.5rem; right: 0.5rem;
background: none;
border: none;
color: #a5b4fc;
font-size: 1.1rem;
cursor: pointer;
padding: 0.25rem;
line-height: 1;
}
.sidebar-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem 0.75rem;
border-bottom: 1px solid #3730a3;
min-height: 52px;
}
.app-icon { font-size: 1.3rem; flex-shrink: 0; }
.app-name { font-weight: 700; font-size: 1.05rem; white-space: nowrap; }
.sidebar-nav {
flex: 1;
padding: 0.5rem 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.sidebar-footer {
border-top: 1px solid #3730a3;
padding: 0.5rem 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.75rem;
color: #c7d2fe;
text-decoration: none;
font-size: 0.9rem;
border-radius: 6px;
margin: 0 0.3rem;
white-space: nowrap;
transition: background 0.15s;
}
.nav-item:hover { background: #3730a3; color: #e0e7ff; }
.nav-item.active { background: #4338ca; color: #fff; font-weight: 600; }
.nav-label { overflow: hidden; }
</style>
+30
View File
@@ -0,0 +1,30 @@
<script lang="ts">
let { announcement = '', children }: { announcement?: string; children?: any } = $props();
</script>
<header class="topnav">
<div class="topnav-announcement">
{#if announcement}<span class="announcement-text">{announcement}</span>{/if}
</div>
<div class="topnav-actions">
{@render children?.()}
</div>
</header>
<style>
.topnav {
position: fixed;
top: 0; left: 220px; right: 0;
height: 48px;
background: #fff;
border-bottom: 1px solid #e5e7eb;
display: flex;
align-items: center;
padding: 0 1.5rem;
z-index: 90;
transition: left 0.2s;
}
.topnav-announcement { flex: 1; text-align: center; }
.announcement-text { font-size: 0.85rem; color: #6b7280; }
.topnav-actions { display: flex; align-items: center; gap: 0.5rem; }
</style>
@@ -0,0 +1,71 @@
<script lang="ts">
let { title, subtitle, tabs, weeknav, sort }: {
title: string;
subtitle?: string;
tabs?: { items: { label: string; value: string }[]; active: string; onchange: (v: string) => void };
weeknav?: { current: string; onPrev: () => void; onNext: () => void };
sort?: { options: { label: string; value: string }[]; active: string; onchange: (v: string) => void };
} = $props();
</script>
<div class="view-header">
<div class="view-title-group">
<h1 class="view-title">{title}</h1>
{#if subtitle}<p class="view-subtitle">{subtitle}</p>{/if}
</div>
<div class="view-tools">
{#if tabs}
<div class="tab-bar">
{#each tabs.items as tab}
<button
class="tab-btn"
class:active={tab.value === tabs.active}
onclick={() => tabs.onchange(tab.value)}
>{tab.label}</button>
{/each}
</div>
{/if}
{#if weeknav}
<div class="week-nav">
<button class="nav-btn" onclick={weeknav.onPrev}></button>
<span class="nav-label">{weeknav.current}</span>
<button class="nav-btn" onclick={weeknav.onNext}></button>
</div>
{/if}
{#if sort}
<select class="sort-select" value={sort.active} onchange={(e) => sort.onchange(e.currentTarget.value)}>
{#each sort.options as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{/if}
</div>
</div>
<style>
.view-header { margin-bottom: 1.5rem; }
.view-title-group { margin-bottom: 0.5rem; }
.view-title { font-size: 1.4rem; font-weight: 700; color: #111827; margin: 0; }
.view-subtitle { font-size: 0.9rem; color: #6b7280; margin: 0.25rem 0 0 0; }
.view-tools { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.tab-bar { display: flex; gap: 2px; background: #f3f4f6; border-radius: 6px; padding: 2px; }
.tab-btn {
padding: 0.35rem 0.85rem; border: none; background: transparent; border-radius: 5px;
font-size: 0.85rem; color: #6b7280; cursor: pointer;
}
.tab-btn.active { background: #fff; color: #4338ca; font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.week-nav { display: flex; align-items: center; gap: 0.5rem; }
.nav-btn {
padding: 0.2rem 0.5rem; border: 1px solid #d1d5db; background: #fff; border-radius: 4px;
cursor: pointer; font-size: 1rem; line-height: 1;
}
.nav-btn:hover { background: #f3f4f6; }
.nav-label { font-size: 0.85rem; color: #374151; font-weight: 500; min-width: 140px; text-align: center; }
.sort-select {
padding: 0.3rem 0.6rem; border: 1px solid #d1d5db; border-radius: 5px;
font-size: 0.85rem; background: #fff;
}
</style>
+11
View File
@@ -0,0 +1,11 @@
export const dashboardIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>'
export const choresIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>'
export const rewardsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89L17 22l-5-3-5 3 1.523-9.11"/></svg>'
export const bonusesIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>'
export const homeIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>'
export const settingsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>'
export const logoutIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>'
export const prefsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>'
export const chevronLeft = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>'
export const chevronRight = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>'
export const bellIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg>'
+8
View File
@@ -0,0 +1,8 @@
export { default as Sidebar } from './Sidebar.svelte';
export { default as TopNav } from './TopNav.svelte';
export { default as Footer } from './Footer.svelte';
export { default as ViewHeader } from './ViewHeader.svelte';
export { default as Card } from './Card.svelte';
export { default as CardGrid } from './CardGrid.svelte';
export { default as Button } from './Button.svelte';
export { default as Accordion } from './Accordion.svelte';
+9
View File
@@ -0,0 +1,9 @@
export async function sha256(input: string): Promise<string> {
const hash = await globalThis.crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(input),
);
return Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
+12 -3
View File
@@ -1,5 +1,14 @@
import PocketBase from 'pocketbase';
import { SERVER_IP, PB_PORT } from '$app/env/public';
import { PUBLIC_PB_URL } from '$app/env/public';
const url = `http://${SERVER_IP}:${PB_PORT}`;
export const pb: PocketBase = new PocketBase(url);
export const pb = new PocketBase(PUBLIC_PB_URL);
pb.autoCancellation(false);
export function initPbFromCookie() {
const match = document.cookie.match(/(?:^|;\s*)pb_token=([^;]*)/);
if (match) {
pb.authStore.save(match[1], null);
return true;
}
return false;
}
+87
View File
@@ -0,0 +1,87 @@
import { redirect } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { SERVER_IP, PROXY_PORT } from '../../../../config.ts';
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
export function getSession(event: RequestEvent) {
return event.locals.session;
}
export function requireAuth(event: RequestEvent) {
const session = getSession(event);
if (!session) {
throw redirect(303, '/login');
}
return session;
}
export async function signup(email: string, password: string, famName: string, parentName?: string) {
const res = await fetch(`${HONO_URL}/api/admin/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, famName, parentName }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Signup failed');
return data;
}
export async function login(email: string, password: string) {
console.log(email);
const res = await fetch(`${HONO_URL}/api/admin/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Login failed');
return data;
}
export async function joinMember(inviteCode: string, name: string, deviceToken: string) {
const res = await fetch(`${HONO_URL}/api/members/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inviteCode, name, deviceToken }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Join failed');
return data;
}
export function setSessionCookie(event: RequestEvent, session: { famId: string; userId: string; famSlug: string }) {
event.cookies.set('session', JSON.stringify(session), {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 30,
secure: false,
});
}
export function setDeviceTokenCookie(event: RequestEvent, token: string) {
event.cookies.set('device_token', token, {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 365,
secure: false,
});
}
export function setPbTokenCookie(event: RequestEvent, token: string) {
event.cookies.set('pb_token', token, {
httpOnly: false,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24,
secure: false,
});
}
export function clearSession(event: RequestEvent) {
event.cookies.delete('session', { path: '/' });
event.cookies.delete('pb_token', { path: '/' });
event.cookies.delete('device_token', { path: '/' });
}
+111
View File
@@ -0,0 +1,111 @@
import { SERVER_IP, PROXY_PORT } from '../../../../config.ts';
import type { RequestEvent } from '@sveltejs/kit';
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
function sessionHeaders(event: RequestEvent): Record<string, string> {
const s = event.locals.session;
if (!s) return {};
return {
'x-session-famid': s.famId,
'x-session-userid': s.userId,
'Content-Type': 'application/json',
};
}
async function request(method: string, path: string, body?: unknown, headers?: Record<string, string>) {
const res = await fetch(`${HONO_URL}${path}`, {
method,
headers: headers || { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || `${method} ${path} failed`);
return data;
}
export const hono = {
admin: {
async list(event: RequestEvent, resource: string, famId: string) {
return request('GET', `/api/admin/${famId}/${resource}`, undefined, sessionHeaders(event));
},
async create(event: RequestEvent, resource: string, famId: string, data: Record<string, unknown>) {
return request('POST', `/api/admin/${famId}/${resource}`, data, sessionHeaders(event));
},
async update(event: RequestEvent, resource: string, famId: string, id: string, data: Record<string, unknown>) {
return request('PATCH', `/api/admin/${famId}/${resource}/${id}`, data, sessionHeaders(event));
},
async remove(event: RequestEvent, resource: string, famId: string, id: string) {
return request('DELETE', `/api/admin/${famId}/${resource}/${id}`, undefined, sessionHeaders(event));
},
async renameFam(event: RequestEvent, famId: string, name: string) {
return request('PATCH', `/api/admin/${famId}/fam`, { name }, sessionHeaders(event));
},
async updatePayday(event: RequestEvent, famId: string, payday: number) {
return request('PATCH', `/api/admin/${famId}/fam`, { payday }, sessionHeaders(event));
},
async fam(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/fam`, undefined, sessionHeaders(event));
},
async verify(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/verify`, undefined, sessionHeaders(event));
},
async regenInvite(event: RequestEvent, famId: string) {
return request('POST', `/api/admin/${famId}/regen-invite`, undefined, sessionHeaders(event));
},
async weeklySummary(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/weekly-summary`, undefined, sessionHeaders(event));
},
async completions(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/completions`, undefined, sessionHeaders(event));
},
async rewards(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/rewards`, undefined, sessionHeaders(event));
},
async claimReward(event: RequestEvent, famId: string, rewardId: string) {
return request('POST', `/api/admin/${famId}/rewards/${rewardId}/claim`, undefined, sessionHeaders(event));
},
async bonusConfigs(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/bonus-configs`, undefined, sessionHeaders(event));
},
async bonusConfigProgress(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/bonus-configs/progress`, undefined, sessionHeaders(event));
},
async evaluateBonusConfig(event: RequestEvent, famId: string, configId?: string) {
return request('POST', `/api/admin/${famId}/bonus-configs/evaluate`, { configId }, sessionHeaders(event));
},
async triggerBonusConfig(event: RequestEvent, famId: string, configId: string, memberId?: string) {
return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/trigger`, { memberId }, sessionHeaders(event));
},
async assignBonusConfig(event: RequestEvent, famId: string, configId: string, data: Record<string, unknown>) {
return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/assign`, data, sessionHeaders(event));
},
async completeBonusConfig(event: RequestEvent, famId: string, configId: string, phase?: string) {
return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/complete`, { phase }, sessionHeaders(event));
},
async destroyBonusConfig(event: RequestEvent, famId: string, configId: string) {
return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/destroy`, undefined, sessionHeaders(event));
},
async bonusConfigTallies(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/bonus-configs/tallies`, undefined, sessionHeaders(event));
},
async revokeCompletion(event: RequestEvent, famId: string, completionId: string) {
return request('POST', `/api/admin/${famId}/completions/${completionId}/revoke`, undefined, sessionHeaders(event));
},
async sendMessage(event: RequestEvent, famId: string, memberId: string, message: string) {
return request('POST', `/api/admin/${famId}/send-message`, { memberId, message }, sessionHeaders(event));
},
async memberChores(event: RequestEvent, famId: string, memberId: string) {
return request('GET', `/api/admin/${famId}/members/${memberId}/chores`, undefined, sessionHeaders(event));
},
async updateMember(event: RequestEvent, famId: string, memberId: string, data: Record<string, unknown>) {
return request('PATCH', `/api/admin/${famId}/members/${memberId}`, data, sessionHeaders(event));
},
async getProfile(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/profile`, undefined, sessionHeaders(event));
},
async updateProfile(event: RequestEvent, famId: string, data: Record<string, unknown>) {
return request('PATCH', `/api/admin/${famId}/profile`, data, sessionHeaders(event));
},
},
};
+45
View File
@@ -0,0 +1,45 @@
import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from '../../../../config.ts';
const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`;
let token: string | null = null;
let tokenExpiry = 0;
async function ensureToken(): Promise<string> {
if (token && Date.now() < tokenExpiry) return token;
const res = await fetch(`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }),
});
const data = await res.json();
if (!res.ok) throw new Error(`PB admin auth failed: ${JSON.stringify(data)}`);
token = data.token;
tokenExpiry = Date.now() + 23 * 60 * 60 * 1000;
return token!;
}
export const pbAdmin = {
async getList(collection: string, filter = '') {
const t = await ensureToken();
const params = new URLSearchParams();
if (filter) params.set('filter', filter);
params.set('perPage', '200');
const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records?${params}`, {
headers: { Authorization: `Bearer ${t}` },
});
const data = await res.json();
if (!res.ok) throw new Error(`PB list ${collection}: ${JSON.stringify(data)}`);
return data.items || [];
},
async getOne(collection: string, id: string) {
const t = await ensureToken();
const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records/${id}`, {
headers: { Authorization: `Bearer ${t}` },
});
const data = await res.json();
if (!res.ok) throw new Error(`PB get ${collection}/${id}: ${JSON.stringify(data)}`);
return data;
},
};
+155
View File
@@ -0,0 +1,155 @@
import { pb } from '$lib/pocketbase';
import type {
Member, ChoreTemplate, AssignedChore, Completion,
WeeklyHistory, Reward, BonusConfig, Fam,
} from '$lib/types';
type CollectionName = 'members' | 'chore_templates' | 'assigned_chores' | 'completions' | 'bonus_configs' | 'rewards';
class FamStore {
fam = $state<Fam | null>(null)
members = $state<Member[]>([])
templates = $state<ChoreTemplate[]>([])
assigned = $state<AssignedChore[]>([])
completions = $state<Completion[]>([])
history = $state<WeeklyHistory[]>([])
rewards = $state<Reward[]>([])
bonusConfigs = $state<BonusConfig[]>([])
initialized = $state(false)
famId = $state('')
private unsubs: (() => void)[] = []
private destroyed = false
memberMap(): Map<string, Member> {
return new Map(this.members.map((m) => [m.id, m]))
}
templateMap(): Map<string, ChoreTemplate> {
return new Map(this.templates.map((t) => [t.id, t]))
}
bonusConfigMap(): Map<string, BonusConfig> {
return new Map(this.bonusConfigs.map((b) => [b.id, b]))
}
assignedForMember(memberId: string): AssignedChore[] {
return this.assigned.filter((a) => a.memberId === memberId)
}
completionsForDate(date: string): Completion[] {
return this.completions.filter((c) => (c.date?.slice(0, 10) || c.date) === date)
}
isCompleted(assignedChoreId: string, date: string): boolean {
return this.completions.some((c) => c.assignedChoreId === assignedChoreId && (c.date?.slice(0, 10) || c.date) === date)
}
private initPromise: Promise<void> | null = null
async init(famId: string) {
if (this.initialized && this.famId === famId) return
// Wait for any in-flight init to finish first
if (this.initPromise) {
await this.initPromise
if (this.initialized && this.famId === famId) return
}
this.cleanup()
this.destroyed = false
this.famId = famId
this.initPromise = (async () => {
try {
const [famRes, membersRes, templatesRes, assignedRes, completionsRes, bonusConfigsRes, rewardsRes] =
await Promise.all([
pb.collection('fams').getOne(famId) as Promise<Fam>,
pb.collection('members').getFullList({ filter: `famId = '${famId}'` }) as Promise<Member[]>,
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }) as Promise<ChoreTemplate[]>,
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }) as Promise<AssignedChore[]>,
pb.collection('completions').getFullList({ filter: `famId = '${famId}'` }) as Promise<Completion[]>,
pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}'` }) as Promise<BonusConfig[]>,
pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }) as Promise<Reward[]>,
])
this.fam = famRes
this.members = membersRes
this.templates = templatesRes
this.assigned = assignedRes
this.completions = completionsRes
this.bonusConfigs = bonusConfigsRes
this.rewards = rewardsRes
this.initialized = true
} catch (e) {
console.error('FamStore.init failed:', e)
this.initPromise = null
throw e
}
await this.subscribe()
this.initPromise = null
})()
return this.initPromise!
}
private async subscribe() {
const subs: { collection: CollectionName; filter?: string }[] = [
{ collection: 'members', filter: this.famId },
{ collection: 'chore_templates', filter: this.famId },
{ collection: 'assigned_chores', filter: this.famId },
{ collection: 'completions', filter: this.famId },
{ collection: 'bonus_configs', filter: this.famId },
{ collection: 'rewards', filter: this.famId },
]
const promises = subs.map(({ collection, filter }) => {
const filterStr = filter ? `famId = '${filter}'` : ''
return pb.collection(collection).subscribe('*', (data: any) => {
if (this.destroyed) return
this.handleRealtime(collection, data.action, data.record)
}, { filter: filterStr || undefined }).then((unsub) => {
if (this.destroyed) { unsub(); return }
this.unsubs.push(unsub)
}).catch((err: Error) => {
console.error(`[famStore] subscribe failed for ${collection}:`, err)
})
})
await Promise.allSettled(promises)
}
// Called from form action callbacks for instant UI feedback,
// and from PB subscribe SSE for multi-user realtime.
applyRecord(collection: CollectionName, record: any, action: 'create' | 'update' | 'delete') {
const apply = <T extends { id: string }>(list: T[]): T[] => {
if (action === 'create') return [record, ...list]
if (action === 'update') return list.map((x) => (x.id === record.id ? { ...x, ...record } : x))
if (action === 'delete') return list.filter((x) => x.id !== record.id)
return list
}
switch (collection) {
case 'members': this.members = apply(this.members); break
case 'chore_templates': this.templates = apply(this.templates); break
case 'assigned_chores': this.assigned = apply(this.assigned); break
case 'completions': this.completions = apply(this.completions); break
case 'bonus_configs': this.bonusConfigs = apply(this.bonusConfigs); break
case 'rewards': this.rewards = apply(this.rewards); break
}
}
private handleRealtime(collection: string, action: string, record: any) {
this.applyRecord(collection as CollectionName, record, action as 'create' | 'update' | 'delete')
}
cleanup() {
// TODO - we need to properly unsubscribe from pocketbase
this.destroyed = true
for (const unsub of this.unsubs) unsub()
this.unsubs = []
this.initialized = false
}
}
export const famStore = new FamStore()
+142
View File
@@ -0,0 +1,142 @@
export type Frequency = 'daily' | 'weekly'
export type RewardType = 'points' | 'money'
export type BonusTarget = 'individual' | 'competitive' | 'collaborative'
export type BonusType = 'threshold' | 'count' | 'manual'
export type BonusOccurrence = 'recurring' | 'once'
export type BonusRewardType = 'points' | 'cash' | 'prize'
export type BonusPeriod = 'weekly' | 'monthly'
export type BonusStatus = 'active' | 'archived'
export type BonusState = 'pending' | 'unclaimed' | 'claimed'
export interface Fam {
id: string
name: string
slug: string
inviteCode: string
stripeCustomerId?: string
featureFlags: Record<string, boolean>
created: string
updated: string
}
export interface Member {
id: string
famId: string
name: string
color: string
deviceToken: string
deviceTokenHint: string
created: string
updated: string
}
export interface ChoreTemplate {
id: string
famId: string
name: string
description?: string
defaultFrequency: Frequency
defaultType: RewardType
defaultValue: number
created: string
updated: string
}
export interface AssignedChore {
id: string
famId: string
memberId: string
templateId: string
frequency: Frequency
type: RewardType
value: number
customName?: string
created: string
updated: string
}
export interface Completion {
id: string
famId: string
memberId: string
assignedChoreId: string
date: string
completedAt: string
}
export interface WeeklyHistory {
id: string
famId: string
memberId: string
weekStart: string
pointsEarned: number
moneyEarned: number
choresCompleted: number
bonusEarned: number
}
export interface BonusConfig {
id: string
famId: string
name: string
description?: string
target: BonusTarget
memberId?: string
type: BonusType
occurrence: BonusOccurrence
rewardType: BonusRewardType
rewardValue: string
criteriaValue?: number
period?: BonusPeriod
status: BonusStatus
phase?: 'template' | 'ready' | 'active' | 'completed'
created: string
updated: string
}
export interface Reward {
id: string
famId: string
memberId: string
bonusConfigId?: string
label: string
value: number
rewardType: BonusRewardType
claimed: boolean
claimedAt?: string
date: string
created: string
updated: string
}
export interface BonusProgress {
memberId: string
memberName: string
memberColor: string
current: number
criteriaValue: number
reward: { id: string; claimed: boolean } | null
state: BonusState
achieved: boolean
}
export interface BonusConfigWithProgress {
config: BonusConfig
progress: BonusProgress[]
periodStart: string
periodEnd: string
}
export interface Settings {
id: string
famId: string
webhookUrl?: string
}
export interface Session {
famId: string
userId: string
famSlug: string
memberName?: string
role?: string
}