Compare commits
8 Commits
097106fa69
...
486f239fb1
| Author | SHA1 | Date | |
|---|---|---|---|
| 486f239fb1 | |||
| a5e3b80ff6 | |||
| 41033914b4 | |||
| 4730ab3eb9 | |||
| a270d965fe | |||
| 27492b1558 | |||
| 897e203dab | |||
| f802113cd1 |
@@ -55,7 +55,10 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
username: handleOf(record.username || ''),
|
||||
role: record.role || 'parent',
|
||||
famId: record.famId,
|
||||
color: record.color || ''
|
||||
color: record.color || '',
|
||||
pattern: record.pattern || '',
|
||||
themeSize: record.themeSize || '',
|
||||
themeOpacity: record.themeOpacity || ''
|
||||
} satisfies SessionUser;
|
||||
event.locals.pbToken = freshToken;
|
||||
|
||||
|
||||
@@ -7,7 +7,15 @@
|
||||
children,
|
||||
class: className,
|
||||
selected = false
|
||||
}: { cols?: 1 | 2 | 3 | 4 | 5 | 6; title?: string; accent?: string; scrollX?: boolean; children?: any; class?: string; selected?: boolean } = $props();
|
||||
}: {
|
||||
cols?: 1 | 2 | 3 | 4 | 5 | 6;
|
||||
title?: string;
|
||||
accent?: string;
|
||||
scrollX?: boolean;
|
||||
children?: any;
|
||||
class?: string;
|
||||
selected?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -44,6 +52,14 @@
|
||||
.card.has-accent {
|
||||
border-top: 3px solid var(--card-accent, #6366f1);
|
||||
}
|
||||
/* Background pattern active (has-pattern on the layout root): cards go
|
||||
translucent so the pattern shows through. Desktop/tablet only — mobile
|
||||
cards are already fully transparent. */
|
||||
@media (min-width: 640px) {
|
||||
:global(.has-pattern) .card {
|
||||
background: #ffffffd9;
|
||||
}
|
||||
}
|
||||
/* Tablet (2-col grid): anything spanning 3+ collapses to a full row (span 2) */
|
||||
@media (min-width: 640px) and (max-width: 1023px) {
|
||||
.card[data-cols='3'],
|
||||
@@ -53,12 +69,6 @@
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
/* Mobile (1-col grid): every card is a full row */
|
||||
@media (max-width: 639px) {
|
||||
.card {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
.card-header {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
@@ -76,4 +86,17 @@
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
</style>
|
||||
/* Mobile (1-col grid): every card is a full row — declared last so it
|
||||
overrides the base .card/.card-body rules (same specificity). */
|
||||
@media (max-width: 639px) {
|
||||
.card {
|
||||
grid-column: span 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
.card-body {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -298,7 +298,7 @@
|
||||
/* Mobile: full-screen fixed layer. */
|
||||
@media (max-width: 767.98px) {
|
||||
.chat-panel {
|
||||
width: 100vw;
|
||||
width: 100dvw;
|
||||
}
|
||||
}
|
||||
.chat-head {
|
||||
|
||||
@@ -96,5 +96,18 @@
|
||||
.footer-aside {
|
||||
right: 1rem;
|
||||
}
|
||||
.footer-row {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.footer-star {
|
||||
position: static;
|
||||
}
|
||||
.footer-aside {
|
||||
position: static;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<script lang="ts">
|
||||
import '../theme-patterns.css';
|
||||
import {
|
||||
themeShades,
|
||||
PATTERN_COUNT,
|
||||
patternId,
|
||||
THEME_SIZE_MIN,
|
||||
THEME_SIZE_MAX,
|
||||
THEME_SIZE_DEFAULT,
|
||||
THEME_OPACITY_MIN,
|
||||
THEME_OPACITY_MAX,
|
||||
THEME_OPACITY_STEP,
|
||||
THEME_OPACITY_DEFAULT
|
||||
} from '$lib/theme';
|
||||
|
||||
let {
|
||||
value = '',
|
||||
color = '#6366f1',
|
||||
size = THEME_SIZE_DEFAULT,
|
||||
opacity = THEME_OPACITY_DEFAULT,
|
||||
onchange,
|
||||
onsize,
|
||||
onopacity
|
||||
}: {
|
||||
value?: string;
|
||||
color?: string;
|
||||
size?: number;
|
||||
opacity?: number;
|
||||
onchange: (v: string) => void;
|
||||
onsize?: (v: number) => void;
|
||||
onopacity?: (v: number) => void;
|
||||
} = $props();
|
||||
|
||||
let shades = $derived(themeShades(color));
|
||||
// Inline vars beat the pattern class defaults; --s shrinks the pattern so
|
||||
// thumbnails show a sample of the tiling.
|
||||
let vars = $derived(
|
||||
`--c1:${shades.c1};--c2:${shades.c2};--c3:${shades.c3};--c4:${shades.c4};--s:26px`
|
||||
);
|
||||
|
||||
// Local slider state — bound directly to the inputs (no controlled-value
|
||||
// re-render mid-drag), then emitted on input for the live preview.
|
||||
let sizeVal = $state(size);
|
||||
let opacityVal = $state(opacity);
|
||||
// Keep local state in sync if the props ever change (e.g. after a save).
|
||||
$effect(() => {
|
||||
sizeVal = size;
|
||||
});
|
||||
$effect(() => {
|
||||
opacityVal = opacity;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="pattern-grid">
|
||||
<button
|
||||
type="button"
|
||||
class="pattern-tile none"
|
||||
class:selected={value === ''}
|
||||
title="No background pattern"
|
||||
onclick={() => onchange('')}
|
||||
>
|
||||
<span class="none-dot" style="background:{color}"></span>
|
||||
</button>
|
||||
{#each Array(PATTERN_COUNT) as _, i}
|
||||
{@const id = patternId(i + 1)}
|
||||
<button
|
||||
type="button"
|
||||
class="pattern-tile pattern-{id}"
|
||||
class:selected={value === id}
|
||||
title={`Pattern ${i + 1}`}
|
||||
style={vars}
|
||||
onclick={() => onchange(id)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if value !== ''}
|
||||
{#if onsize}
|
||||
<div class="slider-row">
|
||||
<div class="slider-head">
|
||||
<label for="theme-size">Pattern size</label>
|
||||
<span class="slider-val">{sizeVal}</span>
|
||||
</div>
|
||||
<input
|
||||
id="theme-size"
|
||||
type="range"
|
||||
min={THEME_SIZE_MIN}
|
||||
max={THEME_SIZE_MAX}
|
||||
step="0.5"
|
||||
bind:value={sizeVal}
|
||||
oninput={() => onsize?.(sizeVal)}
|
||||
/>
|
||||
<span class="slider-hint">scales with screen width</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if onopacity}
|
||||
<div class="slider-row">
|
||||
<div class="slider-head">
|
||||
<label for="theme-opacity">Opacity</label>
|
||||
<span class="slider-val">{Math.round(opacityVal * 100)}%</span>
|
||||
</div>
|
||||
<input
|
||||
id="theme-opacity"
|
||||
type="range"
|
||||
min={THEME_OPACITY_MIN}
|
||||
max={THEME_OPACITY_MAX}
|
||||
step={THEME_OPACITY_STEP}
|
||||
bind:value={opacityVal}
|
||||
oninput={() => onopacity?.(opacityVal)}
|
||||
/>
|
||||
<span class="slider-hint">max = current look</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.pattern-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(56px, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
/* Static grid — currently 19 tiles fit without scrolling. Padding keeps
|
||||
the selected tile's outline from clipping at the top-left corner. */
|
||||
padding: 0.4rem;
|
||||
}
|
||||
.pattern-tile {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
/* No background here — the imported .pattern-pN classes supply it.
|
||||
A local background (0,2,0) would beat .pattern-pN (0,1,0) and hide
|
||||
every preview. */
|
||||
transition: outline 0.12s ease;
|
||||
}
|
||||
.pattern-tile:hover {
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
.pattern-tile.selected {
|
||||
outline: 3px solid #111827;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.pattern-tile.none {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
}
|
||||
.none-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.slider-row {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: #374151;
|
||||
}
|
||||
.slider-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.slider-head label {
|
||||
font-weight: 600;
|
||||
}
|
||||
.slider-row input[type='range'] {
|
||||
width: 100%;
|
||||
display: block;
|
||||
accent-color: #6366f1;
|
||||
}
|
||||
.slider-val {
|
||||
font-weight: 700;
|
||||
color: #4338ca;
|
||||
}
|
||||
.slider-hint {
|
||||
color: #9ca3af;
|
||||
font-size: 0.72rem;
|
||||
display: block;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
</style>
|
||||
@@ -147,6 +147,9 @@
|
||||
}
|
||||
.price { margin: 0.55rem 0 0.15rem; line-height: 1; }
|
||||
.amount { font-size: 2.4rem; font-weight: 800; color: #111827; }
|
||||
@media (max-width: 639px) {
|
||||
.amount { font-size: 1.5em; }
|
||||
}
|
||||
.period { font-size: 0.85rem; color: #9ca3af; margin-left: 0.3rem; }
|
||||
.blurb { margin: 0 0 0.9rem; font-size: 0.85rem; color: #6b7280; }
|
||||
|
||||
|
||||
@@ -11,8 +11,14 @@
|
||||
prefsIcon
|
||||
} from './icons';
|
||||
import FamDone from './FamDone.svelte';
|
||||
import { famStore } from '$lib/stores/fam.svelte';
|
||||
|
||||
let { famName = '', session = null, isParent = false, role = 'child' } = $props();
|
||||
// Outstanding claim requests (children asked → parent still to issue).
|
||||
let requestedClaims = $derived(
|
||||
famStore.initialized ? famStore.rewards.filter((r: any) => r.status === 'requested').length : 0
|
||||
);
|
||||
|
||||
let { famName = '', session = null, isParent = false, role = 'child', isDemo = false } = $props();
|
||||
|
||||
let famSlug = $derived(page.data.famSlug ?? page.params.fam);
|
||||
let memberName = $derived(session?.memberName || page.params.username || '');
|
||||
@@ -37,10 +43,10 @@
|
||||
);
|
||||
|
||||
let footerItems = $derived([
|
||||
...(memberName
|
||||
...(memberName && !isDemo
|
||||
? [{ href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon }]
|
||||
: []),
|
||||
...(isParent && memberName
|
||||
...(isParent && memberName && !isDemo
|
||||
? [{ href: `/${famSlug}/${memberName}/settings`, label: 'Settings', icon: settingsIcon }]
|
||||
: []),
|
||||
{
|
||||
@@ -62,6 +68,9 @@
|
||||
<a href={item.href} class="nav-item" class:active={page.url.pathname === item.href}>
|
||||
{@html item.icon}
|
||||
<span class="nav-label">{item.label}</span>
|
||||
{#if isParent && item.label === 'Dashboard' && requestedClaims > 0}
|
||||
<span class="nav-badge">{requestedClaims}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
@@ -147,6 +156,19 @@
|
||||
margin: 0 0.3rem;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
.nav-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 6px;
|
||||
background: #f59e0b;
|
||||
color: #fff;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.nav-item:hover {
|
||||
background: #3730a3;
|
||||
|
||||
@@ -73,6 +73,14 @@
|
||||
<style>
|
||||
.view-header {
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
/* Stack title above tools so the header doesn't squish on phones */
|
||||
.view-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
.view-title-group {
|
||||
margin-bottom: 0.5rem;
|
||||
@@ -82,6 +90,11 @@
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.view-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
.view-subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: #6b7280;
|
||||
@@ -90,11 +103,19 @@
|
||||
.view-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
/* Tools row takes the full header width so the title never gets squeezed */
|
||||
width: 100%;
|
||||
}
|
||||
/* Immediate tools (tab bar / week nav / sort) grow to span the row; they
|
||||
wrap to their own full-width line only when they don't fit side by side */
|
||||
.view-tools > * {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 2px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 6px;
|
||||
@@ -118,6 +139,7 @@
|
||||
.week-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.nav-btn {
|
||||
|
||||
@@ -1,33 +1,66 @@
|
||||
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 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 coinsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v6c0 1.66 3.58 3 8 3s8-1.34 8-3V6"/><path d="M4 12v6c0 1.66 3.58 3 8 3s8-1.34 8-3v-6"/></svg>'
|
||||
export const walletIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>'
|
||||
export const circleIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></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>'
|
||||
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>'
|
||||
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>'
|
||||
export const targetIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>'
|
||||
export const calendarIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>'
|
||||
export const sunIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>'
|
||||
export const lockIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>'
|
||||
export const clockIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>'
|
||||
export const giftIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="8" width="18" height="4"/><path d="M12 8v13M12 8C12 8 9 3 6 5s0 3 3 3M12 8c0 0 3-5 6-3s0 3-3 3"/><path d="M5 12v9h14v-9"/></svg>'
|
||||
export const chevronLeftIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="m15 6 -6 6 6 6"/></svg>'
|
||||
export const chevronRightIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="m9 6 6 6 -6 6"/></svg>'
|
||||
export const alertTriangleIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><path d="M12 9v4M12 17h.01"/></svg>'
|
||||
export const xCircleIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="m15 9-6 6M9 9l6 6"/></svg>'
|
||||
export const infoIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 16v-4M12 8h.01"/></svg>'
|
||||
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 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 coinsIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v6c0 1.66 3.58 3 8 3s8-1.34 8-3V6"/><path d="M4 12v6c0 1.66 3.58 3 8 3s8-1.34 8-3v-6"/></svg>';
|
||||
export const walletIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>';
|
||||
export const circleIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></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>';
|
||||
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>';
|
||||
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>';
|
||||
export const targetIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>';
|
||||
export const calendarIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>';
|
||||
export const sunIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>';
|
||||
export const lockIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
|
||||
export const clockIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
|
||||
export const giftIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="8" width="18" height="4"/><path d="M12 8v13M12 8C12 8 9 3 6 5s0 3 3 3M12 8c0 0 3-5 6-3s0 3-3 3"/><path d="M5 12v9h14v-9"/></svg>';
|
||||
export const chevronLeftIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m15 6 -6 6 6 6"/></svg>';
|
||||
export const chevronRightIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 6 6 6 -6 6"/></svg>';
|
||||
export const alertTriangleIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><path d="M12 9v4M12 17h.01"/></svg>';
|
||||
export const xCircleIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="m15 9-6 6M9 9l6 6"/></svg>';
|
||||
export const infoIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 16v-4M12 8h.01"/></svg>';
|
||||
|
||||
@@ -13,3 +13,4 @@ export { default as AuthShell } from './AuthShell.svelte';
|
||||
export { default as NoticeDialog } from './NoticeDialog.svelte';
|
||||
export { default as PricingPlans } from './PricingPlans.svelte';
|
||||
export { default as CheckboxGrid } from './CheckboxGrid.svelte';
|
||||
export { default as PatternPicker } from './PatternPicker.svelte';
|
||||
|
||||
@@ -1,14 +1,31 @@
|
||||
import { Resend } from 'resend';
|
||||
import { RESEND_API } from '$app/env/private';
|
||||
import { getAccessEmailTemplate } from './email-templates';
|
||||
import { getPlatformFlags } from '$lib/server/platform';
|
||||
|
||||
const resend = new Resend(String(RESEND_API));
|
||||
|
||||
let demoCache: { value: boolean; at: number } | null = null;
|
||||
const DEMO_TTL_MS = 10_000;
|
||||
|
||||
async function isDemoMode(): Promise<boolean> {
|
||||
if (demoCache && Date.now() - demoCache.at < DEMO_TTL_MS) return demoCache.value;
|
||||
try {
|
||||
const flags = await getPlatformFlags();
|
||||
demoCache = { value: flags.demo === true, at: Date.now() };
|
||||
return flags.demo === true;
|
||||
} catch {
|
||||
demoCache = { value: false, at: Date.now() };
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendPasswordResetEmail(opts: {
|
||||
to: string;
|
||||
resetLink: string;
|
||||
famName: string;
|
||||
}) {
|
||||
if (await isDemoMode()) return;
|
||||
const { to, resetLink, famName } = opts;
|
||||
|
||||
await resend.emails.send({
|
||||
@@ -30,6 +47,7 @@ export async function sendParentInviteEmail(opts: {
|
||||
famName: string;
|
||||
otp: string;
|
||||
}) {
|
||||
if (await isDemoMode()) return;
|
||||
const { to, inviteLink, famName, otp } = opts;
|
||||
|
||||
await resend.emails.send({
|
||||
@@ -52,6 +70,7 @@ export async function sendAccessUnlockedEmail(opts: {
|
||||
codeValue: string;
|
||||
dashboardUrl: string;
|
||||
}) {
|
||||
if (await isDemoMode()) return;
|
||||
const { to, famName, codeName, codeValue, dashboardUrl } = opts;
|
||||
|
||||
const template = getAccessEmailTemplate(codeValue);
|
||||
@@ -70,6 +89,7 @@ export async function sendWelcomeEmail(opts: {
|
||||
famName: string;
|
||||
dashboardUrl: string;
|
||||
}) {
|
||||
if (await isDemoMode()) return;
|
||||
const { to, famName, dashboardUrl } = opts;
|
||||
|
||||
await resend.emails.send({
|
||||
@@ -112,6 +132,7 @@ export async function sendMentionEmail(opts: {
|
||||
message: string;
|
||||
dashboardUrl: string;
|
||||
}) {
|
||||
if (await isDemoMode()) return;
|
||||
const { to, parentName, authorName, message, dashboardUrl } = opts;
|
||||
|
||||
await resend.emails.send({
|
||||
@@ -123,7 +144,37 @@ export async function sendMentionEmail(opts: {
|
||||
<p><strong>${escHtml(authorName)}</strong> mentioned you in your family chat:</p>
|
||||
<blockquote style="margin:1rem 0;padding:0.6rem 1rem;border-left:3px solid #6366f1;background:#f5f5ff;color:#374151;border-radius:6px">${escHtml(message)}</blockquote>
|
||||
<p><a href="${dashboardUrl}">Open your family dashboard</a> to read and reply.</p>
|
||||
<p>Talk soon,<br />The FamDone team</p>
|
||||
`
|
||||
<p>Talk soon,<br />The FamDone team</p>
|
||||
`
|
||||
});
|
||||
}
|
||||
|
||||
// A child asked to collect a reward — ping the parent(s) with the ask and a
|
||||
// link straight to the Claims card on the admin dashboard.
|
||||
export async function sendClaimRequestEmail(opts: {
|
||||
to: string;
|
||||
parentName: string;
|
||||
childName: string;
|
||||
rewardName: string;
|
||||
rewardValue: string;
|
||||
dashboardUrl: string;
|
||||
}) {
|
||||
if (await isDemoMode()) return;
|
||||
const { to, parentName, childName, rewardName, rewardValue, dashboardUrl } = opts;
|
||||
|
||||
await resend.emails.send({
|
||||
from: 'no-reply@walthamstow.xyz',
|
||||
to,
|
||||
subject: `${childName} asked to claim ${rewardName}`,
|
||||
html: `
|
||||
<p>Hi ${escHtml(parentName)},</p>
|
||||
<p><strong>${escHtml(childName)}</strong> asked to collect a reward:</p>
|
||||
<p>
|
||||
<strong>${escHtml(rewardName)}</strong>
|
||||
${rewardValue ? `— ${escHtml(rewardValue)}` : ''}
|
||||
</p>
|
||||
<p><a href="${dashboardUrl}">Open the Claims card on your dashboard</a> to review and issue it.</p>
|
||||
<p>Talk soon,<br />The FamDone team</p>
|
||||
`
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { SCHEMA_PLAN } from '@shared/pb/schema';
|
||||
import { PB_ENDPOINT } from '$lib/server/pocketbase';
|
||||
import { PB_EMAIL, PB_PASSWORD } from '$app/env/private';
|
||||
import { PB_EMAIL, PB_PASSWORD, MEMBER_SECRET } from '$app/env/private';
|
||||
import { getPlatformFlags } from '$lib/server/platform';
|
||||
|
||||
let token: string | null = null;
|
||||
let tokenExpiry = 0;
|
||||
|
||||
async function auth(): Promise<string> {
|
||||
if (token) return token;
|
||||
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' },
|
||||
@@ -14,9 +16,16 @@ async function auth(): Promise<string> {
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(`PB auth failed: ${JSON.stringify(data)}`);
|
||||
token = data.token;
|
||||
// PB JWTs expire in 1 hour; refresh 5 min early.
|
||||
tokenExpiry = Date.now() + 55 * 60 * 1000;
|
||||
return token!;
|
||||
}
|
||||
|
||||
async function isDemo(): Promise<boolean> {
|
||||
const flags = await getPlatformFlags();
|
||||
return flags.demo === true;
|
||||
}
|
||||
|
||||
async function getCollection(name: string): Promise<any | null> {
|
||||
const t = await auth();
|
||||
const res = await fetch(`${PB_ENDPOINT}/api/collections?filter=name='${name}'`, {
|
||||
@@ -146,6 +155,18 @@ async function ensureUsers(ids: Record<string, string>): Promise<void> {
|
||||
usersCol.fields.push({ name: 'color', type: 'text', required: false });
|
||||
changed = true;
|
||||
}
|
||||
if (!has('pattern')) {
|
||||
usersCol.fields.push({ name: 'pattern', type: 'text', required: false });
|
||||
changed = true;
|
||||
}
|
||||
if (!has('themeSize')) {
|
||||
usersCol.fields.push({ name: 'themeSize', type: 'text', required: false });
|
||||
changed = true;
|
||||
}
|
||||
if (!has('themeOpacity')) {
|
||||
usersCol.fields.push({ name: 'themeOpacity', type: 'text', required: false });
|
||||
changed = true;
|
||||
}
|
||||
if (!has('passwordResetToken')) {
|
||||
usersCol.fields.push({ name: 'passwordResetToken', type: 'text', required: false });
|
||||
changed = true;
|
||||
@@ -429,6 +450,10 @@ export async function migrate(): Promise<void> {
|
||||
await ensureAccessCodes();
|
||||
await ensurePlatform();
|
||||
await ensureDefaultSeasons();
|
||||
if (await isDemo()) {
|
||||
await seedDemoFamily();
|
||||
await seedDemoCompletions();
|
||||
}
|
||||
console.log('[migrate] Done');
|
||||
}
|
||||
|
||||
@@ -762,4 +787,367 @@ async function seedGlobalTemplates(): Promise<void> {
|
||||
console.log('[migrate] Seeded global templates.');
|
||||
}
|
||||
|
||||
// ── Demo family seed ────────────────────────────────────────────────────────
|
||||
// Creates a "Showboaters" family with one admin (demo) and three kids
|
||||
// (Juno, Jackson, Jalipo). Idempotent — skips if the family already exists.
|
||||
// The parent password is `demo1234` (PB enforces ≥8 chars). Children's
|
||||
// derived passwords are handled by the normal member-otp flow.
|
||||
|
||||
const DEMO_FAM = {
|
||||
name: 'Showboaters',
|
||||
slug: 'showboaters',
|
||||
parentName: 'Demo',
|
||||
parentEmail: 'demo@famdone.xyz',
|
||||
parentPassword: 'demo1234',
|
||||
children: [
|
||||
{ name: 'Juno', color: '#f472b6' },
|
||||
{ name: 'Jackson', color: '#60a5fa' },
|
||||
{ name: 'Jalipo', color: '#34d399' }
|
||||
]
|
||||
};
|
||||
|
||||
async function seedDemoFamily(): Promise<void> {
|
||||
const t = await auth();
|
||||
|
||||
// Check if the demo family already exists
|
||||
const famRes = await fetch(
|
||||
`${PB_ENDPOINT}/api/collections/fams/records?filter=slug='${DEMO_FAM.slug}'&perPage=1`,
|
||||
{ headers: { Authorization: `Bearer ${t}` } }
|
||||
);
|
||||
const famData = await famRes.json();
|
||||
if (famData?.items?.length) return; // already seeded
|
||||
|
||||
console.log('[migrate] Seeding demo family "Showboaters"...');
|
||||
|
||||
// 1. Create the family
|
||||
const createdFam = await fetch(`${PB_ENDPOINT}/api/collections/fams/records`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
|
||||
body: JSON.stringify({
|
||||
name: DEMO_FAM.name,
|
||||
slug: DEMO_FAM.slug,
|
||||
timezone: 'auto',
|
||||
paymentMode: 'none',
|
||||
active: false
|
||||
})
|
||||
});
|
||||
const fam = await createdFam.json();
|
||||
if (!createdFam.ok) throw new Error(`Seed demo fam failed: ${JSON.stringify(fam)}`);
|
||||
const famId = fam.id;
|
||||
|
||||
// 2. Create the parent (admin) user
|
||||
const parentUsername = `${DEMO_FAM.slug}:${DEMO_FAM.parentName.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
|
||||
const createdParent = await fetch(`${PB_ENDPOINT}/api/collections/users/records`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
|
||||
body: JSON.stringify({
|
||||
username: parentUsername,
|
||||
name: DEMO_FAM.parentName,
|
||||
email: DEMO_FAM.parentEmail,
|
||||
emailVisibility: false,
|
||||
password: DEMO_FAM.parentPassword,
|
||||
passwordConfirm: DEMO_FAM.parentPassword,
|
||||
famId,
|
||||
role: 'parent'
|
||||
})
|
||||
});
|
||||
if (!createdParent.ok) {
|
||||
const pd = await createdParent.json();
|
||||
throw new Error(`Seed demo parent failed: ${JSON.stringify(pd)}`);
|
||||
}
|
||||
|
||||
// 3. Create settings for the family
|
||||
await fetch(`${PB_ENDPOINT}/api/collections/settings/records`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
|
||||
body: JSON.stringify({ famId })
|
||||
});
|
||||
|
||||
// 4. Apply access code (dev123) to unlock the family
|
||||
const codeRes = await fetch(
|
||||
`${PB_ENDPOINT}/api/collections/accesscodes/records?filter=value='dev123'&perPage=1`,
|
||||
{ headers: { Authorization: `Bearer ${t}` } }
|
||||
);
|
||||
const codeData = await codeRes.json();
|
||||
const accessCode = codeData?.items?.[0];
|
||||
if (accessCode) {
|
||||
await fetch(`${PB_ENDPOINT}/api/collections/fams/records/${famId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
|
||||
body: JSON.stringify({
|
||||
paymentMode: 'code',
|
||||
active: true,
|
||||
accessCodeId: accessCode.id,
|
||||
accessCodeEnteredAt: new Date().toISOString()
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Create child users (via the same derive-password path as member-otp)
|
||||
for (const child of DEMO_FAM.children) {
|
||||
const childHandle = child.name.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const childUsername = `${DEMO_FAM.slug}:${childHandle}`;
|
||||
const childPassword = `${String(MEMBER_SECRET)}${DEMO_FAM.slug}${childHandle}`;
|
||||
|
||||
await fetch(`${PB_ENDPOINT}/api/collections/users/records`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
|
||||
body: JSON.stringify({
|
||||
username: childUsername,
|
||||
name: child.name,
|
||||
color: child.color,
|
||||
password: childPassword,
|
||||
passwordConfirm: childPassword,
|
||||
famId,
|
||||
role: 'child'
|
||||
})
|
||||
}).catch((e) => {
|
||||
console.warn(`[migrate] Failed to create demo child ${child.name}:`, e);
|
||||
});
|
||||
|
||||
// Auto-create pocket-money droplet
|
||||
await fetch(`${PB_ENDPOINT}/api/collections/bonus_configs/records`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
|
||||
body: JSON.stringify({
|
||||
famId,
|
||||
name: 'Pocket Money',
|
||||
target: 'individual',
|
||||
type: 'threshold',
|
||||
thresholdType: 'percent',
|
||||
occurrence: 'recurring',
|
||||
rewardType: 'cash',
|
||||
rewardValue: '',
|
||||
criteriaValue: 50,
|
||||
period: 'weekly',
|
||||
status: 'active',
|
||||
isPocketMoney: true
|
||||
})
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
console.log('[migrate] ✓ Demo family "Showboaters" seeded');
|
||||
console.log(`[migrate] Login: ${DEMO_FAM.parentEmail} / ${DEMO_FAM.parentPassword}`);
|
||||
}
|
||||
|
||||
// ── Demo rotating data ──────────────────────────────────────────────────────
|
||||
// On every boot, if the demo family exists and doesn't already have today's
|
||||
// completions, wipe and regenerate 21 days of deterministic completion data.
|
||||
// This ensures the demo always shows a realistic 3-week history relative to
|
||||
// the current date, without manual re-seeding.
|
||||
|
||||
// Simple seeded PRNG (mulberry32) so the same data is produced every time.
|
||||
function demoRng(seed: number): () => number {
|
||||
let s = seed | 0;
|
||||
return () => {
|
||||
s = (s + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function dateStr(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function addDays(d: Date, n: number): Date {
|
||||
const r = new Date(d);
|
||||
r.setDate(r.getDate() + n);
|
||||
return r;
|
||||
}
|
||||
|
||||
// Each kid's "completion personality" — Juno is reliable, Jackson solid, Jalipo spotty.
|
||||
// Maps assigned chore customName → weekday (0=Sun) → probability of completion.
|
||||
const COMPLETION_PROFILES: Record<string, Record<number, Record<string, number>>> = {
|
||||
'Juno': {
|
||||
0: { default: 0.85 }, // Sun
|
||||
1: { default: 0.95 }, // Mon
|
||||
2: { default: 0.9 },
|
||||
3: { default: 0.95 },
|
||||
4: { default: 0.9 },
|
||||
5: { default: 0.85 }, // Fri
|
||||
6: { default: 0.8 } // Sat
|
||||
},
|
||||
'Jackson': {
|
||||
0: { default: 0.7 },
|
||||
1: { default: 0.9 },
|
||||
2: { default: 0.85 },
|
||||
3: { default: 0.8 },
|
||||
4: { default: 0.75 },
|
||||
5: { default: 0.7 },
|
||||
6: { default: 0.6 }
|
||||
},
|
||||
'Jalipo': {
|
||||
0: { default: 0.4 },
|
||||
1: { default: 0.75 },
|
||||
2: { default: 0.65 },
|
||||
3: { default: 0.7 },
|
||||
4: { default: 0.6 },
|
||||
5: { default: 0.5 },
|
||||
6: { default: 0.35 }
|
||||
}
|
||||
};
|
||||
|
||||
async function seedDemoCompletions(): Promise<void> {
|
||||
const t = await auth();
|
||||
|
||||
// Find the demo family
|
||||
const famRes = await fetch(
|
||||
`${PB_ENDPOINT}/api/collections/fams/records?filter=slug='${DEMO_FAM.slug}'&perPage=1`,
|
||||
{ headers: { Authorization: `Bearer ${t}` } }
|
||||
);
|
||||
const famData = await famRes.json();
|
||||
const fam = famData?.items?.[0];
|
||||
if (!fam) return; // demo family not seeded yet
|
||||
|
||||
const famId = fam.id;
|
||||
|
||||
// Check if today's completions already exist (idempotent)
|
||||
const today = dateStr(new Date());
|
||||
const todayComps = await fetch(
|
||||
`${PB_ENDPOINT}/api/collections/completions/records?filter=${encodeURIComponent(`famId='${famId}' && date='${today}'`)}&perPage=1`,
|
||||
{ headers: { Authorization: `Bearer ${t}` } }
|
||||
);
|
||||
if ((await todayComps.json())?.items?.length) {
|
||||
console.log('[migrate] Demo data already rotated for today — skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[migrate] Rotating demo data for Showboaters...');
|
||||
|
||||
// Fetch members, assigned chores
|
||||
const [membersRes, assignedRes] = await Promise.all([
|
||||
fetch(`${PB_ENDPOINT}/api/collections/users/records?filter=${encodeURIComponent(`famId='${famId}' && role='child'`)}&perPage=10`, {
|
||||
headers: { Authorization: `Bearer ${t}` }
|
||||
}),
|
||||
fetch(`${PB_ENDPOINT}/api/collections/assigned_chores/records?filter=famId='${famId}'&perPage=100`, {
|
||||
headers: { Authorization: `Bearer ${t}` }
|
||||
})
|
||||
]);
|
||||
const members: any[] = (await membersRes.json())?.items || [];
|
||||
const assigned: any[] = (await assignedRes.json())?.items || [];
|
||||
|
||||
if (!members.length || !assigned.length) return;
|
||||
|
||||
// Delete existing completions and weekly_history for the demo family
|
||||
const [oldCompsRes, oldHistoryRes] = await Promise.all([
|
||||
fetch(`${PB_ENDPOINT}/api/collections/completions/records?filter=famId='${famId}'&perPage=500`, {
|
||||
headers: { Authorization: `Bearer ${t}` }
|
||||
}),
|
||||
fetch(`${PB_ENDPOINT}/api/collections/weekly_history/records?filter=famId='${famId}'&perPage=500`, {
|
||||
headers: { Authorization: `Bearer ${t}` }
|
||||
})
|
||||
]);
|
||||
const oldComps: any[] = (await oldCompsRes.json())?.items || [];
|
||||
const oldHistory: any[] = (await oldHistoryRes.json())?.items || [];
|
||||
for (const r of oldComps) {
|
||||
await fetch(`${PB_ENDPOINT}/api/collections/completions/records/${r.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${t}` }
|
||||
}).catch(() => {});
|
||||
}
|
||||
for (const r of oldHistory) {
|
||||
await fetch(`${PB_ENDPOINT}/api/collections/weekly_history/records/${r.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${t}` }
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Generate 21 days of completions (today - 20 → today)
|
||||
const todayDate = new Date();
|
||||
todayDate.setUTCHours(12, 0, 0, 0); // anchor at noon UTC
|
||||
const startDate = addDays(todayDate, -20);
|
||||
|
||||
// Track per-member-per-week totals for weekly_history
|
||||
type WeekKey = string; // "YYYY-MM-DD" (Monday)
|
||||
const weekTotals: Record<string, Record<string, { points: number; money: number; count: number }>> = {};
|
||||
|
||||
for (const member of members) {
|
||||
const profile = COMPLETION_PROFILES[member.name] || COMPLETION_PROFILES['Jalipo'];
|
||||
|
||||
for (let d = 0; d < 21; d++) {
|
||||
const day = addDays(startDate, d);
|
||||
const ds = dateStr(day);
|
||||
const dow = day.getUTCDay(); // 0=Sun
|
||||
|
||||
// Compute week start (Monday) for weekly_history tracking
|
||||
const weekMonday = new Date(day);
|
||||
weekMonday.setUTCDate(weekMonday.getUTCDate() - ((weekMonday.getUTCDay() + 6) % 7));
|
||||
const ws = dateStr(weekMonday);
|
||||
|
||||
if (!weekTotals[member.id]) weekTotals[member.id] = {};
|
||||
if (!weekTotals[member.id][ws]) weekTotals[member.id][ws] = { points: 0, money: 0, count: 0 };
|
||||
|
||||
const dayProfile = profile[dow] || profile[0];
|
||||
|
||||
// Find this member's assigned chores
|
||||
const memberChores = assigned.filter(
|
||||
(a: any) => a.memberId === member.id || (!a.memberId && a.shared)
|
||||
);
|
||||
|
||||
for (const chore of memberChores) {
|
||||
// Weekly chores: only complete on Monday (dow=1)
|
||||
if (chore.frequency === 'weekly' && dow !== 1) continue;
|
||||
|
||||
const prob = dayProfile[chore.customName] || dayProfile.default;
|
||||
const rng = demoRng(
|
||||
famId.length * 1000 +
|
||||
member.id.length * 100 +
|
||||
d * 10 +
|
||||
(chore.id?.charCodeAt(0) || 0)
|
||||
);
|
||||
|
||||
if (rng() > prob) continue; // missed this day
|
||||
|
||||
// Create completion record
|
||||
await fetch(`${PB_ENDPOINT}/api/collections/completions/records`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
|
||||
body: JSON.stringify({
|
||||
famId,
|
||||
memberId: member.id,
|
||||
assignedChoreId: chore.id,
|
||||
date: ds
|
||||
})
|
||||
}).catch(() => {});
|
||||
|
||||
// Track totals
|
||||
const val = Number(chore.value) || 0;
|
||||
if (chore.type === 'points') {
|
||||
weekTotals[member.id][ws].points += val;
|
||||
} else if (chore.type === 'money') {
|
||||
weekTotals[member.id][ws].money += val;
|
||||
}
|
||||
weekTotals[member.id][ws].count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create weekly_history records for each week that has data
|
||||
for (const memberId of Object.keys(weekTotals)) {
|
||||
for (const ws of Object.keys(weekTotals[memberId])) {
|
||||
const totals = weekTotals[memberId][ws];
|
||||
if (totals.count === 0) continue;
|
||||
|
||||
await fetch(`${PB_ENDPOINT}/api/collections/weekly_history/records`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
|
||||
body: JSON.stringify({
|
||||
famId,
|
||||
memberId,
|
||||
weekStart: ws,
|
||||
pointsEarned: totals.points,
|
||||
moneyEarned: totals.money,
|
||||
choresCompleted: totals.count,
|
||||
bonusEarned: 0
|
||||
})
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[migrate] ✓ Demo completions rotated (21 days)');
|
||||
}
|
||||
|
||||
export { GLOBAL_TEMPLATE_ICONS };
|
||||
export { seedDemoCompletions };
|
||||
|
||||
@@ -69,7 +69,15 @@ export async function patchFam(pb: any, famId: string, body: Record<string, unkn
|
||||
|
||||
export async function getProfile(pb: any, famId: string, userId: string) {
|
||||
const rec = await pb.collection('users').getOne(userId);
|
||||
return { id: rec.id, name: rec.name || '', color: rec.color || '#6366f1', email: rec.email || '' };
|
||||
return {
|
||||
id: rec.id,
|
||||
name: rec.name || '',
|
||||
color: rec.color || '#6366f1',
|
||||
pattern: rec.pattern || '',
|
||||
themeSize: rec.themeSize || '',
|
||||
themeOpacity: rec.themeOpacity || '',
|
||||
email: rec.email || ''
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateProfile(
|
||||
@@ -81,9 +89,20 @@ export async function updateProfile(
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (data.name) patch.name = data.name;
|
||||
if (data.color) patch.color = data.color;
|
||||
if (data.pattern !== undefined) patch.pattern = data.pattern;
|
||||
if (data.themeSize !== undefined) patch.themeSize = data.themeSize;
|
||||
if (data.themeOpacity !== undefined) patch.themeOpacity = data.themeOpacity;
|
||||
if (data.email !== undefined) patch.email = data.email;
|
||||
const rec = await pb.collection('users').update(userId, patch);
|
||||
return { id: rec.id, name: rec.name || '', color: rec.color || '#6366f1', email: rec.email || '' };
|
||||
return {
|
||||
id: rec.id,
|
||||
name: rec.name || '',
|
||||
color: rec.color || '#6366f1',
|
||||
pattern: rec.pattern || '',
|
||||
themeSize: rec.themeSize || '',
|
||||
themeOpacity: rec.themeOpacity || '',
|
||||
email: rec.email || ''
|
||||
};
|
||||
}
|
||||
|
||||
export async function weeklySummary(pb: any, famId: string) {
|
||||
@@ -309,4 +328,4 @@ export async function completeWeek(pb: any, famId: string) {
|
||||
historyRecords,
|
||||
memberCount: members.length
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,4 +5,7 @@ export interface SessionUser {
|
||||
role: 'parent' | 'child';
|
||||
famId: string;
|
||||
color?: string;
|
||||
}
|
||||
pattern?: string;
|
||||
themeSize?: string;
|
||||
themeOpacity?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Live theme draft — the PatternPicker writes here the moment a tile (or a
|
||||
// colour swatch) is clicked, so the fam layout can preview the background
|
||||
// instantly. `null` means "no draft yet — use the saved value from session".
|
||||
export const themeDraft = $state<{
|
||||
pattern: string | null;
|
||||
color: string | null;
|
||||
size: number | null;
|
||||
opacity: number | null;
|
||||
}>({
|
||||
pattern: null,
|
||||
color: null,
|
||||
size: null,
|
||||
opacity: null
|
||||
});
|
||||
@@ -0,0 +1,991 @@
|
||||
/* Generated from css-patterns.css (see scripts/gen-theme-patterns.mjs).
|
||||
57 unique patterns (3 duplicates removed).
|
||||
Colors are overridden per-user via --c1..--c4 set inline on the element
|
||||
(hsl shades of the chosen swatch). */
|
||||
|
||||
html .app-shell.pattern-p1,
|
||||
.pattern-p1 {
|
||||
--s: 84px; /* control the size*/
|
||||
--c1: #f2f2f2;
|
||||
--c2: #cdcbcc;
|
||||
--c3: #999999;
|
||||
|
||||
--_g: 0 120deg,#0000 0;
|
||||
background:
|
||||
conic-gradient( at calc(250%/3) calc(100%/3),var(--c3) var(--_g)),
|
||||
conic-gradient(from -120deg at calc( 50%/3) calc(100%/3),var(--c2) var(--_g)),
|
||||
conic-gradient(from 120deg at calc(100%/3) calc(250%/3),var(--c1) var(--_g)),
|
||||
conic-gradient(from 120deg at calc(200%/3) calc(250%/3),var(--c1) var(--_g)),
|
||||
conic-gradient(from -180deg at calc(100%/3) 50%,var(--c2) 60deg,var(--c1) var(--_g)),
|
||||
conic-gradient(from 60deg at calc(200%/3) 50%,var(--c1) 60deg,var(--c3) var(--_g)),
|
||||
conic-gradient(from -60deg at 50% calc(100%/3),var(--c1) 120deg,var(--c2) 0 240deg,var(--c3) 0);
|
||||
background-size: calc(var(--s)*sqrt(3)) var(--s);
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p2,
|
||||
.pattern-p2 {
|
||||
--s: 200px; /* control the size*/
|
||||
--c1: #1d1d1d;
|
||||
--c2: #4e4f51;
|
||||
--c3: #3c3c3c;
|
||||
|
||||
background:
|
||||
repeating-conic-gradient(from 30deg,#0000 0 120deg,var(--c3) 0 50%)
|
||||
calc(var(--s)/2) calc(var(--s)*tan(30deg)/2),
|
||||
repeating-conic-gradient(from 30deg,var(--c1) 0 60deg,var(--c2) 0 120deg,var(--c3) 0 50%);
|
||||
background-size: var(--s) calc(var(--s)*tan(30deg));
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p3,
|
||||
.pattern-p3 {
|
||||
--s: 64px; /* control the size*/
|
||||
--c1: #c02942;
|
||||
--c2: #53777a;
|
||||
--c3: #ecd078;
|
||||
--c4: #d95b43;
|
||||
|
||||
background:
|
||||
radial-gradient(var(--c1) 24%,#0000 25%),
|
||||
radial-gradient(var(--c2) 30%,#0000 32%) calc(var(--s)/2) calc(var(--s)/2),
|
||||
repeating-conic-gradient(from 30deg,var(--c3) 0 30deg,var(--c4) 0 25%);
|
||||
background-size: var(--s) var(--s);
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p4,
|
||||
.pattern-p4 {
|
||||
--s: 100px; /* control the size*/
|
||||
--c1: #fff0e5;
|
||||
--c2: #025d8c;
|
||||
--c3: #e1642a;
|
||||
|
||||
--_g: 50%,#0000 37%,var(--c1) 39% 70%,#0000 72%;
|
||||
--_t: 50%,var(--c2) 40deg,var(--c3) 0 140deg,var(--c2) 0 180deg,#0000 0;
|
||||
--_s: 47% 50% at;
|
||||
background:
|
||||
radial-gradient(var(--_s) -10% var(--_g)) 0 calc(var(--s)/2),
|
||||
radial-gradient(var(--_s) -10% var(--_g)) calc(var(--s)/2) 0,
|
||||
radial-gradient(var(--_s) 110% var(--_g)),
|
||||
radial-gradient(var(--_s) 110% var(--_g)) calc(var(--s)/2) calc(var(--s)/2),
|
||||
conic-gradient(from 0deg at 55% var(--_t)) calc(var(--s)/4) 0,
|
||||
conic-gradient(from 180deg at 45% var(--_t)) calc(var(--s)/4) 0,
|
||||
var(--c2);
|
||||
background-size: var(--s) var(--s);
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p5,
|
||||
.pattern-p5 {
|
||||
--s: 150px; /* control the size*/
|
||||
--c1: #f7d2a1;
|
||||
--c2: #05057e;
|
||||
|
||||
--_g:
|
||||
var(--c1) 0% 5% ,var(--c2) 6% 15%,var(--c1) 16% 25%,var(--c2) 26% 35%,var(--c1) 36% 45%,
|
||||
var(--c2) 46% 55%,var(--c1) 56% 65%,var(--c2) 66% 75%,var(--c1) 76% 85%,var(--c2) 86% 95%,
|
||||
#0000 96%;
|
||||
background:
|
||||
radial-gradient(50% 50% at 100% 0,var(--_g)),
|
||||
radial-gradient(50% 50% at 0 100%,var(--_g)),
|
||||
radial-gradient(50% 50%,var(--_g)),
|
||||
radial-gradient(50% 50%,var(--_g)) calc(var(--s)/2) calc(var(--s)/2)
|
||||
var(--c1);
|
||||
background-size: var(--s) var(--s);
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p6,
|
||||
.pattern-p6 {
|
||||
--s: 120px; /* control the size*/
|
||||
--c1: #fa6900;
|
||||
--c2: #d95b43;
|
||||
--c3: #ecd078;
|
||||
|
||||
background:
|
||||
conic-gradient(from 150deg at 50% 33%,#0000,var(--c1) .5deg 60deg,#0000 60.5deg)
|
||||
calc(var(--s)/2) calc(var(--s)/sqrt(2)),
|
||||
conic-gradient(from -30deg at 50% 66%,#0000,var(--c2) .5deg 60deg,var(--c3) 60.5deg);
|
||||
background-size: var(--s) calc(.5*var(--s)/tan(30deg));
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p7,
|
||||
.pattern-p7 {
|
||||
--s: 100px; /* control the size*/
|
||||
--c1: #4ecdc4;
|
||||
--c2: #556270;
|
||||
|
||||
background:
|
||||
linear-gradient(atan(-.5),var(--c1) 33%,var(--c2) 33.5% 66.5%,var(--c1) 67%)
|
||||
0/var(--s) var(--s);
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p8,
|
||||
.pattern-p8 {
|
||||
--s: 100px; /* control the size*/
|
||||
--c1: #eceddc;
|
||||
--c2: #29ab87;
|
||||
|
||||
--_g: var(--c1) 90deg,#0000 90.5deg;
|
||||
background:
|
||||
conic-gradient(from -45deg,var(--_g)),
|
||||
conic-gradient(from 135deg,var(--_g)) calc(var(--s)/2) 0,
|
||||
var(--c2);
|
||||
background-size: var(--s) var(--s)
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p9,
|
||||
.pattern-p9 {
|
||||
--s: 150px; /* control the size*/
|
||||
--c1: #ff847c;
|
||||
--c2: #e84a5f;
|
||||
--c3: #fecea8;
|
||||
--c4: #99b898;
|
||||
|
||||
background:
|
||||
conic-gradient(from 45deg at 75% 75%, var(--c3) 90deg,var(--c1) 0 180deg,#0000 0),
|
||||
conic-gradient(from -45deg at 25% 25%, var(--c3) 90deg,#0000 0),
|
||||
conic-gradient(from -45deg at 50% 100%,#0000 180deg,var(--c3) 0),
|
||||
conic-gradient(from -45deg,var(--c1) 90deg, var(--c2) 0 225deg,var(--c4) 0);
|
||||
background-size: var(--s) var(--s);
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p10,
|
||||
.pattern-p10 {
|
||||
--s: 222px; /* control the size*/
|
||||
--c1: #7f727b;
|
||||
--c2: #d6b4c2;
|
||||
--c3: #baa0ab;
|
||||
|
||||
--_g: var(--c1) 10%,var(--c2) 10.5% 19%,#0000 19.5% 80.5%,var(--c2) 81% 89.5%,var(--c3) 90%;
|
||||
--_c: from -90deg at 37.5% 50%,#0000 75%;
|
||||
--_l1: linear-gradient(145deg,var(--_g));
|
||||
--_l2: linear-gradient( 35deg,var(--_g));
|
||||
background:
|
||||
var(--_l1), var(--_l1) calc(var(--s)/2) var(--s),
|
||||
var(--_l2), var(--_l2) calc(var(--s)/2) var(--s),
|
||||
conic-gradient(var(--_c),var(--c1) 0) calc(var(--s)/8) 0,
|
||||
conic-gradient(var(--_c),var(--c3) 0) calc(var(--s)/2) 0,
|
||||
linear-gradient(90deg,var(--c3) 38%,var(--c1) 0 50%,var(--c3) 0 62%,var(--c1) 0);
|
||||
background-size: var(--s) calc(2*var(--s)/3);
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p11,
|
||||
.pattern-p11 {
|
||||
--s: 20px; /* control the size*/
|
||||
--c1: #c02942;
|
||||
--c2: #53777a;
|
||||
--c3: #ecd078;
|
||||
|
||||
--g: var(--s); /* gap between lines */
|
||||
background:
|
||||
conic-gradient(at var(--s) calc(100% - var(--s)),#0000 270deg,var(--c1) 0) calc(var(--s) + var(--g)) 0,
|
||||
linear-gradient(var(--c2) var(--s),#0000 0) 0 var(--g),
|
||||
conic-gradient(at var(--s) calc(100% - var(--s)),#0000 90deg,var(--c2) 0 180deg, var(--c1) 0),
|
||||
var(--c3);
|
||||
background-size: calc(2*(var(--s) + var(--g))) calc(2*(var(--s) + var(--g)));
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p12,
|
||||
.pattern-p12 {
|
||||
--s: 200px; /* control the size*/
|
||||
--c1: #ffffff;
|
||||
--c2: #1095c1;
|
||||
|
||||
--_g: #0000 8%,var(--c1) 0 17%,#0000 0 58%;
|
||||
background:
|
||||
linear-gradient(135deg,#0000 20.5%,var(--c1) 0 29.5%,#0000 0) 0 calc(var(--s)/4),
|
||||
linear-gradient( 45deg,var(--_g)) calc(var(--s)/2) 0,
|
||||
linear-gradient(135deg,var(--_g),var(--c1) 0 67%,#0000 0),
|
||||
linear-gradient( 45deg,var(--_g),var(--c1) 0 67%,#0000 0 83%,var(--c1) 0 92%,#0000 0),
|
||||
var(--c2);
|
||||
background-size: var(--s) var(--s);
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p13,
|
||||
.pattern-p13 {
|
||||
--s: 64px; /* control the size*/
|
||||
--c1: #e0e4cc;
|
||||
--c2: #69d2e7;
|
||||
|
||||
--_g: 90deg,#0000 0;
|
||||
background:
|
||||
conic-gradient(from 135deg,var(--c1) var(--_g)) var(--s) calc(var(--s)/2),
|
||||
conic-gradient(from 135deg,var(--c2) var(--_g)),
|
||||
conic-gradient(from 135deg at 50% 0,var(--c1) var(--_g)) var(--c2);
|
||||
background-size: calc(2*var(--s)) var(--s);
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p14,
|
||||
.pattern-p14 {
|
||||
--s: 64px; /* control the size*/
|
||||
--c1: #eb6841;
|
||||
--c2: #edc951;
|
||||
|
||||
background:
|
||||
conic-gradient(from -45deg,var(--c1) 90deg,#0000 0 180deg,var(--c2) 0 270deg,#0000 0)
|
||||
0 calc(var(--s)/2)/var(--s) var(--s),
|
||||
conic-gradient(from 135deg at 50% 0,var(--c1) 90deg,var(--c2) 0)
|
||||
0 0/calc(2*var(--s)) var(--s);
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p15,
|
||||
.pattern-p15 {
|
||||
--s: 36px; /* control the size*/
|
||||
--c1: #b62f31;
|
||||
--c2: #ecdacb;
|
||||
--c3: #8e1f08;
|
||||
|
||||
--_g: calc(2*var(--s)*1.732) calc(2*var(--s))
|
||||
conic-gradient(from 60deg at 62.5% 50%,var(--c3) 60deg,#0000 0);
|
||||
background:
|
||||
calc( 2.598*var(--s)/2) calc(var(--s)/ 2)/var(--_g),
|
||||
calc(-0.866*var(--s)/2) calc(var(--s)/-2)/var(--_g),
|
||||
repeating-conic-gradient(var(--c2) 0 90deg,#0000 0 180deg)
|
||||
0 0/calc(2*var(--s)*1.732) calc(2*var(--s)),
|
||||
repeating-conic-gradient(from 60deg,var(--c1) 0 60deg,var(--c2) 0 180deg)
|
||||
0 0/calc(var(--s)*1.732) var(--s);
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p16,
|
||||
.pattern-p16 {
|
||||
--s: 125px; /* control the size*/
|
||||
--c1: #f8ca00;
|
||||
--c2: #bd1550;
|
||||
|
||||
--_g: var(--c1) 90deg,var(--c2) 0 135deg,#0000 0;
|
||||
background:
|
||||
conic-gradient(from -45deg at calc(100%/3) calc(100%/3) ,var(--c1) 90deg,#0000 0 ),
|
||||
conic-gradient(from -135deg at calc(100%/3) calc(2*100%/3),var(--_g)),
|
||||
conic-gradient(from 135deg at calc(2*100%/3) calc(2*100%/3),var(--_g)),
|
||||
conic-gradient(from 45deg at calc(2*100%/3) calc(100%/3) ,var(--_g),var(--c1) 0 225deg,var(--c2) 0);
|
||||
background-size: var(--s) var(--s);
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p17,
|
||||
.pattern-p17 {
|
||||
--s: 120px; /* control the size*/
|
||||
--c1: #72e21f;
|
||||
--c2: #044012;
|
||||
|
||||
--_c: var(--c1) 25%,#0000 0;
|
||||
--_g1: conic-gradient(at 62.5% 12.5%,var(--_c));
|
||||
--_g2: conic-gradient(at 87.5% 62.5%,var(--_c));
|
||||
--_g3: conic-gradient(at 25% 12.5%,var(--_c));
|
||||
background:
|
||||
var(--_g1) calc( var(--s)/-8) calc(var(--s)/2),var(--_g1) calc(-3*var(--s)/8) calc(var(--s)/4),
|
||||
var(--_g2) calc(3*var(--s)/8) calc(var(--s)/4),var(--_g2) calc( var(--s)/-8) 0,
|
||||
var(--_g3) 0 calc(var(--s)/-4),var(--_g3) calc(var(--s)/-4) 0,
|
||||
conic-gradient(at 87.5% 87.5%,var(--_c)) calc(var(--s)/8) 0
|
||||
var(--c2);
|
||||
background-size: var(--s) var(--s);
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p18,
|
||||
.pattern-p18 {
|
||||
--s: 170px; /* control the size*/
|
||||
--c1: #3b8686;
|
||||
--c2: #cff09e;
|
||||
|
||||
--_c: #0000 75%,var(--c1) 0;
|
||||
--_g1: conic-gradient(at 20.71% 50%,var(--_c));
|
||||
--_g2: conic-gradient(at 50% 20.71%,var(--_c));
|
||||
background:
|
||||
var(--_g1) calc(var(--s)/ 6.828) calc(var(--s)/2),var(--_g1) calc(var(--s)/-2.828) 0,
|
||||
conic-gradient(from 45deg at 29.29% 29.29%,var(--_c)) calc(var(--s)/-6.828) 0,
|
||||
var(--_g2) calc(var(--s)/2) calc(var(--s)/-2.828),var(--_g2) 0 calc(var(--s)/ 6.828),
|
||||
conic-gradient(from -135deg at 29.29% 70.71%,var(--_c)) calc(var(--s)/-6.828) 0,
|
||||
var(--c2);
|
||||
background-size: var(--s) var(--s), var(--s) var(--s), calc(var(--s)/2) calc(var(--s)/2);
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p19,
|
||||
.pattern-p19 {
|
||||
--s: 76px; /* control the size*/
|
||||
--c1: #f6d86b;
|
||||
--c2: #f10c49;
|
||||
|
||||
background:
|
||||
conic-gradient(
|
||||
var(--c2) atan(2), var(--c1) 0 calc(180deg - atan(2)),
|
||||
var(--c2) 0 180deg,var(--c1) 0 calc(180deg + atan(2)),
|
||||
var(--c2) 0 calc(360deg - atan(2)),var(--c1) 0);
|
||||
background-size: calc(2*var(--s)) var(--s);
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p20,
|
||||
.pattern-p20 {
|
||||
--s: 40px; /* control the size*/
|
||||
--c1: #c0d860;
|
||||
--c2: #604848;
|
||||
|
||||
--_c: #0000 75%,var(--c1) 0;
|
||||
--_g1: conic-gradient(at 10% 50%,var(--_c));
|
||||
--_g2: conic-gradient(at 50% 10%,var(--_c));
|
||||
background:
|
||||
var(--_g1),
|
||||
var(--_g1) calc(1*var(--s)) calc(3*var(--s)),
|
||||
var(--_g1) calc(2*var(--s)) calc(1*var(--s)),
|
||||
var(--_g1) calc(3*var(--s)) calc(4*var(--s)),
|
||||
var(--_g1) calc(4*var(--s)) calc(2*var(--s)),
|
||||
var(--_g2) 0 calc(4*var(--s)),
|
||||
var(--_g2) calc(1*var(--s)) calc(2*var(--s)),
|
||||
var(--_g2) calc(2*var(--s)) 0,
|
||||
var(--_g2) calc(3*var(--s)) calc(3*var(--s)),
|
||||
var(--_g2) calc(4*var(--s)) calc(1*var(--s)),
|
||||
var(--c2);
|
||||
background-size: calc(5*var(--s)) calc(5*var(--s));
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p21,
|
||||
.pattern-p21 {
|
||||
--s: 65px; /* control the size*/
|
||||
--c1: #dadee1;
|
||||
--c2: #4a99b4;
|
||||
--c3: #9cceb5;
|
||||
|
||||
--_c: 75%,var(--c3) 52.72deg,#0000 0;
|
||||
--_g1: conic-gradient(from -116.36deg at 25% var(--_c));
|
||||
--_g2: conic-gradient(from 63.43deg at 75% var(--_c));
|
||||
background:
|
||||
var(--_g1), var(--_g1) calc(3*var(--s)) calc(var(--s)/2),
|
||||
var(--_g2), var(--_g2) calc(3*var(--s)) calc(var(--s)/2),
|
||||
conic-gradient(
|
||||
var(--c2) 63.43deg ,var(--c1) 0 116.36deg,
|
||||
var(--c2) 0 180deg ,var(--c1) 0 243.43deg,
|
||||
var(--c2) 0 296.15deg,var(--c1) 0);
|
||||
background-size: calc(2*var(--s)) var(--s);
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p22,
|
||||
.pattern-p22 {
|
||||
--s: 82px; /* control the size*/
|
||||
--c1: #b2b2b2;
|
||||
--c2: #ffffff;
|
||||
--c3: #d9d9d9;
|
||||
|
||||
--_g: var(--c3) 0 120deg,#0000 0;
|
||||
background:
|
||||
conic-gradient(from -60deg at 50% calc(100%/3),var(--_g)),
|
||||
conic-gradient(from 120deg at 50% calc(200%/3),var(--_g)),
|
||||
conic-gradient(from 60deg at calc(200%/3),var(--c3) 60deg,var(--c2) 0 120deg,#0000 0),
|
||||
conic-gradient(from 180deg at calc(100%/3),var(--c1) 60deg,var(--_g)),
|
||||
linear-gradient(90deg,var(--c1) calc(100%/6),var(--c2) 0 50%,
|
||||
var(--c1) 0 calc(500%/6),var(--c2) 0);
|
||||
background-size: calc(1.732*var(--s)) var(--s);
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p23,
|
||||
.pattern-p23 {
|
||||
--s: 50px; /* control the size*/
|
||||
--c1: #0b2e59;
|
||||
--c2: #0d6759;
|
||||
|
||||
background:
|
||||
conic-gradient(at 25% 25%,#0000 75%,var(--c1) 0) var(--s) var(--s),
|
||||
repeating-conic-gradient(at 25% 25%,var(--c1) 0 25%,var(--c2) 0 50%);
|
||||
background-size: calc(2*var(--s)) calc(2*var(--s));
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p24,
|
||||
.pattern-p24 {
|
||||
--s: 100px; /* control the size*/
|
||||
--c1: #1bb0ce;
|
||||
--c2: #dad6ca;
|
||||
|
||||
--_g1:
|
||||
var(--c1) calc(25%/3) ,#0000 0 calc(50%/3),
|
||||
var(--c1) 0 25% ,#0000 0 75%,
|
||||
var(--c1) 0 calc(250%/3),#0000 0 calc(275%/3),
|
||||
var(--c1) 0;
|
||||
--_g2:
|
||||
#0000 calc(25%/3) ,var(--c1) 0 calc(50%/3),
|
||||
#0000 0 calc(250%/3),var(--c1) 0 calc(275%/3),
|
||||
#0000 0;
|
||||
background:
|
||||
linear-gradient( 45deg,var(--_g2)),linear-gradient( 45deg,var(--_g1)),
|
||||
linear-gradient(-45deg,var(--_g2)),linear-gradient(-45deg,var(--_g1))
|
||||
var(--c2); /* second color */
|
||||
background-position: 0 0,var(--s) var(--s);
|
||||
background-size: calc(2*var(--s)) calc(2*var(--s));
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p25,
|
||||
.pattern-p25 {
|
||||
--s: 100px; /* control the size*/
|
||||
--c1: #c3ccaf;
|
||||
--c2: #67434f;
|
||||
|
||||
--_s: calc(2*var(--s)) calc(2*var(--s));
|
||||
--_g: var(--_s) conic-gradient(at 40% 40%,#0000 75%,var(--c1) 0);
|
||||
--_p: var(--_s) conic-gradient(at 20% 20%,#0000 75%,var(--c2) 0);
|
||||
background:
|
||||
calc( .9*var(--s)) calc( .9*var(--s))/var(--_p),
|
||||
calc(-.1*var(--s)) calc(-.1*var(--s))/var(--_p),
|
||||
calc( .7*var(--s)) calc( .7*var(--s))/var(--_g),
|
||||
calc(-.3*var(--s)) calc(-.3*var(--s))/var(--_g),
|
||||
conic-gradient(from 90deg at 20% 20%,var(--c2) 25%,var(--c1) 0)
|
||||
0 0/var(--s) var(--s);
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p26,
|
||||
.pattern-p26 {
|
||||
--s: 106px; /* control the size*/
|
||||
--c1: #eafde6;
|
||||
--c2: #519548;
|
||||
|
||||
background:
|
||||
conic-gradient(from 30deg at 87.5% 75%,var(--c1) 60deg,var(--c2) 0 120deg,#0000 0) 0 calc(.2165*var(--s)),
|
||||
conic-gradient(from -90deg at 50% 25%,var(--c2) 60deg,var(--c1) 0 180deg,#0000 0),
|
||||
conic-gradient(from 90deg at 50% 75%,var(--c2) 120deg,var(--c1) 0 180deg,#0000 0),
|
||||
conic-gradient(from -30deg at 87.5% 50%,var(--c2) 120deg,var(--c1) 0 240deg,#0000 0),
|
||||
conic-gradient(from 90deg at 37.5% 50%,var(--c2) 120deg,var(--c1) 0 180deg,var(--c2) 0 240deg,var(--c1) 0);
|
||||
background-size: var(--s) calc(.866*var(--s));
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p27,
|
||||
.pattern-p27 {
|
||||
--s: 60px; /* control the size*/
|
||||
--c1: #b09f79;
|
||||
--c2: #476074;
|
||||
|
||||
--_g: #0000 83%,var(--c1) 85% 99%,#0000 101%;
|
||||
background:
|
||||
radial-gradient(27% 29% at right ,var(--_g)) calc(var(--s)/ 2) var(--s),
|
||||
radial-gradient(27% 29% at left ,var(--_g)) calc(var(--s)/-2) var(--s),
|
||||
radial-gradient(29% 27% at top ,var(--_g)) 0 calc(var(--s)/ 2),
|
||||
radial-gradient(29% 27% at bottom,var(--_g)) 0 calc(var(--s)/-2)
|
||||
var(--c2);
|
||||
background-size: calc(2*var(--s)) calc(2*var(--s));
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p28,
|
||||
.pattern-p28 {
|
||||
--s: 80px; /* control the size*/
|
||||
--c1: #5e8c6a;
|
||||
--c2: #bfb35a;
|
||||
|
||||
--_s: calc(2*var(--s)) calc(2*var(--s));
|
||||
--_g: 35.36% 35.36% at;
|
||||
--_c: #0000 66%,var(--c2) 68% 70%,#0000 72%;
|
||||
background:
|
||||
radial-gradient(var(--_g) 100% 25%,var(--_c)) var(--s) var(--s)/var(--_s),
|
||||
radial-gradient(var(--_g) 0 75%,var(--_c)) var(--s) var(--s)/var(--_s),
|
||||
radial-gradient(var(--_g) 100% 25%,var(--_c)) 0 0/var(--_s),
|
||||
radial-gradient(var(--_g) 0 75%,var(--_c)) 0 0/var(--_s),
|
||||
repeating-conic-gradient(var(--c1) 0 25%,#0000 0 50%) 0 0/var(--_s),
|
||||
radial-gradient(var(--_c)) 0 calc(var(--s)/2)/var(--s) var(--s)
|
||||
var(--c1);
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p29,
|
||||
.pattern-p29 {
|
||||
--s: 150px; /* control the size*/
|
||||
--c1: #ccbf82;
|
||||
--c2: #604848;
|
||||
|
||||
--_g: var(--c1) 6.1%,var(--c2) 6.4% 18.6%,var(--c1) 18.9% 31.1%,
|
||||
var(--c2) 31.4% 43.6%,var(--c1) 43.9% 56.1%,var(--c2) 56.4% 68.6%,#0000 68.9%;
|
||||
background:
|
||||
radial-gradient(var(--s) at 100% 0 ,var(--_g)),
|
||||
radial-gradient(var(--s) at 0 0 ,var(--_g)),
|
||||
radial-gradient(var(--s) at 0 100%,var(--_g)),
|
||||
radial-gradient(var(--s) at 100% 100%,var(--_g)) var(--c1);
|
||||
background-size: var(--s) var(--s);
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p30,
|
||||
.pattern-p30 {
|
||||
--s: 150px; /* control the size*/
|
||||
--c1: #046d8b;
|
||||
--c2: #2fb8ac;
|
||||
|
||||
--_g: #0000 90deg,var(--c1) 0;
|
||||
background:
|
||||
conic-gradient(from 116.56deg at calc(100%/3) 0 ,var(--_g)),
|
||||
conic-gradient(from -63.44deg at calc(200%/3) 100%,var(--_g))
|
||||
var(--c2);
|
||||
background-size: var(--s) var(--s);
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p31,
|
||||
.pattern-p31 {
|
||||
--s: 60px; /* control the size*/
|
||||
--c1: #ffdc56;
|
||||
--c2: #fe6601;
|
||||
--c3: #803201;
|
||||
|
||||
--_s: calc(2*var(--s)) calc(2*var(--s));
|
||||
--_g1: var(--_s) conic-gradient(at calc(500%/6) 50%,var(--c3) 25%,#0000 0);
|
||||
--_g2: var(--_s) conic-gradient(at calc(200%/3) 50%,var(--c2) 25%,#0000 0);
|
||||
background:
|
||||
var(--s) var(--s)/var(--_g1),0 0/var(--_g1),
|
||||
var(--s) var(--s)/var(--_g2),0 0/var(--_g2),
|
||||
repeating-conic-gradient(var(--c1) 0 25%,#0000 0 50%) 0 0/var(--_s),
|
||||
linear-gradient(var(--c1) calc(100%/3),var(--c2) 0 calc(200%/3),var(--c3) 0)
|
||||
0 0/var(--s) var(--s);
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p32,
|
||||
.pattern-p32 {
|
||||
--s: 50px; /* control the size*/
|
||||
--c1: #6963bb;
|
||||
--c2: #99ffc0;
|
||||
--c3: #ffd6d6;
|
||||
|
||||
--_g: conic-gradient(at 50% 25%,#0000 75%,var(--c1) 0);
|
||||
background:
|
||||
var(--_g),var(--_g) var(--s) var(--s),
|
||||
var(--_g) calc(2*var(--s)) calc(2*var(--s)),
|
||||
var(--_g) calc(3*var(--s)) calc(3*var(--s)),
|
||||
repeating-linear-gradient(135deg,var(--c2) 0 12.5%,var(--c3) 0 25%);
|
||||
background-size: calc(4*var(--s)) calc(4*var(--s))
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p33,
|
||||
.pattern-p33 {
|
||||
--s: 100px; /* control the size*/
|
||||
--c1: #668284;
|
||||
--c2: #b6d8c0;
|
||||
--c3: #b9d7d9;
|
||||
|
||||
--_g: #0000, var(--c1) 1deg 30deg,var(--c2) 31deg 89deg,var(--c1) 90deg 119deg,#0000 120deg;
|
||||
background:
|
||||
conic-gradient(from -60deg at 50% 28.86%,var(--_g)),
|
||||
conic-gradient(from 30deg at 71.14% 50%,var(--_g)),
|
||||
conic-gradient(from 120deg at 50% 71.14%,var(--_g)),
|
||||
conic-gradient(from 210deg at 28.86% 50%,var(--_g))
|
||||
var(--c3);
|
||||
background-size: var(--s) var(--s);
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p34,
|
||||
.pattern-p34 {
|
||||
--s: 100px; /* control the size*/
|
||||
--c1: #fcd036;
|
||||
--c2: #987f69;
|
||||
|
||||
--_g: var(--c1) 0 100%,#0000 102%;
|
||||
background:
|
||||
conic-gradient(#0000 75%,var(--_g)) calc(var(--s)/4) calc(var(--s)/4),
|
||||
radial-gradient(65% 65% at 50% -50%,var(--_g)),
|
||||
radial-gradient(65% 65% at -50% 50%,var(--_g)),
|
||||
radial-gradient(65% 65% at 50% 150%,var(--_g)),
|
||||
radial-gradient(65% 65% at 150% 50%,var(--_g))
|
||||
var(--c2);
|
||||
background-size: var(--s) var(--s);
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p35,
|
||||
.pattern-p35 {
|
||||
--s: 76px; /* control the size*/
|
||||
--c1: #1a144a;
|
||||
--c2: #2eb044;
|
||||
|
||||
--_g: conic-gradient(var(--c1) 25%,#0000 0) 0 0;
|
||||
background:
|
||||
var(--_g)/calc(2*var(--s)) calc(var(--s)/9.5),
|
||||
var(--_g)/calc(var(--s)/9.5) calc(2*var(--s)),
|
||||
repeating-conic-gradient(#0000 0 25%,var(--c1) 0 50%)
|
||||
var(--s) 0 /calc(2*var(--s)) calc(2*var(--s)),
|
||||
radial-gradient(50% 50%,var(--c2) 98%,var(--c1))
|
||||
0 0/var(--s) var(--s);
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p36,
|
||||
.pattern-p36 {
|
||||
--s: 70px; /* control the size*/
|
||||
--c1: #6b5344;
|
||||
--c2: #f8ecc9;
|
||||
|
||||
--_l: #0000 46%,var(--c1) 47% 53%,#0000 54%;
|
||||
background:
|
||||
radial-gradient(100% 100% at 100% 100%,var(--_l)) var(--s) var(--s),
|
||||
radial-gradient(100% 100% at 0 0 ,var(--_l)) var(--s) var(--s),
|
||||
radial-gradient(100% 100%,#0000 22%,var(--c1) 23% 29%, #0000 30% 34%,var(--c1) 35% 41%,#0000 42%),
|
||||
var(--c2);
|
||||
background-size: calc(var(--s)*2) calc(var(--s)*2);
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p37,
|
||||
.pattern-p37 {
|
||||
--s: 80px; /* control the size*/
|
||||
--c1: #542437;
|
||||
--c2: #c02942;
|
||||
|
||||
--_g:
|
||||
#0000 calc(-650%/13) calc(50%/13),var(--c1) 0 calc(100%/13),
|
||||
#0000 0 calc(150%/13),var(--c1) 0 calc(200%/13),
|
||||
#0000 0 calc(250%/13),var(--c1) 0 calc(300%/13);
|
||||
--_g0: repeating-linear-gradient( 45deg,var(--_g));
|
||||
--_g1: repeating-linear-gradient(-45deg,var(--_g));
|
||||
background:
|
||||
var(--_g0),var(--_g0) var(--s) var(--s),
|
||||
var(--_g1),var(--_g1) var(--s) var(--s) var(--c2);
|
||||
background-size: calc(2*var(--s)) calc(2*var(--s));
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p38,
|
||||
.pattern-p38 {
|
||||
--s: 56px; /* control the size*/
|
||||
--c1: #3fb8af;
|
||||
--c2: #ff9e9d;
|
||||
|
||||
--_c1: var(--c1) 99%,#0000 101%;
|
||||
--_c2: var(--c2) 99%,#0000 101%;
|
||||
|
||||
--r:calc(var(--s)*.866);
|
||||
--g0:radial-gradient(var(--s),var(--_c1));
|
||||
--g1:radial-gradient(var(--s),var(--_c2));
|
||||
--f:radial-gradient(var(--s) at calc(100% + var(--r)) 50%,var(--_c1));
|
||||
--p:radial-gradient(var(--s) at 100% 50%,var(--_c2));
|
||||
background:
|
||||
var(--f) 0 calc(-5*var(--s)/2),
|
||||
var(--f) calc(-2*var(--r)) calc(var(--s)/2),
|
||||
var(--p) 0 calc(-2*var(--s)),
|
||||
var(--g0) var(--r) calc(-5*var(--s)/2),
|
||||
var(--g1) var(--r) calc( 5*var(--s)/2),
|
||||
radial-gradient(var(--s) at 100% 100%,var(--_c1)) 0 calc(-1*var(--s)),
|
||||
radial-gradient(var(--s) at 0% 50% ,var(--_c1)) 0 calc(-4*var(--s)),
|
||||
var(--g1) calc(-1*var(--r)) calc(-7*var(--s)/2),
|
||||
var(--g0) calc(-1*var(--r)) calc(-5*var(--s)/2),
|
||||
var(--p) calc(-2*var(--r)) var(--s),
|
||||
var(--g0) calc(-1*var(--r)) calc(var(--s)/ 2),
|
||||
var(--g1) calc(-1*var(--r)) calc(var(--s)/-2),
|
||||
var(--g0) 0 calc(-1*var(--s)),
|
||||
var(--g1) var(--r) calc(var(--s)/-2),
|
||||
var(--g0) var(--r) calc(var(--s)/ 2)
|
||||
var(--c2);
|
||||
background-size: calc(4*var(--r)) calc(6*var(--s));
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p39,
|
||||
.pattern-p39 {
|
||||
--s: 50px; /* control the size*/
|
||||
--c1: #cff09e;
|
||||
--c2: #0b486b;
|
||||
|
||||
--_g: radial-gradient(var(--c2) 49%,#0000 50%);
|
||||
background:
|
||||
var(--_g) calc(var(--s)/-2) calc(var(--s)/2),
|
||||
repeating-conic-gradient(from 45deg,var(--c1) 0 25%,#0000 0 50%)
|
||||
calc(var(--s)/2) calc(var(--s)/2),
|
||||
var(--_g),var(--_g) var(--s) var(--s) var(--c1);
|
||||
background-size: calc(2*var(--s)) calc(2*var(--s));
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p40,
|
||||
.pattern-p40 {
|
||||
--s: 75px; /* control the size*/
|
||||
--c1: #1fffa9;
|
||||
--c2: #a31e39;
|
||||
--c3: #31570e;
|
||||
|
||||
--_c:#0000,var(--c1) 1deg 79deg,#0000 81deg;
|
||||
--g0:conic-gradient(from 140deg at 50% 87.5% ,var(--_c));
|
||||
--g1:conic-gradient(from 140deg at 50% 81.25%,var(--_c));
|
||||
--g2:conic-gradient(from 140deg at 50% 75% ,var(--_c));
|
||||
--g3:conic-gradient(at 10% 20%,#0000 75%,var(--c1) 0);
|
||||
background:
|
||||
var(--g0) 0 calc(var(--s)/-4),var(--g0) var(--s) calc(3*var(--s)/4),
|
||||
var(--g1) ,var(--g1) var(--s) var(--s),
|
||||
var(--g2) 0 calc(var(--s)/ 4),var(--g2) var(--s) calc(5*var(--s)/4),
|
||||
var(--g3) calc( var(--s)/-10) var(--s),
|
||||
var(--g3) calc(9*var(--s)/10) calc(2*var(--s)),
|
||||
repeating-conic-gradient(from 45deg,var(--c2) 0 25%,var(--c3) 0 50%);
|
||||
background-size: calc(2*var(--s)) calc(2*var(--s));
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p41,
|
||||
.pattern-p41 {
|
||||
--s: 150px; /* control the size*/
|
||||
--c1: #5e412f;
|
||||
--c2: #fcebb6;
|
||||
|
||||
background:
|
||||
linear-gradient(135deg,#0000 18.75%,var(--c1) 0 31.25%,#0000 0),
|
||||
repeating-linear-gradient(45deg,var(--c1) -6.25% 6.25%,var(--c2) 0 18.75%);
|
||||
background-size: var(--s) var(--s);
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p42,
|
||||
.pattern-p42 {
|
||||
--s: 120px; /* control the size*/
|
||||
--c1: #1a2030;
|
||||
--c2: #0f9177;
|
||||
--c3: #fdebad;
|
||||
--c4: #d34434;
|
||||
--c5: #b5d999;
|
||||
|
||||
--_g: radial-gradient(#0000 70%,var(--c1) 71%);
|
||||
background:
|
||||
var(--_g),var(--_g) calc(var(--s)/2) calc(var(--s)/2),
|
||||
conic-gradient(var(--c2) 25%,var(--c3) 0 50%,var(--c4) 0 75%,var(--c5) 0);
|
||||
background-size: var(--s) var(--s);
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p43,
|
||||
.pattern-p43 {
|
||||
--s: 34px; /* control the size*/
|
||||
--c1: #ecbe13;
|
||||
--c2: #309292;
|
||||
|
||||
--_g: radial-gradient(calc(var(--s)/2),var(--c1) 97%,#0000);
|
||||
background:
|
||||
var(--_g),var(--_g) calc(2*var(--s)) calc(2*var(--s)),
|
||||
repeating-conic-gradient(from 45deg,#0000 0 25%,var(--c2) 0 50%) calc(-.707*var(--s)) calc(-.707*var(--s)),
|
||||
repeating-linear-gradient(135deg,var(--c1) calc(var(--s)/-2) calc(var(--s)/2),var(--c2) 0 calc(2.328*var(--s)));
|
||||
background-size: calc(4*var(--s)) calc(4*var(--s));
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p44,
|
||||
.pattern-p44 {
|
||||
--s: 60px; /* control the size*/
|
||||
--c1: #7bb0a8;
|
||||
--c2: #a7dbab;
|
||||
|
||||
--_c: var(--c1) calc(100% - var(--s)/2) 99%,#0000;
|
||||
--_g: var(--s),#0000 calc(99% - var(--s)/2),var(--_c);
|
||||
background:
|
||||
radial-gradient(var(--s) at 100% var(--_g)),
|
||||
radial-gradient(calc(var(--s)/4) at 50% calc(100%/3),var(--_c)) var(--s) 0,
|
||||
radial-gradient(var(--s) at 0% var(--_g)) 0 calc(3*var(--s))
|
||||
var(--c2);
|
||||
background-size:
|
||||
calc(2*var(--s)) calc(9*var(--s)/4),
|
||||
calc(2*var(--s)) calc(3*var(--s)/4);
|
||||
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p45,
|
||||
.pattern-p45 {
|
||||
--s: 30px; /* control the size*/
|
||||
--c1: #e5fcc2;
|
||||
--c2: #45ada8;
|
||||
|
||||
--_s:37.5% 12.5% at 62.5%;
|
||||
--_g:34% 99%,#0000 101%;
|
||||
--g1:radial-gradient(var(--_s) 100%,#0000 32%,var(--c1) var(--_g));
|
||||
--g2:radial-gradient(var(--_s) 0 ,#0000 32%,var(--c1) var(--_g));
|
||||
--g3:radial-gradient(var(--_s) 100%,#0000 32%,var(--c2) var(--_g));
|
||||
--g4:radial-gradient(var(--_s) 0 ,#0000 32%,var(--c2) var(--_g));
|
||||
background:
|
||||
var(--g1),
|
||||
var(--g2) 0 calc(3*var(--s)),
|
||||
var(--g3) var(--s) calc(3*var(--s)),
|
||||
var(--g4) var(--s) calc(6*var(--s)),
|
||||
var(--g1) calc(2*var(--s)) calc(6*var(--s)),
|
||||
var(--g2) calc(2*var(--s)) calc(9*var(--s)),
|
||||
var(--g3) calc(3*var(--s)) calc(9*var(--s)),
|
||||
var(--g4) calc(3*var(--s)) 0,
|
||||
repeating-linear-gradient(var(--c1) 0 25%,var(--c2) 0 50%);
|
||||
background-size: calc(4*var(--s)) calc(12*var(--s));
|
||||
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p46,
|
||||
.pattern-p46 {
|
||||
--s: 65px; /* control the size*/
|
||||
--c1: #ecd078;
|
||||
--c2: #0b486b;
|
||||
|
||||
--g: calc(var(--s)/5); /* control the gap */
|
||||
--_l: #0000 calc(33% - .866*var(--g)),var(--c1) calc(33.2% - .866*var(--g)) 33%,#0000 34%;
|
||||
background:
|
||||
repeating-linear-gradient(var(--c1) 0 var(--g), #0000 0 50%)
|
||||
0 calc(.866*var(--s) - var(--g)/2),
|
||||
conic-gradient(from -150deg at var(--g) 50%,var(--c1) 120deg,#0000 0),
|
||||
linear-gradient(-120deg,var(--_l)),linear-gradient( -60deg,var(--_l))
|
||||
var(--c2);
|
||||
background-size: var(--s) calc(3.466*var(--s))
|
||||
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p47,
|
||||
.pattern-p47 {
|
||||
--s: 100px; /* control the size*/
|
||||
--c1: #f2e9e1;
|
||||
--c2: #99b2b7;
|
||||
|
||||
background:
|
||||
repeating-conic-gradient(var(--c1) 0 45deg,var(--c2) 0 90deg)
|
||||
0/var(--s) var(--s);
|
||||
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p48,
|
||||
.pattern-p48 {
|
||||
--s: 50px; /* control the size*/
|
||||
--c1: #d9ceb2;
|
||||
--c2: #948c75;
|
||||
|
||||
--_g: calc(2*var(--s)) calc(2*var(--s))
|
||||
radial-gradient(25% 25%,var(--c1) 99%,#0000 101%);
|
||||
background:
|
||||
0 var(--s)/var(--_g),var(--s) 0/var(--_g),
|
||||
radial-gradient(50% 50%,var(--c2) 97%,#0000)
|
||||
calc(var(--s)/2) calc(var(--s)/2)/var(--s) var(--s),
|
||||
linear-gradient(90deg,var(--c1) 50%,var(--c2) 0)
|
||||
0 0/calc(2*var(--s));
|
||||
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p49,
|
||||
.pattern-p49 {
|
||||
--s: 100px; /* control the size*/
|
||||
--c1: #5a3d31;
|
||||
--c2: #e5edb8;
|
||||
|
||||
background:
|
||||
radial-gradient(#0000 50%,var(--c1) 52% 55%,var(--c2) 57%)
|
||||
0 0/var(--s) var(--s),
|
||||
repeating-linear-gradient(45deg,var(--c1) 0 25%,var(--c2) 0 50%)
|
||||
0 0/calc(2*var(--s)) calc(2*var(--s))
|
||||
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p50,
|
||||
.pattern-p50 {
|
||||
--s: 50px; /* control the size*/
|
||||
--c1: #e08e79;
|
||||
--c2: #774f38;
|
||||
|
||||
--c: var(--c1) 0 25%,var(--c2) 0 50%,#0000 0;
|
||||
background:
|
||||
conic-gradient(from 180deg,var(--c))
|
||||
0/var(--s) var(--s),
|
||||
repeating-conic-gradient(from 90deg,var(--c))
|
||||
0/calc(3*var(--s)) calc(3*var(--s));
|
||||
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p51,
|
||||
.pattern-p51 {
|
||||
--s: 100px; /* control the size*/
|
||||
--c1: #cc2a41;
|
||||
--c2: #351330;
|
||||
|
||||
--_g: #0000 24%,
|
||||
var(--c2) 26% 34%,var(--c1) 36% 44%,
|
||||
var(--c2) 46% 54%,var(--c1) 56% 64%,
|
||||
var(--c2) 66% 74%,#0000 76%;
|
||||
background:
|
||||
radial-gradient(100% 100% at 100% 0,var(--_g)),
|
||||
radial-gradient(100% 100% at 0 100%,var(--_g)),
|
||||
radial-gradient(var(--c2) 14%,var(--c1) 16%)
|
||||
calc(var(--s)/2) calc(var(--s)/2);
|
||||
background-size: var(--s) var(--s);
|
||||
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p52,
|
||||
.pattern-p52 {
|
||||
--s: 29px; /* control the size*/
|
||||
--c1: #615375;
|
||||
--c2: #8eb2c5;
|
||||
|
||||
--g:#0000 calc(100% - var(--s)/3 - 1px),var(--c1)
|
||||
calc(100% - var(--s)/3) calc(100% - 1px),#0000;
|
||||
--r:calc(2.414*var(--s));
|
||||
--p:calc(1.414*var(--s));
|
||||
background:
|
||||
radial-gradient(var(--r) at 0 0 ,var(--g)) var(--r) var(--r),
|
||||
radial-gradient(var(--s) at 0 0 ,var(--g)) var(--p) var(--p),
|
||||
radial-gradient(var(--r) at 0 100%,var(--g)) 0 var(--p),
|
||||
radial-gradient(var(--s) at 0 100%,var(--g)) calc(-1*var(--s)) var(--r),
|
||||
radial-gradient(var(--r) at 100% 0 ,var(--g)) var(--p) 0,
|
||||
radial-gradient(var(--s) at 100% 0 ,var(--g)) var(--r) calc(-1*var(--s)),
|
||||
radial-gradient(var(--r) at 100% 100%,var(--g)) calc(-1*var(--s)) calc(-1*var(--s)),
|
||||
radial-gradient(var(--s) at 100% 100%,var(--g)) var(--c2);
|
||||
background-size: calc(2*var(--r)) calc(2*var(--r));
|
||||
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p53,
|
||||
.pattern-p53 {
|
||||
--s: 160px; /* control the size*/
|
||||
--c1: #a14016;
|
||||
--c2: #cfc89a;
|
||||
|
||||
--_g: var(--s) var(--s)
|
||||
radial-gradient(var(--c1) 17%,var(--c2) 18% 35%,#0000 36.5%);
|
||||
background:
|
||||
calc(var(--s)/-4) calc(var(--s)/-4)/var(--_g),
|
||||
calc(var(--s)/ 4) calc(var(--s)/ 4)/var(--_g),
|
||||
radial-gradient(var(--c2) 34%,var(--c1) 36% 68%,#0000 70%)
|
||||
0 0/calc(var(--s)/2) calc(var(--s)/2),
|
||||
repeating-linear-gradient(45deg,var(--c1) -12.5% 12.5%,var(--c2) 0 37.5%)
|
||||
0 0/var(--s) var(--s);
|
||||
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p54,
|
||||
.pattern-p54 {
|
||||
--s: 100px; /* control the size*/
|
||||
--c1: #ffd500;
|
||||
--c2: #0341ae;
|
||||
|
||||
background:
|
||||
conic-gradient(at 50% 25%,var(--c2) 25%,#0000 0)
|
||||
calc(var(--s)/-4) calc(var(--s)/-2),
|
||||
conic-gradient(at 25% 25%,var(--c1) 25%,#0000 0 75%,var(--c2) 0)
|
||||
calc(var(--s)/4) calc(var(--s)/-4),
|
||||
conic-gradient(from -90deg at 75% 25%,var(--c2) 75%,var(--c1) 0);
|
||||
background-size: var(--s) var(--s);
|
||||
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p55,
|
||||
.pattern-p55 {
|
||||
--s: 50px; /* control the size*/
|
||||
--c1: #292522;
|
||||
--c2: #efd9b4;
|
||||
|
||||
--c: #0000 75%,var(--c1) 0;
|
||||
--g1: conic-gradient(at 75% 25%,var(--c));
|
||||
--g2: conic-gradient(at 25% 75%,var(--c));
|
||||
background:
|
||||
var(--g1),var(--g1) var(--s) var(--s),
|
||||
var(--g2),var(--g2) var(--s) var(--s) var(--c2);
|
||||
background-size: calc(2*var(--s)) calc(2*var(--s));
|
||||
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p56,
|
||||
.pattern-p56 {
|
||||
--s: 6px; /* control the size*/
|
||||
--c1: #3b2d38;
|
||||
--c2: #f02475;
|
||||
|
||||
--g:#0000 66%,var(--c1) 68% 98%,#0000;
|
||||
background:
|
||||
radial-gradient(30% 30% at 0% 30%,var(--g))
|
||||
var(--s) calc(9*var(--s)),
|
||||
radial-gradient(30% 30% at 100% 30%,var(--g))
|
||||
var(--s) calc(-1*var(--s)),
|
||||
radial-gradient(30% 30% at 30% 100%,var(--g))
|
||||
calc(10*var(--s)) 0,
|
||||
radial-gradient(30% 30% at 30% 0% ,var(--g))
|
||||
var(--c2);
|
||||
background-size: calc(20*var(--s)) calc(20*var(--s));
|
||||
|
||||
}
|
||||
|
||||
html .app-shell.pattern-p57,
|
||||
.pattern-p57 {
|
||||
--s: 194px; /* control the size*/
|
||||
--c1: #f6edb3;
|
||||
--c2: #acc4a3;
|
||||
--c3: #55897c;
|
||||
|
||||
--_l:#0000 calc(25%/3),var(--c1) 0 25%,#0000 0;
|
||||
--_g:conic-gradient(from 120deg at 50% 87.5%,var(--c1) 120deg,#0000 0);
|
||||
background:
|
||||
var(--_g),var(--_g) 0 calc(var(--s)/2),
|
||||
conic-gradient(from 180deg at 75%,var(--c2) 60deg,#0000 0),
|
||||
conic-gradient(from 60deg at 75% 75%,var(--c1) 0 60deg,#0000 0),
|
||||
linear-gradient(150deg,var(--_l)) 0 calc(var(--s)/2),
|
||||
conic-gradient(at 25% 25%,#0000 50%,var(--c2) 0 240deg,var(--c1) 0 300deg,var(--c2) 0),
|
||||
linear-gradient(-150deg,var(--_l)) var(--c3);
|
||||
background-size: calc(0.866*var(--s)) var(--s);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Theme helpers — derive subtle pattern shades from a user's chosen color
|
||||
// swatch. Patterns in theme-patterns.css read --c1..--c4; we override them
|
||||
// with hsl variations of the user's hue so the background stays subtle.
|
||||
|
||||
export function hexToHsl(hex: string): { h: number; s: number; l: number } {
|
||||
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
||||
if (!m) return { h: 243, s: 75, l: 66 }; // indigo fallback
|
||||
const n = parseInt(m[1], 16);
|
||||
const r = ((n >> 16) & 255) / 255;
|
||||
const g = ((n >> 8) & 255) / 255;
|
||||
const b = (n & 255) / 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2;
|
||||
if (max === min) return { h: 0, s: 0, l: Math.round(l * 100) };
|
||||
const d = max - min;
|
||||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
let h = 0;
|
||||
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
else if (max === g) h = ((b - r) / d + 2) / 6;
|
||||
else h = ((r - g) / d + 4) / 6;
|
||||
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
|
||||
}
|
||||
|
||||
// Four subtle shades built on the swatch's hue — pastel tints distinct
|
||||
// enough that the pattern motif reads against the near-white app shell,
|
||||
// but soft enough to stay a background wash rather than a loud print.
|
||||
// `alpha` (0..1) fades the whole motif toward the shell background — the
|
||||
// app-shell keeps `background: whitesmoke` underneath, so lowering it
|
||||
// blends the pattern out.
|
||||
export function themeShades(
|
||||
hex: string,
|
||||
alpha = 1
|
||||
): { c1: string; c2: string; c3: string; c4: string } {
|
||||
const { h } = hexToHsl(hex);
|
||||
return {
|
||||
c1: `hsl(${h} 45% 93% / ${alpha})`,
|
||||
c2: `hsl(${h} 50% 85% / ${alpha})`,
|
||||
c3: `hsl(${h} 55% 74% / ${alpha})`,
|
||||
c4: `hsl(${(h + 40) % 360} 50% 87% / ${alpha})`
|
||||
};
|
||||
}
|
||||
|
||||
// Pattern ids: 'p1'..'p60', '' = none. Currently showing the first 19;
|
||||
// bump this to include more (the CSS file still ships all 60).
|
||||
export const PATTERN_COUNT = 19;
|
||||
export function patternId(n: number): string {
|
||||
return `p${n}`;
|
||||
}
|
||||
|
||||
// Pattern size slider — the value is the pattern's `--s` in vw units, so the
|
||||
// motif scales with the viewport. E.g. 7 at a 1400px screen ≈ 98px tiles.
|
||||
export const THEME_SIZE_MIN = 2;
|
||||
export const THEME_SIZE_MAX = 16;
|
||||
export const THEME_SIZE_DEFAULT = 7;
|
||||
|
||||
// Pattern opacity slider — 1 = current full visibility, down to a faint hint.
|
||||
export const THEME_OPACITY_MIN = 0.05;
|
||||
export const THEME_OPACITY_MAX = 1;
|
||||
export const THEME_OPACITY_STEP = 0.05;
|
||||
export const THEME_OPACITY_DEFAULT = 1;
|
||||
@@ -643,4 +643,9 @@
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
h1 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { redirect } from '@sveltejs/kit';
|
||||
import { createPbClient } from '$lib/server/pocketbase';
|
||||
import { createServices, type ChatActor } from '$lib/server/services';
|
||||
import { ensureFamAccess } from '$lib/server/access';
|
||||
import { seedDemoCompletions } from '$lib/server/migrate';
|
||||
import { getPlatformFlags } from '$lib/server/platform';
|
||||
|
||||
async function paydayCheck(famId: string, pbToken: string) {
|
||||
try {
|
||||
@@ -46,6 +48,11 @@ async function resolveChatIdentity(
|
||||
}
|
||||
|
||||
export async function load(event) {
|
||||
// Demo family: rotate 3-week completion data on every visit when demo mode is on (idempotent).
|
||||
if (event.params.fam === 'showboaters' && (await getPlatformFlags()).demo) {
|
||||
seedDemoCompletions().catch(() => {});
|
||||
}
|
||||
|
||||
const session = event.locals.user;
|
||||
const role = session?.role || 'child';
|
||||
const isParent = role === 'parent';
|
||||
@@ -88,6 +95,8 @@ export async function load(event) {
|
||||
}
|
||||
}
|
||||
|
||||
const demoMode = (await getPlatformFlags()).demo;
|
||||
|
||||
return {
|
||||
// Canonical fam slug — from the URL param ([fam] routes). Client code
|
||||
// reads page.data.famSlug; never copy it into local $state.
|
||||
@@ -98,6 +107,9 @@ export async function load(event) {
|
||||
userId: session.id,
|
||||
memberName: session.name,
|
||||
memberColor: session.color || '',
|
||||
memberPattern: session.pattern || '',
|
||||
memberThemeSize: session.themeSize || '',
|
||||
memberThemeOpacity: session.themeOpacity || '',
|
||||
username: session.username || '',
|
||||
role: session.role
|
||||
}
|
||||
@@ -108,6 +120,7 @@ export async function load(event) {
|
||||
chat,
|
||||
pbToken,
|
||||
fam,
|
||||
famAccess
|
||||
famAccess,
|
||||
demoMode
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,12 +9,30 @@
|
||||
import { Sidebar, TopNav, Footer, Chat } from '$lib/components';
|
||||
import { chatIcon } from '$lib/components/icons';
|
||||
import { recordShortcut } from '$lib/shortcut';
|
||||
import { themeShades } from '$lib/theme';
|
||||
import '$lib/theme-patterns.css';
|
||||
import { themeDraft } from '$lib/stores/theme.svelte';
|
||||
import type { Session } from '$lib/types';
|
||||
|
||||
let { children, data } = $props();
|
||||
|
||||
// Background pattern ('' = none) tinted with the user's colour. A live
|
||||
// draft (clicked in the picker, not yet saved) previews immediately;
|
||||
// otherwise the saved value from the session applies.
|
||||
let bgPattern = $derived(themeDraft.pattern ?? (data.session?.memberPattern || ''));
|
||||
let bgColor = $derived(themeDraft.color ?? (data.session?.memberColor || '#6366f1'));
|
||||
let bgOpacity = $derived(
|
||||
themeDraft.opacity ??
|
||||
(Number(data.session?.memberThemeOpacity) > 0 ? data.session?.memberThemeOpacity : 1)
|
||||
);
|
||||
let bgShades = $derived(themeShades(bgColor, Number(bgOpacity) || 1));
|
||||
// Pattern size in vw units ('' = untouched → pattern class default).
|
||||
let bgSize = $derived(themeDraft.size ?? (data.session?.memberThemeSize || ''));
|
||||
let bgSizeVw = $derived(Number(bgSize) > 0 ? bgSize : '');
|
||||
let session = $state<Session | null>(data.session);
|
||||
let isParent = $state(data.isParent);
|
||||
let role = $state(data.role);
|
||||
let isDemo = $state(data.demoMode);
|
||||
let famName = $derived(
|
||||
famStore.initialized ? (famStore.fam as any)?.name || page.params.fam : page.params.fam
|
||||
);
|
||||
@@ -126,7 +144,7 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!session || !famStore.initialized) return;
|
||||
if (!isParent || !session || !famStore.initialized) return;
|
||||
const rewards = famStore.rewards;
|
||||
if (!mounted) {
|
||||
mounted = true;
|
||||
@@ -176,11 +194,14 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="layout-stage" class:chat-open={chatStore.open}>
|
||||
<div class="app-shell">
|
||||
<Sidebar {famName} session={data.session} {isParent} {role} />
|
||||
<div class="layout-stage" class:chat-open={chatStore.open} class:has-pattern={!!bgPattern}>
|
||||
<div
|
||||
class="app-shell {bgPattern ? `pattern-${bgPattern}` : ''}"
|
||||
style={`${bgSizeVw ? `--s:calc(${bgSizeVw} * 1vw);` : ''}--c1:${bgShades.c1};--c2:${bgShades.c2};--c3:${bgShades.c3};--c4:${bgShades.c4}`}
|
||||
>
|
||||
<Sidebar {famName} session={data.session} {isParent} {role} {isDemo} />
|
||||
<TopNav
|
||||
{role}
|
||||
role={data.session ? role : ''}
|
||||
seasons={famStore.seasons.filter((s) => s.active !== false)}
|
||||
announcement={disabled && !activating ? accessMessage(accessReason, isParent) : ''}
|
||||
>
|
||||
@@ -191,7 +212,7 @@
|
||||
{/if}
|
||||
</button>
|
||||
</TopNav>
|
||||
<main class="app-main">
|
||||
<main class="app-main" class:join-page={page.url.pathname.split('/')[2] === 'join'}>
|
||||
<div class="page-wrap">
|
||||
<div class="page-content" class:locked>{@render children()}</div>
|
||||
|
||||
@@ -218,7 +239,12 @@
|
||||
</div>
|
||||
</main>
|
||||
{#if claimToast}
|
||||
<div class="claim-toast">{claimToast}</div>
|
||||
<a
|
||||
class="claim-toast"
|
||||
href={`/${page.params.fam}/${data.session?.username || page.params.username || ''}`}
|
||||
>
|
||||
{claimToast} — view dashboard →
|
||||
</a>
|
||||
{/if}
|
||||
<Footer sidebar />
|
||||
</div>
|
||||
@@ -232,17 +258,33 @@
|
||||
<style>
|
||||
.claim-toast {
|
||||
position: fixed;
|
||||
top: 3.5rem;
|
||||
right: 1.5rem;
|
||||
background: #059669;
|
||||
color: white;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px;
|
||||
top: 52px;
|
||||
left: 220px;
|
||||
right: 0;
|
||||
background: #f59e0b;
|
||||
color: #fff;
|
||||
padding: 0.5rem 1.5rem;
|
||||
font-size: 0.85rem;
|
||||
z-index: 999;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
font-weight: 600;
|
||||
z-index: 95;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
|
||||
text-decoration: none;
|
||||
animation: fadein 0.2s;
|
||||
}
|
||||
.claim-toast:hover {
|
||||
background: #d97706;
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.claim-toast {
|
||||
left: 56px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.claim-toast {
|
||||
left: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
}
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
@@ -283,6 +325,16 @@
|
||||
margin-left: 56px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.app-main {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
/* Join pages center their own content — no shell padding needed */
|
||||
.app-main.join-page {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
.page-wrap {
|
||||
position: relative;
|
||||
min-height: 70vh;
|
||||
|
||||
@@ -49,7 +49,6 @@
|
||||
let tileChores = $derived(
|
||||
summary?.summaries?.reduce((s: number, m: any) => s + (m.choresCompleted || 0), 0) ?? 0
|
||||
);
|
||||
|
||||
// ─── All-time header stats (server-computed) ───
|
||||
let allTimePts = $derived(Number(stats.allTimePts) || 0);
|
||||
let allTimeCash = $derived(Number(stats.allTimeCash) || 0);
|
||||
@@ -58,6 +57,9 @@
|
||||
// ─── Goals completed (rewards from bonus configs, pocket money excluded) ───
|
||||
// Derived from famStore so realtime updates (a goal met / revoked) flow in.
|
||||
let rewards = $derived(famStore.initialized ? famStore.rewards : data.rewards || []);
|
||||
// All-time goals achieved — every materialized bonus reward (prize / points /
|
||||
// cash), including pocket money configs.
|
||||
let tileGoals = $derived(rewards.filter((r: any) => r.bonusConfigId).length);
|
||||
let bonusConfigs = $derived(
|
||||
famStore.initialized ? famStore.bonusConfigs : data.bonusConfigs || []
|
||||
);
|
||||
@@ -326,6 +328,10 @@
|
||||
<span class="tile-val">£{tileMoney.toFixed(2)}</span>
|
||||
<span class="tile-lbl">cash this week</span>
|
||||
</div>
|
||||
<div class="tile tile-goals">
|
||||
<span class="tile-val">{tileGoals}</span>
|
||||
<span class="tile-lbl">goals achieved</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardGrid>
|
||||
@@ -364,7 +370,7 @@
|
||||
{@const scVal = avgDailyPointsFor(s)}
|
||||
{@const scPct = scPot > 0 ? Math.min(100, Math.round((scVal / scPot) * 100)) : 0}
|
||||
{@const goals = goalsByMember.get(s.memberId)}
|
||||
<div class="column col-member">
|
||||
<div class="column col-member" style="--mc: {s.memberColor}">
|
||||
<div class="member-head">
|
||||
<span class="dot" style="background:{s.memberColor}"></span>
|
||||
<span class="member-name">{s.memberName}</span>
|
||||
@@ -527,7 +533,8 @@
|
||||
/* ── Tiles (matching child dashboard) ── */
|
||||
.tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
/* members 1 unit wide; goals / points / cash 2 units each (1+2+2+2 on one row) */
|
||||
grid-template-columns: 1fr 2fr 2fr 2fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -535,6 +542,9 @@
|
||||
.tiles {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.vh-num {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
}
|
||||
.tile {
|
||||
border-radius: 14px;
|
||||
@@ -557,6 +567,9 @@
|
||||
.tile-chores {
|
||||
background: linear-gradient(135deg, #0284c7, #38bdf8);
|
||||
}
|
||||
.tile-goals {
|
||||
background: linear-gradient(135deg, #f59e0b, #f97316);
|
||||
}
|
||||
.tile-val {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
@@ -584,7 +597,8 @@
|
||||
}
|
||||
.col-member {
|
||||
border-top-color: #6366f1;
|
||||
background: #eef2ff;
|
||||
/* Translucent wash of the member's own colour (set via --mc inline) */
|
||||
background: color-mix(in srgb, var(--mc, #6366f1) 12%, transparent);
|
||||
}
|
||||
|
||||
.member-head {
|
||||
@@ -705,4 +719,13 @@
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
/* Mobile: member columns stack full width (same convention as the chores
|
||||
kanban) instead of the 450px horizontal scroll. Declared last so it wins
|
||||
over the base .kanban rule. */
|
||||
@media (max-width: 639px) {
|
||||
.kanban {
|
||||
grid-template-columns: 1fr;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { pbUser, createPbClient } from '$lib/server/pocketbase';
|
||||
import { createServices } from '$lib/server/services';
|
||||
import { getPlatformFlags } from '$lib/server/platform';
|
||||
import { pbAdmin } from '$lib/server/pocketbase';
|
||||
|
||||
export async function load(event) {
|
||||
const session = event.locals.user;
|
||||
const demoMode = (await getPlatformFlags()).demo;
|
||||
const isDemoParent = session?.username === 'demo';
|
||||
const famSlug = event.params.fam;
|
||||
const username = event.params.username;
|
||||
|
||||
// Demo parent visiting a child's route → load child kanban view.
|
||||
const demoParentVisitingChild = demoMode && isDemoParent && session?.username !== username;
|
||||
|
||||
// Parent (role=parent, session auth) → admin overview.
|
||||
// Branch on role, NOT presence: a child who OTP-logged-in also has a
|
||||
// `users` session (locals.user) and must not hit the parent path.
|
||||
if (session && session.role === 'parent') {
|
||||
const famId = session.famId;
|
||||
const famSlug = event.params.fam;
|
||||
const username = event.params.username;
|
||||
if (session.name && session.username && session.username !== username) {
|
||||
throw redirect(303, `/${famSlug}/${session.username}`);
|
||||
if (session && session.role === 'parent' && !demoParentVisitingChild) {
|
||||
if (!(demoMode && isDemoParent)) {
|
||||
if (session.name && session.username && session.username !== username) {
|
||||
throw redirect(303, `/${famSlug}/${session.username}`);
|
||||
}
|
||||
}
|
||||
const s = createServices(pbUser(event), session);
|
||||
const [
|
||||
@@ -27,15 +35,15 @@ export async function load(event) {
|
||||
completions,
|
||||
settings
|
||||
] = await Promise.all([
|
||||
s.crud.list('members', famId),
|
||||
s.crud.list('chore-templates', famId),
|
||||
s.crud.list('assigned-chores', famId),
|
||||
s.fam.weeklySummary(famId),
|
||||
s.fam.get(famId),
|
||||
s.crud.list('rewards', famId),
|
||||
s.crud.list('bonus-configs', famId),
|
||||
s.crud.list('completions', famId),
|
||||
s.settings.get(famId)
|
||||
s.crud.list('members', session.famId),
|
||||
s.crud.list('chore-templates', session.famId),
|
||||
s.crud.list('assigned-chores', session.famId),
|
||||
s.fam.weeklySummary(session.famId),
|
||||
s.fam.get(session.famId),
|
||||
s.crud.list('rewards', session.famId),
|
||||
s.crud.list('bonus-configs', session.famId),
|
||||
s.crud.list('completions', session.famId),
|
||||
s.settings.get(session.famId)
|
||||
]);
|
||||
return {
|
||||
role: 'parent',
|
||||
@@ -52,18 +60,23 @@ export async function load(event) {
|
||||
};
|
||||
}
|
||||
|
||||
// Child (users auth, role=child) → kanban
|
||||
const famSlug = event.params.fam;
|
||||
const username = event.params.username;
|
||||
// Child (users auth, role=child) → kanban.
|
||||
// Also demo parent visiting a child's route.
|
||||
|
||||
// A child should always land on their own kanban route.
|
||||
if (session?.username && session.username !== username) {
|
||||
throw redirect(303, `/${famSlug}/${session.username}`);
|
||||
if (!(demoMode && isDemoParent)) {
|
||||
if (session?.username && session.username !== username) {
|
||||
throw redirect(303, `/${famSlug}/${session.username}`);
|
||||
}
|
||||
}
|
||||
|
||||
const pbToken = event.cookies.get('pb_token') || '';
|
||||
const famId = session?.famId || '';
|
||||
const childId = session?.id || '';
|
||||
// When a demo parent visits a child's route, session.id is the parent's PB
|
||||
// record. Look up the actual child user by username so chores load correctly.
|
||||
const childId = demoParentVisitingChild
|
||||
? (await pbAdmin.getList('users', `famId='${famId}' && username='${famSlug}:${username}'`))?.[0]?.id || ''
|
||||
: session?.id || '';
|
||||
|
||||
const empty = {
|
||||
role: 'child' as const,
|
||||
|
||||
@@ -103,6 +103,8 @@
|
||||
);
|
||||
}
|
||||
|
||||
let hasClaimableRewards = $derived(claimableRewards().length > 0);
|
||||
|
||||
function memberClaimable(memberId: string) {
|
||||
return claimableRewards().filter((r: any) => r.memberId === memberId);
|
||||
}
|
||||
@@ -144,10 +146,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Same "Bonus name – prize" split as the ledger, so prize rewards don't
|
||||
// duplicate the full label in both columns of a claim row.
|
||||
function rewardLabelParts(r: any): { name: string; prize: string } {
|
||||
const label = r.label || '';
|
||||
const idx = label.indexOf(' – ');
|
||||
if (idx === -1) return { name: label, prize: label };
|
||||
return { name: label.slice(0, idx), prize: label.slice(idx + 3) };
|
||||
}
|
||||
|
||||
function rewardLabel(r: any): string {
|
||||
if (r.rewardType === 'cash') return `£${Number(r.value).toFixed(2)}`;
|
||||
if (r.rewardType === 'points') return `${r.value} pts`;
|
||||
return r.label;
|
||||
return rewardLabelParts(r).prize;
|
||||
}
|
||||
|
||||
function manualConfigs() {
|
||||
@@ -605,8 +616,14 @@
|
||||
const existingReward = rewards.find(
|
||||
(r) => r.bonusConfigId === cfg.id && r.memberId === memberId
|
||||
);
|
||||
// An earned reward only keeps lighting up the card while it's fresh
|
||||
// (earned in the viewed week) or still outstanding (not yet claimed).
|
||||
// Once claimed and the week rolls over, "Reward earned" disappears.
|
||||
const rDate = (existingReward?.date || '').slice(0, 10);
|
||||
const rewardInWeek = !!rDate && rDate >= weekStart && rDate <= weekEnd;
|
||||
const rewardOutstanding = !!existingReward && existingReward.status !== 'claimed';
|
||||
const achieved = existingReward
|
||||
? true
|
||||
? rewardInWeek || rewardOutstanding
|
||||
: isPercent
|
||||
? totalPotential > 0 && ratio * 100 >= configured
|
||||
: target > 0 && current >= target;
|
||||
@@ -729,6 +746,12 @@
|
||||
function headDay(): number {
|
||||
return headDate().getDate();
|
||||
}
|
||||
function headDayOrdinal(): string {
|
||||
const n = headDay();
|
||||
if (n % 100 >= 11 && n % 100 <= 13) return `${n}th`;
|
||||
const s = ['th', 'st', 'nd', 'rd'];
|
||||
return `${n}${s[n % 10] || s[0]}`;
|
||||
}
|
||||
function headWeekday(): string {
|
||||
return headDate().toLocaleDateString(undefined, { weekday: 'long' });
|
||||
}
|
||||
@@ -914,7 +937,6 @@
|
||||
</div>
|
||||
<div class="gm-bottom">
|
||||
<span class="gm-reward">{g.rewardText}</span>
|
||||
<span class="gm-date">{formatDDMMYY(g.date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -1047,7 +1069,7 @@
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<Card cols={1} title="Claims" accent="#f59e0b">
|
||||
<Card cols={1} title="Claims" accent={claimableRewards().length > 0 ? '#f97316' : undefined} class={claimableRewards().length > 0 ? 'claims-card' : ''}>
|
||||
{#if claimableRewards().length === 0}
|
||||
<div class="claims-empty">
|
||||
{@html checkCircleIcon}<span>No outstanding claims</span>
|
||||
@@ -1106,7 +1128,7 @@
|
||||
<p class="section-label">Requested</p>
|
||||
{#each requested as r}
|
||||
<form
|
||||
class="flex justify-between items-center"
|
||||
class="flex flex-wrap justify-between items-center"
|
||||
method="POST"
|
||||
action="?/claim"
|
||||
use:enhance={() => {
|
||||
@@ -1118,7 +1140,7 @@
|
||||
<span class="claim-icon"
|
||||
>{@html r.rewardType === 'cash' ? coinsIcon : giftIcon}</span
|
||||
>
|
||||
<span class="claim-label">{r.label}</span>
|
||||
<span class="claim-label">{rewardLabelParts(r).name}</span>
|
||||
<span class="claim-value">{rewardLabel(r)}</span>
|
||||
</div>
|
||||
<Button type="submit" size="sm" variant="primary">Issue</Button>
|
||||
@@ -1133,7 +1155,7 @@
|
||||
<span class="claim-icon"
|
||||
>{@html r.rewardType === 'cash' ? coinsIcon : giftIcon}</span
|
||||
>
|
||||
<span class="claim-label">{r.label}</span>
|
||||
<span class="claim-label">{rewardLabelParts(r).name}</span>
|
||||
<span class="claim-value">
|
||||
{rewardLabel(r)}
|
||||
{#if paydayLocked(r)}
|
||||
@@ -1265,8 +1287,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="hero-date">
|
||||
<span class="hero-day">{headDay()}</span>
|
||||
<span class="hero-weekday">{headWeekday()}</span>
|
||||
<span class="hero-day">{headDayOrdinal()}</span>
|
||||
<span class="hero-month">{headMonth()}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2224,11 +2246,15 @@
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
.hero-days {
|
||||
font-size: 0.85rem;
|
||||
font-size: 1rem;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
/* Own full-width line, centered under the hero content */
|
||||
flex: 1 1 100%;
|
||||
text-align: center;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.hero-days b {
|
||||
font-size: 1.4rem;
|
||||
font-size: 1.5rem;
|
||||
margin-right: 0.15rem;
|
||||
}
|
||||
.hero-nav {
|
||||
@@ -2483,20 +2509,25 @@
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.goal-mini-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.goal-mini {
|
||||
background: linear-gradient(135deg, #d1d3fa, #ccfbf1);
|
||||
border: 1px solid #a7f3d0;
|
||||
border-radius: 12px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
/* Own width as a query container, so tight cards drop the member name */
|
||||
container-type: inline-size;
|
||||
}
|
||||
/* Card narrower than 250px: hide the member name so the row doesn't squish */
|
||||
@container (max-width: 249px) {
|
||||
.gm-member {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.gm-top {
|
||||
display: flex;
|
||||
@@ -2526,18 +2557,14 @@
|
||||
.gm-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gm-reward {
|
||||
font-weight: 800;
|
||||
color: #047857;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.gm-date {
|
||||
color: #9ca3af;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
/* Goal celebration notice (child view) */
|
||||
.celebrate-card.goal {
|
||||
@@ -2954,4 +2981,60 @@
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
/* Mobile: declared last so it wins over the base .goal-mini (.kanban)
|
||||
rules (same specificity, source order decides). */
|
||||
@media (max-width: 639px) {
|
||||
.goal-mini-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.kanban {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
/* Admin overview cards flow full width on phones — same convention as
|
||||
the chores kanban (no horizontal scroll). */
|
||||
.kanban-scroll {
|
||||
overflow-x: visible;
|
||||
}
|
||||
.kanban-inner {
|
||||
min-width: 0;
|
||||
}
|
||||
/* Keep the orange wash visible: restore body padding for this card
|
||||
only (the shared Card removes it on mobile). */
|
||||
:global(.card.claims-card .card-body) {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
/* Claim rows: icon/label/value stay on one line (label truncates
|
||||
with ellipsis) and the Issue button becomes a full-width action
|
||||
below — no squishing or overflow under 450px. */
|
||||
.member-claims form.flex {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 0.5rem;
|
||||
}
|
||||
.member-claims form.flex > div {
|
||||
flex: 1 1 100%;
|
||||
min-width: 0;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
/* :global — the button element is rendered by the shared Button
|
||||
component, so a scoped selector never matches it. */
|
||||
.member-claims form.flex :global(button) {
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
.member-claims .claim-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.member-claims .claim-item {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
/* Claims card — orange wash so pending requests are impossible to miss.
|
||||
:global + !important deliberately override the shared Card's white/
|
||||
mobile-transparent backgrounds. */
|
||||
:global(.card.claims-card) {
|
||||
background: linear-gradient(135deg, #ffedd5, #fdba74 70%, #fb923c) !important;
|
||||
border-color: #f97316;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -197,7 +197,7 @@
|
||||
const isShared = assignTarget === 'shared';
|
||||
const body: Record<string, unknown> = {
|
||||
memberId: isShared ? '' : assignModal.memberId,
|
||||
templateId: assignModal.t.id,
|
||||
templateId: assignModal.t?.id || '',
|
||||
customName: assignName,
|
||||
description: assignDescription,
|
||||
color: assignColor,
|
||||
@@ -242,6 +242,20 @@
|
||||
showTodoModal = true;
|
||||
}
|
||||
|
||||
function openCreateChore() {
|
||||
assignModal = { t: null as any, memberId: members[0]?.id || '' };
|
||||
assignStep = 1;
|
||||
assignTarget = 'member';
|
||||
assignName = '';
|
||||
assignDescription = '';
|
||||
assignColor = '';
|
||||
assignIcon = '';
|
||||
assignFreq = 'daily';
|
||||
assignType = 'points';
|
||||
assignValue = 10;
|
||||
assignSeasonIds = seasonFilter !== 'all' ? [seasonFilter] : [];
|
||||
}
|
||||
|
||||
// Sort todos by completeBy (soonest first), then by startDate
|
||||
function todoSort(a: AssignedChore, b: AssignedChore): number {
|
||||
const da = a.completeBy || '9999-99-99';
|
||||
@@ -381,6 +395,7 @@
|
||||
<div class="template-section">
|
||||
<div class="template-header">
|
||||
<h3>Templates — Drag to assign</h3>
|
||||
<button class="add-inline" onclick={openCreateChore}>+ Create a chore</button>
|
||||
</div>
|
||||
<div class="template-list">
|
||||
{#each templates as t}
|
||||
@@ -527,11 +542,11 @@
|
||||
class="accordion-wrapper accordion-chores"
|
||||
class:open={accordionState[m.id]?.chores ?? true}
|
||||
>
|
||||
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'chores')}>
|
||||
<span class="accordion-title">
|
||||
<span class="accordion-icon">{@html choresIcon}</span>
|
||||
<span class="accordion-label">Chores</span>
|
||||
</span>
|
||||
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'chores')}>
|
||||
<span class="accordion-title">
|
||||
<span class="accordion-icon">{@html choresIcon}</span>
|
||||
<span class="accordion-label">Chores</span>
|
||||
</span>
|
||||
<span class="accordion-chevron"
|
||||
>{@html (accordionState[m.id]?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
|
||||
>
|
||||
@@ -619,8 +634,7 @@
|
||||
<span class="accordion-label">Competitive Chores</span>
|
||||
</span>
|
||||
<span class="accordion-chevron"
|
||||
>{@html
|
||||
(accordionState.shared?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
|
||||
>{@html (accordionState.shared?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
|
||||
>
|
||||
</button>
|
||||
<span class="accordion-count" style="background:#6366f1"
|
||||
@@ -720,12 +734,15 @@
|
||||
<div
|
||||
class="overlay"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) { assignModal = null; assignStep = 1; }
|
||||
if (e.target === e.currentTarget) {
|
||||
assignModal = null;
|
||||
assignStep = 1;
|
||||
}
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
||||
<h3>Assign: {assignModal.t.name}</h3>
|
||||
<h3>{assignModal.t ? `Assign: ${assignModal.t.name}` : 'Create a chore'}</h3>
|
||||
|
||||
<!-- Step tabs -->
|
||||
<div class="step-tabs" role="tablist">
|
||||
@@ -743,14 +760,22 @@
|
||||
{#if assignStep === 1}
|
||||
<!-- Step 1: Who -->
|
||||
<label>Name <input bind:value={assignName} placeholder="Task name" /></label>
|
||||
<label>Description <input bind:value={assignDescription} placeholder="Optional description" /></label>
|
||||
<label
|
||||
>Description <input
|
||||
bind:value={assignDescription}
|
||||
placeholder="Optional description"
|
||||
/></label
|
||||
>
|
||||
<label>
|
||||
Assign to
|
||||
<select bind:value={assignTarget} onchange={() => {
|
||||
if (assignTarget === 'member' && !assignModal?.memberId && members.length) {
|
||||
assignModal = { ...assignModal!, memberId: members[0].id };
|
||||
}
|
||||
}}>
|
||||
<select
|
||||
bind:value={assignTarget}
|
||||
onchange={() => {
|
||||
if (assignTarget === 'member' && !assignModal?.memberId && members.length) {
|
||||
assignModal = { ...assignModal!, memberId: members[0].id };
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="member">A specific member</option>
|
||||
<option value="shared">Shared (all members)</option>
|
||||
</select>
|
||||
@@ -824,7 +849,13 @@
|
||||
{#if assignStep > 1}
|
||||
<button type="button" onclick={() => assignStep--}>Back</button>
|
||||
{:else}
|
||||
<button type="button" onclick={() => { assignModal = null; assignStep = 1; }}>Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
assignModal = null;
|
||||
assignStep = 1;
|
||||
}}>Cancel</button
|
||||
>
|
||||
{/if}
|
||||
{#if assignStep < 3}
|
||||
<button type="button" class="primary" onclick={() => assignStep++}>Next</button>
|
||||
@@ -1163,7 +1194,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Kanban (3 columns, scrolls via Card scrollX) ── */
|
||||
.kanban {
|
||||
display: flex;
|
||||
@@ -1849,4 +1879,23 @@
|
||||
opacity: 0.5;
|
||||
min-height: 200px;
|
||||
}
|
||||
/* Mobile: declared last so it wins over the base .kanban/.column rules
|
||||
(same specificity, source order decides). */
|
||||
@media (max-width: 639px) {
|
||||
.template-list {
|
||||
display: none;
|
||||
}
|
||||
.kanban {
|
||||
flex-direction: column;
|
||||
overflow-x: visible;
|
||||
}
|
||||
.column {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
min-height: auto;
|
||||
}
|
||||
.column-placeholder {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,14 +5,15 @@ export async function load(event) {
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const s = servicesFor(event);
|
||||
const [rewards, members, assigned, templates, completions] = await Promise.all([
|
||||
const [rewards, members, assigned, templates, completions, fam] = await Promise.all([
|
||||
s.crud.list('rewards', famId),
|
||||
s.crud.list('members', famId),
|
||||
s.crud.list('assigned-chores', famId),
|
||||
s.crud.list('chore-templates', famId),
|
||||
s.crud.list('completions', famId)
|
||||
s.crud.list('completions', famId),
|
||||
s.fam.get(famId)
|
||||
]);
|
||||
return { rewards, members, assigned, templates, completions };
|
||||
return { rewards, members, assigned, templates, completions, fam };
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
@@ -29,4 +30,4 @@ export const actions = {
|
||||
return { error: e instanceof Error ? e.message : 'Failed to claim reward' };
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { enhance } from '$app/forms';
|
||||
import { famStore } from '$lib/stores/fam.svelte';
|
||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||
import { formatDDMMYY } from '$lib/format';
|
||||
import { formatDDMMYY, formatShortDate } from '$lib/format';
|
||||
import { resolveTz, weekStart as tzWeekStart, addDaysStr } from '@shared/timezone';
|
||||
import type { Member, AssignedChore, ChoreTemplate, Completion, Reward } from '$lib/types';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -24,6 +25,28 @@
|
||||
famStore.initialized ? famStore.templates : ((data.templates || []) as ChoreTemplate[])
|
||||
);
|
||||
|
||||
// ── Week navigation (mirrors the member dashboard: payday-anchored weeks) ──
|
||||
// Anchor comes from data.fam at SSR; famStore takes over after init so a
|
||||
// payday/timezone change in settings flows through too.
|
||||
let fam = $derived(
|
||||
famStore.initialized ? (famStore.fam as any) : ((data as any).fam as any) || {}
|
||||
);
|
||||
let famPayday = $derived(fam?.payday != null ? Number(fam.payday) : 1);
|
||||
let famTz = $derived(resolveTz(fam?.timezone || 'auto'));
|
||||
let currentWeek = $derived.by(() => tzWeekStart(famPayday, famTz));
|
||||
let weekStart = $state(tzWeekStart(famPayday, famTz));
|
||||
let weekEnd = $derived(addDaysStr(weekStart, 6));
|
||||
let isCurrentWeek = $derived(weekStart === currentWeek);
|
||||
|
||||
function inWeek(dateStr: string | undefined): boolean {
|
||||
const d = (dateStr || '').slice(0, 10);
|
||||
return !!d && d >= weekStart && d <= weekEnd;
|
||||
}
|
||||
|
||||
function navigateWeek(amount: number) {
|
||||
weekStart = addDaysStr(weekStart, amount * 7);
|
||||
}
|
||||
|
||||
let activeTab = $state('rewards');
|
||||
|
||||
let memberMap = $derived.by(() => new Map(members.map((m: any) => [m.id, m])));
|
||||
@@ -43,10 +66,22 @@
|
||||
}
|
||||
|
||||
// ── Rewards tab ──
|
||||
// Rewards store the label as "Bonus name – prize" (built in bonuses.ts:
|
||||
// cash → "Reading Bonus 3 – £5.00", points → "Reading Bonus 3 – 50",
|
||||
// items → "Reading Bonus 3 – fluffy toy"). Split it so the description
|
||||
// column shows the bonus name and the amount column shows the prize;
|
||||
// labels without a separator keep the full text in both columns.
|
||||
function rewardLabelParts(r: any): { name: string; prize: string } {
|
||||
const label = r.label || '';
|
||||
const idx = label.indexOf(' – ');
|
||||
if (idx === -1) return { name: label, prize: label };
|
||||
return { name: label.slice(0, idx), prize: label.slice(idx + 3) };
|
||||
}
|
||||
|
||||
function rewardAmount(r: any): string {
|
||||
if (r.rewardType === 'cash') return `£${Number(r.value).toFixed(2)}`;
|
||||
if (r.rewardType === 'points') return `${r.value} pts`;
|
||||
return r.label;
|
||||
return rewardLabelParts(r).prize;
|
||||
}
|
||||
|
||||
function rewardStatus(r: any): string {
|
||||
@@ -71,8 +106,11 @@
|
||||
})
|
||||
);
|
||||
|
||||
let weekRewards = $derived(sortedRewards.filter((r: any) => inWeek(r.date)));
|
||||
|
||||
// Footer total matches the visible week, not all-time history.
|
||||
let totalOutstanding = $derived(
|
||||
rewards
|
||||
weekRewards
|
||||
.filter((r: any) => r.status !== 'claimed')
|
||||
.filter((r: any) => r.rewardType === 'cash')
|
||||
.reduce((sum: number, r: any) => sum + Number(r.value), 0)
|
||||
@@ -93,6 +131,9 @@
|
||||
[...todoCompletions].sort((a: any, b: any) => (b.date || '').localeCompare(a.date || ''))
|
||||
);
|
||||
|
||||
let weekChoreCompletions = $derived(sortedChoreCompletions.filter((c: any) => inWeek(c.date)));
|
||||
let weekTodoCompletions = $derived(sortedTodoCompletions.filter((c: any) => inWeek(c.date)));
|
||||
|
||||
let toast = $state('');
|
||||
|
||||
async function fire() {
|
||||
@@ -125,6 +166,11 @@
|
||||
active: activeTab,
|
||||
onchange: (v: string) => (activeTab = v)
|
||||
}}
|
||||
weeknav={{
|
||||
current: isCurrentWeek ? 'This week' : formatShortDate(weekStart),
|
||||
onPrev: () => navigateWeek(-1),
|
||||
onNext: () => navigateWeek(1)
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if toast}
|
||||
@@ -134,10 +180,11 @@
|
||||
<CardGrid stackTablet>
|
||||
{#if activeTab === 'rewards'}
|
||||
<Card cols={3}>
|
||||
{#if rewards.length === 0}
|
||||
{#if weekRewards.length === 0}
|
||||
<p class="empty">
|
||||
No rewards yet. Points earned will automatically create bonus rewards when thresholds are
|
||||
met.
|
||||
{isCurrentWeek
|
||||
? 'No rewards yet. Points earned will automatically create bonus rewards when thresholds are met.'
|
||||
: `No rewards for the week of ${formatShortDate(weekStart)}.`}
|
||||
</p>
|
||||
{:else}
|
||||
<table>
|
||||
@@ -152,15 +199,19 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sortedRewards as r}
|
||||
{#each weekRewards as r}
|
||||
{@const outstanding = isOutstanding(r)}
|
||||
<tr class:claimed={!outstanding && !isRequested(r)} class:requested={isRequested(r)}>
|
||||
<tr
|
||||
class:claimed={!outstanding && !isRequested(r)}
|
||||
class:requested={isRequested(r)}
|
||||
style="--mc:{memberColor(r.memberId)}"
|
||||
>
|
||||
<td class="date">{r.date ? formatDDMMYY(r.date) : '—'}</td>
|
||||
<td>
|
||||
<span class="dot" style="background:{memberColor(r.memberId)}"></span>
|
||||
{memberName(r.memberId)}
|
||||
</td>
|
||||
<td>{r.label}</td>
|
||||
<td>{rewardLabelParts(r).name}</td>
|
||||
<td class="num">{rewardAmount(r)}</td>
|
||||
<td>
|
||||
<span
|
||||
@@ -208,8 +259,12 @@
|
||||
</Card>
|
||||
{:else if activeTab === 'chores'}
|
||||
<Card cols={3} scrollX title="Chore completions">
|
||||
{#if sortedChoreCompletions.length === 0}
|
||||
<p class="empty">No chore completions yet.</p>
|
||||
{#if weekChoreCompletions.length === 0}
|
||||
<p class="empty">
|
||||
{isCurrentWeek
|
||||
? 'No chore completions yet.'
|
||||
: `No chore completions for the week of ${formatShortDate(weekStart)}.`}
|
||||
</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
@@ -221,9 +276,9 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sortedChoreCompletions as c}
|
||||
{#each weekChoreCompletions as c}
|
||||
{@const a = assignedMap.get(c.assignedChoreId)}
|
||||
<tr>
|
||||
<tr style="--mc:{memberColor(c.memberId)}">
|
||||
<td class="date">{formatDDMMYY(c.date)}</td>
|
||||
<td>
|
||||
<span class="dot" style="background:{memberColor(c.memberId)}"></span>
|
||||
@@ -242,8 +297,12 @@
|
||||
</Card>
|
||||
{:else}
|
||||
<Card cols={3} scrollX title="Todo completions">
|
||||
{#if sortedTodoCompletions.length === 0}
|
||||
<p class="empty">No todos completed yet.</p>
|
||||
{#if weekTodoCompletions.length === 0}
|
||||
<p class="empty">
|
||||
{isCurrentWeek
|
||||
? 'No todos completed yet.'
|
||||
: `No todos completed for the week of ${formatShortDate(weekStart)}.`}
|
||||
</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
@@ -255,9 +314,9 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sortedTodoCompletions as c}
|
||||
{#each weekTodoCompletions as c}
|
||||
{@const a = assignedMap.get(c.assignedChoreId)}
|
||||
<tr>
|
||||
<tr style="--mc:{memberColor(c.memberId)}">
|
||||
<td class="date">{formatDDMMYY(c.date)}</td>
|
||||
<td>
|
||||
<span class="dot" style="background:{memberColor(c.memberId)}"></span>
|
||||
@@ -356,4 +415,20 @@
|
||||
border-top: 2px solid #d1d5db;
|
||||
padding-top: 0.6rem;
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
/* Hide Member (2nd) and Status (5th, rewards only) columns */
|
||||
th:nth-child(2),
|
||||
td:nth-child(2) {
|
||||
display: none;
|
||||
}
|
||||
th:nth-child(5),
|
||||
td:nth-child(5) {
|
||||
display: none;
|
||||
}
|
||||
/* Member colour as the entry background instead of the member column */
|
||||
tbody tr,
|
||||
tbody tr.requested {
|
||||
background: color-mix(in srgb, var(--mc, #6366f1) 10%, transparent);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { servicesFor } from '$lib/server/servicesFor';
|
||||
import { getPlatformFlags } from '$lib/server/platform';
|
||||
|
||||
async function isDemoMode(): Promise<boolean> {
|
||||
return (await getPlatformFlags()).demo;
|
||||
}
|
||||
|
||||
export async function load(event) {
|
||||
if (await isDemoMode()) {
|
||||
return { verified: false, role: '', token: '', memberId: '', famId: '', memberName: '', memberColor: '', pattern: '', themeSize: '', themeOpacity: '', email: '', demoMode: true };
|
||||
}
|
||||
|
||||
const session = event.locals.user;
|
||||
const famSlug = event.params.fam;
|
||||
const username = event.params.username;
|
||||
@@ -37,6 +46,9 @@ export async function load(event) {
|
||||
famId,
|
||||
memberName: me.name,
|
||||
memberColor: me.color,
|
||||
pattern: me.pattern || '',
|
||||
themeSize: me.themeSize || '',
|
||||
themeOpacity: me.themeOpacity || '',
|
||||
email: me.email || '',
|
||||
session: true
|
||||
};
|
||||
@@ -63,6 +75,9 @@ export async function load(event) {
|
||||
famId,
|
||||
memberName: session.name || '',
|
||||
memberColor: session.color || '',
|
||||
pattern: session.pattern || '',
|
||||
themeSize: session.themeSize || '',
|
||||
themeOpacity: session.themeOpacity || '',
|
||||
email: '',
|
||||
session: true
|
||||
};
|
||||
@@ -77,6 +92,9 @@ export const actions = {
|
||||
const name = fd.get('name') as string;
|
||||
const color = fd.get('color') as string;
|
||||
const email = fd.get('email') as string;
|
||||
const pattern = (fd.get('pattern') as string) || '';
|
||||
const themeSize = (fd.get('themeSize') as string) || '';
|
||||
const themeOpacity = (fd.get('themeOpacity') as string) || '';
|
||||
|
||||
const s = servicesFor(event);
|
||||
const famId = session.famId;
|
||||
@@ -86,9 +104,20 @@ export const actions = {
|
||||
const data: Record<string, string> = {};
|
||||
if (name) data.name = name;
|
||||
if (color) data.color = color;
|
||||
data.pattern = pattern;
|
||||
data.themeSize = themeSize;
|
||||
data.themeOpacity = themeOpacity;
|
||||
data.email = email || '';
|
||||
const me = await s.fam.updateProfile(famId, data);
|
||||
return { success: true, name: me.name, color: me.color, email: me.email };
|
||||
return {
|
||||
success: true,
|
||||
name: me.name,
|
||||
color: me.color,
|
||||
pattern: me.pattern,
|
||||
themeSize: me.themeSize,
|
||||
themeOpacity: me.themeOpacity,
|
||||
email: me.email
|
||||
};
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Update failed' };
|
||||
}
|
||||
@@ -99,8 +128,19 @@ export const actions = {
|
||||
const data: Record<string, string> = {};
|
||||
if (name) data.name = name;
|
||||
if (color) data.color = color;
|
||||
data.pattern = pattern;
|
||||
data.themeSize = themeSize;
|
||||
data.themeOpacity = themeOpacity;
|
||||
const me = await s.fam.updateProfile(famId, data);
|
||||
return { success: true, name: me.name, color: me.color, email: '' };
|
||||
return {
|
||||
success: true,
|
||||
name: me.name,
|
||||
color: me.color,
|
||||
pattern: me.pattern,
|
||||
themeSize: me.themeSize,
|
||||
themeOpacity: me.themeOpacity,
|
||||
email: ''
|
||||
};
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Update failed' };
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { enhance, applyAction } from '$app/forms';
|
||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { ViewHeader, CardGrid, Card, Button, PatternPicker } from '$lib/components';
|
||||
import { themeDraft } from '$lib/stores/theme.svelte';
|
||||
import { THEME_SIZE_DEFAULT, THEME_OPACITY_DEFAULT } from '$lib/theme';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
let memberName = $state(data.memberName || '');
|
||||
let memberColor = $state(data.memberColor || '#6366f1');
|
||||
let memberEmail = $state(data.email || '');
|
||||
let memberPattern = $state(data.pattern || '');
|
||||
let memberSize = $state(Number(data.themeSize) > 0 ? Number(data.themeSize) : THEME_SIZE_DEFAULT);
|
||||
let memberOpacity = $state(
|
||||
Number(data.themeOpacity) > 0 ? Number(data.themeOpacity) : THEME_OPACITY_DEFAULT
|
||||
);
|
||||
|
||||
const colors = [
|
||||
'#6366f1',
|
||||
@@ -32,21 +40,33 @@
|
||||
</CardGrid>
|
||||
{:else}
|
||||
<CardGrid>
|
||||
<Card cols={2} title="Appearance">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance={() => {
|
||||
return async ({ result }) => {
|
||||
// Apply the action result (so the success/error message
|
||||
// shows) WITHOUT resetting the form — the default enhance
|
||||
// clears name/email inputs on a successful submit.
|
||||
if (result.type === 'success' || result.type === 'failure') {
|
||||
await applyAction(result);
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<!-- One form for appearance + theme; display:contents keeps the
|
||||
cards as grid items. -->
|
||||
<form
|
||||
style="display:contents"
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance={() => {
|
||||
return async ({ result }) => {
|
||||
// Apply the action result (so the success/error message
|
||||
// shows) WITHOUT resetting the form — the default enhance
|
||||
// clears name/email inputs on a successful submit.
|
||||
if (result.type === 'success' || result.type === 'failure') {
|
||||
await applyAction(result);
|
||||
}
|
||||
// Refresh session.pattern so the app background updates, and drop
|
||||
// the live draft — the saved value is authoritative from here on.
|
||||
if (result.type === 'success') {
|
||||
themeDraft.pattern = null;
|
||||
themeDraft.color = null;
|
||||
themeDraft.size = null;
|
||||
themeDraft.opacity = null;
|
||||
await invalidateAll();
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Card cols={2} title="Appearance">
|
||||
<div style="margin-bottom:1rem">
|
||||
<label style="display:block;font-size:0.85rem;color:#374151;margin-bottom:0.25rem">
|
||||
Display Name
|
||||
@@ -71,7 +91,10 @@
|
||||
style="width:2rem;height:2rem;border-radius:50%;border:{c === memberColor
|
||||
? '3px solid #111827'
|
||||
: '2px solid transparent'};background:{c};cursor:pointer"
|
||||
onclick={() => (memberColor = c)}
|
||||
onclick={() => {
|
||||
memberColor = c;
|
||||
themeDraft.color = c;
|
||||
}}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -102,8 +125,40 @@
|
||||
<div class="form-actions">
|
||||
<Button type="submit" variant="primary">Update preferences</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</Card>
|
||||
|
||||
<Card cols={1} title="Theme">
|
||||
<p class="theme-hint">
|
||||
Pick a background pattern — it's coloured with your chosen colour. None keeps the plain
|
||||
background.
|
||||
</p>
|
||||
<PatternPicker
|
||||
value={themeDraft.pattern ?? memberPattern}
|
||||
color={memberColor}
|
||||
size={themeDraft.size ?? memberSize}
|
||||
opacity={themeDraft.opacity ?? memberOpacity}
|
||||
onchange={(v) => {
|
||||
memberPattern = v;
|
||||
// Live preview on the app background before saving.
|
||||
themeDraft.pattern = v;
|
||||
themeDraft.color = memberColor;
|
||||
}}
|
||||
onsize={(v) => {
|
||||
memberSize = v;
|
||||
// Live scale preview before saving.
|
||||
themeDraft.size = v;
|
||||
}}
|
||||
onopacity={(v) => {
|
||||
memberOpacity = v;
|
||||
// Live fade preview before saving.
|
||||
themeDraft.opacity = v;
|
||||
}}
|
||||
/>
|
||||
<input type="hidden" name="pattern" value={themeDraft.pattern ?? memberPattern} />
|
||||
<input type="hidden" name="themeSize" value={themeDraft.size ?? memberSize} />
|
||||
<input type="hidden" name="themeOpacity" value={themeDraft.opacity ?? memberOpacity} />
|
||||
</Card>
|
||||
</form>
|
||||
|
||||
<Card cols={1} title="Account">
|
||||
<p style="font-size:0.85rem;color:#6b7280">
|
||||
@@ -122,4 +177,9 @@
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
.theme-hint {
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { issueAccess, createChild, inviteParent } from '$lib/server/member-otp';
|
||||
import { sendParentInviteEmail } from '$lib/server/email';
|
||||
import { slugify, handle, famUsername } from '@shared/slugify';
|
||||
import { applyAccessCode } from '$lib/server/access';
|
||||
import { getPlatformFlags } from '$lib/server/platform';
|
||||
import {
|
||||
createBillingPortalSession,
|
||||
cancelSubscriptionAtPeriodEnd,
|
||||
@@ -18,7 +19,15 @@ function famIdOf(event: RequestEvent): string {
|
||||
return event.locals.user.famId;
|
||||
}
|
||||
|
||||
// In demo mode, settings is disabled for all users.
|
||||
async function isDemoMode(): Promise<boolean> {
|
||||
return (await getPlatformFlags()).demo;
|
||||
}
|
||||
|
||||
export async function load(event: RequestEvent) {
|
||||
if (await isDemoMode()) {
|
||||
return { members: [], fam: null, seasons: [], accessCode: null, subStatus: null, demoMode: true };
|
||||
}
|
||||
const famId = famIdOf(event);
|
||||
const pb = pbUser(event);
|
||||
const [members, fam, seasons] = await Promise.all([
|
||||
|
||||
@@ -1161,4 +1161,7 @@
|
||||
.resume-hint {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.code-display { font-size: 1.5em; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
|
||||
<main class="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center px-6">
|
||||
<section class="w-full rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm">
|
||||
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 text-2xl">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 text-2xl"
|
||||
>
|
||||
<span class="home-badge">{@html homeIcon}</span>
|
||||
</div>
|
||||
<h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1>
|
||||
@@ -51,7 +53,7 @@
|
||||
maxlength="6"
|
||||
placeholder="6-digit code"
|
||||
autocomplete="one-time-code"
|
||||
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-2xl tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
|
||||
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-lg tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
|
||||
/>
|
||||
{#if form?.error}
|
||||
<p class="text-sm font-medium text-rose-600">{form.error}</p>
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
maxlength="6"
|
||||
placeholder="6-digit code"
|
||||
autocomplete="one-time-code"
|
||||
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-2xl tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
|
||||
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-lg tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
|
||||
/>
|
||||
{#if isParent}
|
||||
<input
|
||||
|
||||
@@ -89,12 +89,15 @@ export const load: PageServerLoad = async (event) => {
|
||||
pbAdmin.getList('bonus_templates', 'global = true')
|
||||
]);
|
||||
|
||||
const platformFlags = await getPlatformFlags();
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
fams: famsWithStats,
|
||||
codes: codeList,
|
||||
choreTemplates: choreTemplates as any[],
|
||||
bonusTemplates: bonusTemplates as any[],
|
||||
platformFlags,
|
||||
totalFams: fams.length,
|
||||
totalMembers: famsWithStats.reduce((s, f) => s + f.memberCount, 0),
|
||||
totalRewards: famsWithStats.reduce((s, f) => s + f.totalRewards, 0),
|
||||
@@ -121,6 +124,7 @@ export const load: PageServerLoad = async (event) => {
|
||||
codeCount: 0,
|
||||
choreTemplates: [],
|
||||
bonusTemplates: [],
|
||||
platformFlags: {},
|
||||
loadError: e instanceof Error ? e.message : 'Failed to load platform data'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -188,6 +188,20 @@
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="flag-row">
|
||||
<span class="flag-name">demo</span>
|
||||
<span class="flag-desc">Showboaters demo family with sample data for prospectus viewers</span>
|
||||
<form method="POST" action="?/togglePlatformFlag" use:enhance>
|
||||
<input type="hidden" name="flag" value="demo" />
|
||||
<button
|
||||
type="submit"
|
||||
class="toggle-btn"
|
||||
class:active={page.data.platformFlags?.demo}
|
||||
>
|
||||
{page.data.platformFlags?.demo ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="flag-row">
|
||||
<span class="flag-name">rewardsTargets</span>
|
||||
<span class="flag-desc">Multi-target reward rules (Competition / Collaboration)</span>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { createPbClient } from '$lib/server/pocketbase';
|
||||
import { createPbClient, pbAdmin } from '$lib/server/pocketbase';
|
||||
import { createServices } from '$lib/server/services';
|
||||
import { sendClaimRequestEmail } from '$lib/server/email';
|
||||
import { handleOf } from '@shared/slugify';
|
||||
|
||||
export async function POST(event: RequestEvent) {
|
||||
const u = event.locals.user;
|
||||
@@ -10,8 +12,36 @@ export async function POST(event: RequestEvent) {
|
||||
const id = event.params.id!;
|
||||
try {
|
||||
const s = createServices(pb, { id: u.id, role: u.role });
|
||||
return json(await s.rewards.claim(u.famId, id));
|
||||
const record = await s.rewards.claim(u.famId, id);
|
||||
|
||||
// Tell the parent(s) a claim was requested — best-effort, so a mail
|
||||
// outage can never fail the claim itself.
|
||||
try {
|
||||
const [parents, fam] = await Promise.all([
|
||||
pbAdmin.getList('users', `famId = '${u.famId}' && role = 'parent'`),
|
||||
pbAdmin.getOne('fams', u.famId)
|
||||
]);
|
||||
const label = record?.label || 'a reward';
|
||||
const idx = label.indexOf(' – ');
|
||||
const rewardName = idx === -1 ? label : label.slice(0, idx);
|
||||
const rewardValue = idx === -1 ? '' : label.slice(idx + 3);
|
||||
for (const parent of parents) {
|
||||
if (!parent.email) continue;
|
||||
await sendClaimRequestEmail({
|
||||
to: parent.email,
|
||||
parentName: parent.name || 'there',
|
||||
childName: u.name || 'Someone',
|
||||
rewardName,
|
||||
rewardValue,
|
||||
dashboardUrl: `${event.url.origin}/${fam?.slug || u.famId}/${handleOf(parent.username)}`
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Best-effort only — the claim already succeeded.
|
||||
}
|
||||
|
||||
return json(record);
|
||||
} catch (e) {
|
||||
return json({ error: e instanceof Error ? e.message : 'claim failed' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -13,5 +13,5 @@
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
"version": "1.10.2"
|
||||
"version": "1.10.4"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user