add more ux and ui patches with extra login helper
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
let { cols = 3, children }: { cols?: number; children?: any } = $props();
|
||||
let { stackTablet = false, children }: { stackTablet?: boolean; children?: any } = $props();
|
||||
</script>
|
||||
|
||||
<div class="card-grid" style="--grid-cols: {cols}">
|
||||
<div class="card-grid" class:stack-tablet={stackTablet}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -10,18 +10,22 @@
|
||||
.card-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(var(--grid-cols, 3), 1fr);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
/* Tablet: settle to 2 columns */
|
||||
/* Tablet: two columns */
|
||||
@media (min-width: 640px) and (max-width: 1023px) {
|
||||
.card-grid {
|
||||
--grid-cols: 2;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
/* Opt-in: stack to a single column on tablet */
|
||||
.card-grid.stack-tablet {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
/* Mobile: single column */
|
||||
@media (max-width: 639px) {
|
||||
.card-grid {
|
||||
--grid-cols: 1;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import {
|
||||
dashboardIcon,
|
||||
choresIcon,
|
||||
rewardsIcon,
|
||||
bonusesIcon,
|
||||
archiveIcon,
|
||||
gemIcon,
|
||||
homeIcon,
|
||||
settingsIcon,
|
||||
logoutIcon,
|
||||
@@ -30,8 +30,8 @@
|
||||
{ href: `/${famSlug}`, label: famName, icon: homeIcon },
|
||||
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon },
|
||||
{ href: `/${famSlug}/${memberName}/chores`, label: 'Chores', icon: choresIcon },
|
||||
{ href: `/${famSlug}/${memberName}/ledger`, label: 'Ledger', icon: rewardsIcon },
|
||||
{ href: `/${famSlug}/${memberName}/bonuses`, label: 'Bonuses', icon: bonusesIcon }
|
||||
{ href: `/${famSlug}/${memberName}/rewards`, label: 'Rewards', icon: gemIcon },
|
||||
{ href: `/${famSlug}/${memberName}/ledger`, label: 'Ledger', icon: archiveIcon }
|
||||
]
|
||||
: showChildItems
|
||||
? [
|
||||
|
||||
@@ -1,7 +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 moneyIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>'
|
||||
export const handCoinsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 15h2a2 2 0 1 0 0-4h-3c-.6 0-1.1.2-1.4.6L3 17"/><path d="m7 21 1.6-1.4c.3-.4.8-.6 1.4-.6h4c1.1 0 2.1-.4 2.8-1.2l4.6-4.4a2 2 0 0 0-2.75-2.91l-4.2 3.9"/><path d="M2 16l1 1h2"/><path d="M4 12l1 1h2"/><path d="M6 8l1 1h2"/><path d="M2 20l1.5-.5"/><circle cx="16" cy="8" r="2"/></svg>'
|
||||
export const gemIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 3h12l4 6-10 13L2 9Z"/><path d="M11 3 8 9l4 13 4-13-3-6"/><path d="M2 9h20"/></svg>'
|
||||
export const archiveIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>'
|
||||
export const todoIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></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>'
|
||||
@@ -11,3 +15,5 @@ export const chevronRight = '<svg width="16" height="16" viewBox="0 0 24 24" fil
|
||||
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>'
|
||||
export const chatIcon = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>'
|
||||
export const sendIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>'
|
||||
export const checkCircleIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>'
|
||||
export const revokeIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>'
|
||||
|
||||
@@ -68,8 +68,12 @@ async function createRecord(collection: string, data: any): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Seed two default seasons (Holidays, Term time) per family so the family-admin
|
||||
// season picker always has options. Idempotent by name.
|
||||
// Seed a default season (Holidays) per family so the family-admin season
|
||||
// picker always has at least one option. "Term time" is intentionally NOT
|
||||
// auto-seeded — it's a UK-school-specific term that not every family wants,
|
||||
// and force-seeding it meant a deleted "Term time" kept coming back on every
|
||||
// boot. Families that want it can add it themselves.
|
||||
// Only seeds when a family has zero seasons, so user deletions stick.
|
||||
async function ensureDefaultSeasons(): Promise<void> {
|
||||
const famsCol = await getCollection("fams");
|
||||
if (!famsCol) return;
|
||||
@@ -78,18 +82,19 @@ async function ensureDefaultSeasons(): Promise<void> {
|
||||
headers: { Authorization: `Bearer ${t}` },
|
||||
});
|
||||
const fams = (await famsRes.json()).items || [];
|
||||
const defaults = [
|
||||
{ name: "Holidays", color: "#f59e0b", active: true },
|
||||
{ name: "Term time", color: "#3b82f6", active: true },
|
||||
];
|
||||
const defaults = [{ name: "Holidays", color: "#f59e0b", active: true }];
|
||||
for (const fam of fams) {
|
||||
const sRes = await fetch(
|
||||
`${PB_ENDPOINT}/api/collections/seasons/records?filter=(famId='${fam.id}')&fields=name&perPage=200`,
|
||||
{ headers: { Authorization: `Bearer ${t}` } }
|
||||
);
|
||||
const have = new Set(((await sRes.json()).items || []).map((s: any) => s.name));
|
||||
for (const d of defaults) {
|
||||
if (!have.has(d.name)) {
|
||||
const have = ((await sRes.json()).items || []) as any[];
|
||||
// Only seed defaults for families that have no seasons at all. Seeding
|
||||
// per-name (idempotent) forced a deleted default (e.g. "Term time") back
|
||||
// on every boot; now a user who removes one keeps it removed as long as
|
||||
// at least one season remains.
|
||||
if (have.length === 0) {
|
||||
for (const d of defaults) {
|
||||
await createRecord("seasons", { famId: fam.id, ...d });
|
||||
console.log(` ✓ Seeded season ${d.name} for fam ${fam.id}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export type Shortcut = {
|
||||
famSlug: string;
|
||||
famName: string;
|
||||
userName: string; // '' for parents (no handle in URL)
|
||||
};
|
||||
|
||||
const KEY = 'fam_shortcut';
|
||||
|
||||
export function recordShortcut(entry: Shortcut) {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
localStorage.setItem(KEY, JSON.stringify(entry));
|
||||
}
|
||||
|
||||
export function readShortcut(): Shortcut | null {
|
||||
if (typeof localStorage === 'undefined') return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
return raw ? (JSON.parse(raw) as Shortcut) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function shortcutTarget(s: Shortcut): string {
|
||||
return s.userName ? `/${s.famSlug}/${s.userName}` : `/${s.famSlug}`;
|
||||
}
|
||||
@@ -40,6 +40,7 @@ class ChatStore {
|
||||
private initPromise: Promise<void> | null = null;
|
||||
private lastSeenAt = 0;
|
||||
private typingTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
private audioCtx: AudioContext | null = null;
|
||||
|
||||
typingNames = $derived.by(() => {
|
||||
const names: string[] = [];
|
||||
@@ -156,8 +157,9 @@ class ChatStore {
|
||||
}
|
||||
// Stop showing their typing indicator once the message lands.
|
||||
this.clearTyping(record.authorId);
|
||||
if (record.authorId !== this.actorId && !this.open) {
|
||||
this.unread++;
|
||||
if (record.authorId !== this.actorId) {
|
||||
if (!this.open) this.unread++;
|
||||
this.playDunk();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +186,39 @@ class ChatStore {
|
||||
if (this.typing[actorId]) this.removeTyping(actorId);
|
||||
}
|
||||
|
||||
// Short, low-volume "dunk" blip played when a message from someone else is
|
||||
// broadcast in. Synthesised with the Web Audio API so there's no asset to
|
||||
// ship. Browsers require a user gesture before audio can start, but chat
|
||||
// messages only arrive after the user has interacted, so the context
|
||||
// resumes fine. Failures are silently ignored.
|
||||
private playDunk() {
|
||||
if (typeof window === 'undefined' || typeof AudioContext === 'undefined') return;
|
||||
try {
|
||||
const Ctor = window.AudioContext || (window as any).webkitAudioContext;
|
||||
if (!Ctor) return;
|
||||
if (!this.audioCtx) this.audioCtx = new Ctor();
|
||||
const ctx = this.audioCtx;
|
||||
if (ctx.state === 'suspended') ctx.resume();
|
||||
|
||||
const now = ctx.currentTime;
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = 'sine';
|
||||
// Quick downward pitch sweep gives the soft "dun-nk" thud.
|
||||
osc.frequency.setValueAtTime(190, now);
|
||||
osc.frequency.exponentialRampToValueAtTime(70, now + 0.18);
|
||||
// Low volume with a fast attack/decay envelope.
|
||||
gain.gain.setValueAtTime(0.0001, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.12, now + 0.01);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.22);
|
||||
osc.connect(gain).connect(ctx.destination);
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.24);
|
||||
} catch {
|
||||
/* audio is best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
private removeTyping(actorId: string) {
|
||||
if (!this.typing[actorId]) return;
|
||||
const next = { ...this.typing };
|
||||
|
||||
Reference in New Issue
Block a user