@ssejal8/recipe-ui-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +10 -0
- package/.npmr +1 -0
- package/README.md +354 -0
- package/apps/recipe-web/package.json +31 -0
- package/apps/recipe-web/src/app.css +418 -0
- package/apps/recipe-web/src/app.d.ts +32 -0
- package/apps/recipe-web/src/app.html +25 -0
- package/apps/recipe-web/src/lib/api/mealdb.ts +240 -0
- package/apps/recipe-web/src/lib/components/AppHeader.svelte +290 -0
- package/apps/recipe-web/src/lib/components/ConfirmDialog.svelte +46 -0
- package/apps/recipe-web/src/lib/components/Modal.svelte +33 -0
- package/apps/recipe-web/src/lib/components/RecipeCard.svelte +100 -0
- package/apps/recipe-web/src/lib/components/RecipeForm.svelte +560 -0
- package/apps/recipe-web/src/lib/components/RecipeGrid.svelte +77 -0
- package/apps/recipe-web/src/lib/components/RecipePickerDialog.svelte +185 -0
- package/apps/recipe-web/src/lib/components/Toaster.svelte +111 -0
- package/apps/recipe-web/src/lib/domain/recipe.ts +130 -0
- package/apps/recipe-web/src/lib/domain/validation.ts +189 -0
- package/apps/recipe-web/src/lib/stencil/WebComponent.svelte +145 -0
- package/apps/recipe-web/src/lib/stencil/register.ts +28 -0
- package/apps/recipe-web/src/lib/stores/favorites.svelte.ts +81 -0
- package/apps/recipe-web/src/lib/stores/myRecipes.svelte.ts +98 -0
- package/apps/recipe-web/src/lib/stores/persisted.svelte.ts +73 -0
- package/apps/recipe-web/src/lib/stores/planner.svelte.ts +275 -0
- package/apps/recipe-web/src/lib/stores/theme.svelte.ts +67 -0
- package/apps/recipe-web/src/lib/stores/toast.svelte.ts +66 -0
- package/apps/recipe-web/src/routes/+error.svelte +36 -0
- package/apps/recipe-web/src/routes/+layout.svelte +62 -0
- package/apps/recipe-web/src/routes/+page.svelte +352 -0
- package/apps/recipe-web/src/routes/+page.ts +107 -0
- package/apps/recipe-web/src/routes/favorites/+page.svelte +170 -0
- package/apps/recipe-web/src/routes/my-recipes/+layout.ts +55 -0
- package/apps/recipe-web/src/routes/my-recipes/+page.svelte +180 -0
- package/apps/recipe-web/src/routes/my-recipes/[id]/edit/+page.svelte +104 -0
- package/apps/recipe-web/src/routes/my-recipes/new/+page.svelte +67 -0
- package/apps/recipe-web/src/routes/planner/+page.svelte +422 -0
- package/apps/recipe-web/src/routes/recipes/[id]/+page.svelte +538 -0
- package/apps/recipe-web/src/routes/recipes/[id]/+page.ts +24 -0
- package/apps/recipe-web/static/favicon.svg +8 -0
- package/apps/recipe-web/static/robots.txt +2 -0
- package/apps/recipe-web/svelte.config.js +35 -0
- package/apps/recipe-web/tsconfig.json +14 -0
- package/apps/recipe-web/vite.config.ts +23 -0
- package/package.json +29 -0
- package/packages/recipe-kit-stencil/LICENSE +21 -0
- package/packages/recipe-kit-stencil/README.md +185 -0
- package/packages/recipe-kit-stencil/package-lock.json +4772 -0
- package/packages/recipe-kit-stencil/package.json +91 -0
- package/packages/recipe-kit-stencil/src/components/rk-badge/readme.md +38 -0
- package/packages/recipe-kit-stencil/src/components/rk-badge/rk-badge.css +102 -0
- package/packages/recipe-kit-stencil/src/components/rk-badge/rk-badge.tsx +31 -0
- package/packages/recipe-kit-stencil/src/components/rk-chip/readme.md +52 -0
- package/packages/recipe-kit-stencil/src/components/rk-chip/rk-chip.css +155 -0
- package/packages/recipe-kit-stencil/src/components/rk-chip/rk-chip.tsx +105 -0
- package/packages/recipe-kit-stencil/src/components/rk-empty-state/readme.md +44 -0
- package/packages/recipe-kit-stencil/src/components/rk-empty-state/rk-empty-state.css +64 -0
- package/packages/recipe-kit-stencil/src/components/rk-empty-state/rk-empty-state.tsx +50 -0
- package/packages/recipe-kit-stencil/src/components/rk-meal-slot/readme.md +58 -0
- package/packages/recipe-kit-stencil/src/components/rk-meal-slot/rk-meal-slot.css +225 -0
- package/packages/recipe-kit-stencil/src/components/rk-meal-slot/rk-meal-slot.tsx +182 -0
- package/packages/recipe-kit-stencil/src/components/rk-modal/readme.md +57 -0
- package/packages/recipe-kit-stencil/src/components/rk-modal/rk-modal.css +163 -0
- package/packages/recipe-kit-stencil/src/components/rk-modal/rk-modal.tsx +176 -0
- package/packages/recipe-kit-stencil/src/components/rk-rating/readme.md +42 -0
- package/packages/recipe-kit-stencil/src/components/rk-rating/rk-rating.css +71 -0
- package/packages/recipe-kit-stencil/src/components/rk-rating/rk-rating.tsx +114 -0
- package/packages/recipe-kit-stencil/src/components/rk-recipe-card/readme.md +66 -0
- package/packages/recipe-kit-stencil/src/components/rk-recipe-card/rk-recipe-card.css +280 -0
- package/packages/recipe-kit-stencil/src/components/rk-recipe-card/rk-recipe-card.tsx +179 -0
- package/packages/recipe-kit-stencil/src/components/rk-recipe-card/test/rk-recipe-card.spec.tsx +70 -0
- package/packages/recipe-kit-stencil/src/components/rk-search-bar/readme.md +67 -0
- package/packages/recipe-kit-stencil/src/components/rk-search-bar/rk-search-bar.css +141 -0
- package/packages/recipe-kit-stencil/src/components/rk-search-bar/rk-search-bar.tsx +152 -0
- package/packages/recipe-kit-stencil/src/components/rk-search-bar/test/rk-search-bar.spec.tsx +93 -0
- package/packages/recipe-kit-stencil/src/components/rk-segmented-control/readme.md +41 -0
- package/packages/recipe-kit-stencil/src/components/rk-segmented-control/rk-segmented-control.css +91 -0
- package/packages/recipe-kit-stencil/src/components/rk-segmented-control/rk-segmented-control.tsx +115 -0
- package/packages/recipe-kit-stencil/src/components/rk-tag-input/readme.md +47 -0
- package/packages/recipe-kit-stencil/src/components/rk-tag-input/rk-tag-input.css +146 -0
- package/packages/recipe-kit-stencil/src/components/rk-tag-input/rk-tag-input.tsx +181 -0
- package/packages/recipe-kit-stencil/src/components/rk-tag-input/test/rk-tag-input.spec.tsx +76 -0
- package/packages/recipe-kit-stencil/src/components.d.ts +1192 -0
- package/packages/recipe-kit-stencil/src/global/rk-global.css +104 -0
- package/packages/recipe-kit-stencil/src/index.html +259 -0
- package/packages/recipe-kit-stencil/src/index.ts +6 -0
- package/packages/recipe-kit-stencil/stencil.config.ts +41 -0
- package/packages/recipe-kit-stencil/tsconfig.json +24 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Generic bridge between Svelte 5 and the Stencil custom elements.
|
|
3
|
+
|
|
4
|
+
Svelte can render a custom element directly, but two details make a bridge
|
|
5
|
+
worthwhile for a whole application:
|
|
6
|
+
|
|
7
|
+
1. **Properties, not attributes.** Attributes can only carry strings, so an
|
|
8
|
+
array prop such as `rk-segmented-control.options` has to be assigned as a
|
|
9
|
+
DOM property. Assigning it *before* the element upgrades would shadow the
|
|
10
|
+
generated accessor, so writes are deferred until `whenDefined` resolves.
|
|
11
|
+
|
|
12
|
+
2. **Case-sensitive event names.** The library emits `rkFavoriteToggle`, not
|
|
13
|
+
`rkfavoritetoggle`. `addEventListener` preserves case exactly, which keeps
|
|
14
|
+
the contract identical to the one documented by the library.
|
|
15
|
+
|
|
16
|
+
3. **Server-rendered output.** Property assignment cannot happen during SSR,
|
|
17
|
+
which would leave every card an empty shell until hydration. So each prop
|
|
18
|
+
is *also* written as a kebab-case attribute. Stencil observes those
|
|
19
|
+
attributes, so an element upgrades with the right content immediately —
|
|
20
|
+
before hydration, and even with JavaScript disabled.
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
|
|
24
|
+
<WebComponent
|
|
25
|
+
tag="rk-recipe-card"
|
|
26
|
+
props={{ recipeId: recipe.id, name: recipe.name, favorite }}
|
|
27
|
+
events={{ rkFavoriteToggle: onToggle }}
|
|
28
|
+
>
|
|
29
|
+
<rk-badge slot="actions">30 min</rk-badge>
|
|
30
|
+
</WebComponent>
|
|
31
|
+
-->
|
|
32
|
+
<script lang="ts">
|
|
33
|
+
import { whenTagDefined } from './register';
|
|
34
|
+
|
|
35
|
+
interface Props {
|
|
36
|
+
/** Custom element tag name, e.g. `rk-recipe-card`. */
|
|
37
|
+
tag: string;
|
|
38
|
+
/** DOM properties to assign. Keys are camelCase, matching the library. */
|
|
39
|
+
props?: Record<string, unknown>;
|
|
40
|
+
/** Event listeners, keyed by the exact event name the library emits. */
|
|
41
|
+
events?: Record<string, (event: CustomEvent<never>) => void>;
|
|
42
|
+
/** Class applied to the host element, for layout in the parent. */
|
|
43
|
+
class?: string;
|
|
44
|
+
/** Inline styles applied to the host element. */
|
|
45
|
+
style?: string;
|
|
46
|
+
/** Slot content projected into the element's light DOM. */
|
|
47
|
+
children?: import('svelte').Snippet;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let { tag, props = {}, events = {}, class: className = '', style = '', children }: Props = $props();
|
|
51
|
+
|
|
52
|
+
let element = $state<HTMLElement | null>(null);
|
|
53
|
+
let upgraded = $state(false);
|
|
54
|
+
|
|
55
|
+
// Track which keys were last written so a removed prop can be cleared.
|
|
56
|
+
let applied = new Set<string>();
|
|
57
|
+
|
|
58
|
+
const toKebab = (key: string) => key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Serialises props into attributes for the initial (server-rendered) markup.
|
|
62
|
+
*
|
|
63
|
+
* Booleans are written as `"true"` / `"false"` rather than being omitted, so a
|
|
64
|
+
* prop whose class default is `true` can still be turned off. Arrays and plain
|
|
65
|
+
* objects are JSON-encoded — the library's complex-prop components document
|
|
66
|
+
* that fallback, and the property assignment below supersedes it on the client.
|
|
67
|
+
*/
|
|
68
|
+
const attributes = $derived.by(() => {
|
|
69
|
+
const out: Record<string, string> = {};
|
|
70
|
+
|
|
71
|
+
for (const [key, value] of Object.entries(props)) {
|
|
72
|
+
if (value === undefined || value === null || typeof value === 'function') continue;
|
|
73
|
+
|
|
74
|
+
if (typeof value === 'string') out[toKebab(key)] = value;
|
|
75
|
+
else if (typeof value === 'number') out[toKebab(key)] = Number.isFinite(value) ? String(value) : '';
|
|
76
|
+
else if (typeof value === 'boolean') out[toKebab(key)] = String(value);
|
|
77
|
+
else if (typeof value === 'object') {
|
|
78
|
+
try {
|
|
79
|
+
out[toKebab(key)] = JSON.stringify(value);
|
|
80
|
+
} catch {
|
|
81
|
+
// Circular or otherwise unserialisable: the property write handles it.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return out;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
$effect(() => {
|
|
90
|
+
// Re-run when the tag changes, so a reused instance registers the new tag.
|
|
91
|
+
const currentTag = tag;
|
|
92
|
+
let cancelled = false;
|
|
93
|
+
upgraded = false;
|
|
94
|
+
|
|
95
|
+
whenTagDefined(currentTag).then(() => {
|
|
96
|
+
if (!cancelled) upgraded = true;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return () => {
|
|
100
|
+
cancelled = true;
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
$effect(() => {
|
|
105
|
+
if (!element || !upgraded) return;
|
|
106
|
+
|
|
107
|
+
const target = element as unknown as Record<string, unknown>;
|
|
108
|
+
const nextKeys = new Set(Object.keys(props));
|
|
109
|
+
|
|
110
|
+
for (const key of Object.keys(props)) {
|
|
111
|
+
const value = props[key];
|
|
112
|
+
// Skip no-op writes: Stencil re-renders on every property assignment.
|
|
113
|
+
if (target[key] === value) continue;
|
|
114
|
+
target[key] = value;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Reset properties that are no longer being passed, rather than leaving the
|
|
118
|
+
// element holding a stale value from a previous render.
|
|
119
|
+
for (const key of applied) {
|
|
120
|
+
if (!nextKeys.has(key)) target[key] = undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
applied = nextKeys;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
$effect(() => {
|
|
127
|
+
if (!element) return;
|
|
128
|
+
|
|
129
|
+
const target = element;
|
|
130
|
+
const entries = Object.entries(events);
|
|
131
|
+
for (const [name, handler] of entries) {
|
|
132
|
+
target.addEventListener(name, handler as EventListener);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return () => {
|
|
136
|
+
for (const [name, handler] of entries) {
|
|
137
|
+
target.removeEventListener(name, handler as EventListener);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
</script>
|
|
142
|
+
|
|
143
|
+
<svelte:element this={tag} bind:this={element} {...attributes} class={className} {style}>
|
|
144
|
+
{@render children?.()}
|
|
145
|
+
</svelte:element>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registers the published `recipe-kit-stencil` custom elements.
|
|
3
|
+
*
|
|
4
|
+
* Custom elements are a browser API, so this must only ever run on the client.
|
|
5
|
+
* The promise is cached: every component that needs the elements can await the
|
|
6
|
+
* same registration without triggering a second import.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
let registration: Promise<void> | null = null;
|
|
10
|
+
|
|
11
|
+
export function registerRecipeKit(): Promise<void> {
|
|
12
|
+
if (typeof window === 'undefined') return Promise.resolve();
|
|
13
|
+
|
|
14
|
+
registration ??= (async () => {
|
|
15
|
+
// Dynamic import keeps the loader out of the server bundle entirely.
|
|
16
|
+
const { defineCustomElements } = await import('recipe-kit-stencil/loader');
|
|
17
|
+
await defineCustomElements(window);
|
|
18
|
+
})();
|
|
19
|
+
|
|
20
|
+
return registration;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Resolves once a specific tag has been upgraded and is safe to write props to. */
|
|
24
|
+
export async function whenTagDefined(tag: string): Promise<void> {
|
|
25
|
+
if (typeof window === 'undefined') return;
|
|
26
|
+
await registerRecipeKit();
|
|
27
|
+
await customElements.whenDefined(tag);
|
|
28
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { RecipeSummary } from '$domain/recipe';
|
|
2
|
+
import { Persisted, STORAGE_KEYS } from './persisted.svelte';
|
|
3
|
+
|
|
4
|
+
interface FavoriteEntry extends RecipeSummary {
|
|
5
|
+
favoritedAt: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Favorites store.
|
|
10
|
+
*
|
|
11
|
+
* A full `RecipeSummary` snapshot is saved with each favorite, not just the id,
|
|
12
|
+
* so the favorites page renders without a network round-trip per recipe.
|
|
13
|
+
*/
|
|
14
|
+
class FavoritesStore {
|
|
15
|
+
#store = new Persisted<FavoriteEntry[]>(
|
|
16
|
+
STORAGE_KEYS.favorites,
|
|
17
|
+
() => [],
|
|
18
|
+
// Discard anything that does not look like a favorite entry.
|
|
19
|
+
(raw) => (Array.isArray(raw) ? raw.filter((entry): entry is FavoriteEntry => !!entry && typeof entry.id === 'string') : [])
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
/** Newest first. */
|
|
23
|
+
get all(): FavoriteEntry[] {
|
|
24
|
+
return [...this.#store.current].sort((a, b) => b.favoritedAt - a.favoritedAt);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get count(): number {
|
|
28
|
+
return this.#store.current.length;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get ids(): Set<string> {
|
|
32
|
+
return new Set(this.#store.current.map((entry) => entry.id));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
has(id: string): boolean {
|
|
36
|
+
return this.#store.current.some((entry) => entry.id === id);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
add(recipe: RecipeSummary) {
|
|
40
|
+
if (this.has(recipe.id)) return;
|
|
41
|
+
this.#store.current = [...this.#store.current, { ...recipe, favoritedAt: Date.now() }];
|
|
42
|
+
this.#store.save();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
remove(id: string) {
|
|
46
|
+
const next = this.#store.current.filter((entry) => entry.id !== id);
|
|
47
|
+
if (next.length === this.#store.current.length) return;
|
|
48
|
+
this.#store.current = next;
|
|
49
|
+
this.#store.save();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Returns the resulting state, so callers can report it in a toast. */
|
|
53
|
+
toggle(recipe: RecipeSummary): boolean {
|
|
54
|
+
if (this.has(recipe.id)) {
|
|
55
|
+
this.remove(recipe.id);
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
this.add(recipe);
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Keeps the stored snapshot current after a user edits their own recipe.
|
|
64
|
+
* Without this, a renamed recipe would keep its old title in favorites.
|
|
65
|
+
*/
|
|
66
|
+
sync(recipe: RecipeSummary) {
|
|
67
|
+
const index = this.#store.current.findIndex((entry) => entry.id === recipe.id);
|
|
68
|
+
if (index === -1) return;
|
|
69
|
+
const next = [...this.#store.current];
|
|
70
|
+
next[index] = { ...next[index], ...recipe };
|
|
71
|
+
this.#store.current = next;
|
|
72
|
+
this.#store.save();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
clear() {
|
|
76
|
+
this.#store.reset();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const favorites = new FavoritesStore();
|
|
81
|
+
export type { FavoriteEntry };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createCustomId, toSummary, type Recipe } from '$domain/recipe';
|
|
2
|
+
import { favorites } from './favorites.svelte';
|
|
3
|
+
import { Persisted, STORAGE_KEYS } from './persisted.svelte';
|
|
4
|
+
import { planner } from './planner.svelte';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Store for user-authored recipes — the writable half of the catalogue.
|
|
8
|
+
*
|
|
9
|
+
* Recipes are the only entity the user fully owns, so this store also keeps the
|
|
10
|
+
* favorites and planner snapshots in step on edit and delete. Doing that here
|
|
11
|
+
* rather than in each page means no caller can forget it.
|
|
12
|
+
*/
|
|
13
|
+
class MyRecipesStore {
|
|
14
|
+
#store = new Persisted<Recipe[]>(
|
|
15
|
+
STORAGE_KEYS.myRecipes,
|
|
16
|
+
() => [],
|
|
17
|
+
(raw) => (Array.isArray(raw) ? raw.filter((entry): entry is Recipe => !!entry && typeof entry.id === 'string' && entry.origin === 'custom') : [])
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
/** Most recently updated first. */
|
|
21
|
+
get all(): Recipe[] {
|
|
22
|
+
return [...this.#store.current].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get count(): number {
|
|
26
|
+
return this.#store.current.length;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get(id: string): Recipe | undefined {
|
|
30
|
+
return this.#store.current.find((recipe) => recipe.id === id);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
has(id: string): boolean {
|
|
34
|
+
return this.#store.current.some((recipe) => recipe.id === id);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Case-insensitive duplicate-title check, excluding an optional recipe id. */
|
|
38
|
+
nameTaken(name: string, exceptId?: string): boolean {
|
|
39
|
+
const needle = name.trim().toLowerCase();
|
|
40
|
+
return this.#store.current.some((recipe) => recipe.id !== exceptId && recipe.name.trim().toLowerCase() === needle);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
create(input: Omit<Recipe, 'id' | 'origin' | 'createdAt' | 'updatedAt'>): Recipe {
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
const recipe: Recipe = { ...input, id: createCustomId(), origin: 'custom', createdAt: now, updatedAt: now };
|
|
46
|
+
this.#store.current = [...this.#store.current, recipe];
|
|
47
|
+
this.#store.save();
|
|
48
|
+
return recipe;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
update(id: string, input: Omit<Recipe, 'id' | 'origin' | 'createdAt' | 'updatedAt'>): Recipe | undefined {
|
|
52
|
+
const index = this.#store.current.findIndex((recipe) => recipe.id === id);
|
|
53
|
+
if (index === -1) return undefined;
|
|
54
|
+
|
|
55
|
+
const existing = this.#store.current[index];
|
|
56
|
+
const updated: Recipe = { ...existing, ...input, id, origin: 'custom', updatedAt: Date.now() };
|
|
57
|
+
|
|
58
|
+
const next = [...this.#store.current];
|
|
59
|
+
next[index] = updated;
|
|
60
|
+
this.#store.current = next;
|
|
61
|
+
this.#store.save();
|
|
62
|
+
|
|
63
|
+
// Propagate the rename / new image to the stored snapshots elsewhere.
|
|
64
|
+
const summary = toSummary(updated);
|
|
65
|
+
favorites.sync(summary);
|
|
66
|
+
planner.syncRecipe(summary);
|
|
67
|
+
|
|
68
|
+
return updated;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Returns the deleted recipe so the caller can offer an undo. */
|
|
72
|
+
remove(id: string): Recipe | undefined {
|
|
73
|
+
const recipe = this.get(id);
|
|
74
|
+
if (!recipe) return undefined;
|
|
75
|
+
|
|
76
|
+
this.#store.current = this.#store.current.filter((entry) => entry.id !== id);
|
|
77
|
+
this.#store.save();
|
|
78
|
+
|
|
79
|
+
// A deleted recipe must not linger as a favorite or a planned meal.
|
|
80
|
+
favorites.remove(id);
|
|
81
|
+
planner.removeRecipeEverywhere(id);
|
|
82
|
+
|
|
83
|
+
return recipe;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Re-inserts a previously deleted recipe verbatim, for undo. */
|
|
87
|
+
restore(recipe: Recipe) {
|
|
88
|
+
if (this.has(recipe.id)) return;
|
|
89
|
+
this.#store.current = [...this.#store.current, recipe];
|
|
90
|
+
this.#store.save();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
clear() {
|
|
94
|
+
this.#store.reset();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const myRecipes = new MyRecipesStore();
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { browser } from '$app/environment';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A `$state` value mirrored into `localStorage`.
|
|
5
|
+
*
|
|
6
|
+
* Persistence is explicit rather than `$effect`-driven: stores are created at
|
|
7
|
+
* module scope, outside any component, where effects have no owner. Calling
|
|
8
|
+
* `save()` from each mutation keeps writes predictable and SSR-safe.
|
|
9
|
+
*
|
|
10
|
+
* A `storage` listener keeps duplicate tabs in sync, which matters here because
|
|
11
|
+
* localStorage is the only source of truth for favorites, recipes and plans.
|
|
12
|
+
*/
|
|
13
|
+
export class Persisted<T> {
|
|
14
|
+
#key: string;
|
|
15
|
+
#fallback: () => T;
|
|
16
|
+
#revive?: (raw: unknown) => T;
|
|
17
|
+
|
|
18
|
+
/** The reactive value. Read it in markup; mutate through the owning store. */
|
|
19
|
+
current = $state<T>(undefined as T);
|
|
20
|
+
|
|
21
|
+
constructor(key: string, fallback: () => T, revive?: (raw: unknown) => T) {
|
|
22
|
+
this.#key = key;
|
|
23
|
+
this.#fallback = fallback;
|
|
24
|
+
this.#revive = revive;
|
|
25
|
+
this.current = fallback();
|
|
26
|
+
|
|
27
|
+
if (browser) {
|
|
28
|
+
this.current = this.#read();
|
|
29
|
+
window.addEventListener('storage', this.#onStorage);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#read(): T {
|
|
34
|
+
try {
|
|
35
|
+
const raw = localStorage.getItem(this.#key);
|
|
36
|
+
if (raw === null) return this.#fallback();
|
|
37
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
38
|
+
return this.#revive ? this.#revive(parsed) : (parsed as T);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
// Corrupt or foreign data must not break the app; start clean instead.
|
|
41
|
+
console.warn(`[store] Could not read "${this.#key}" from localStorage.`, error);
|
|
42
|
+
return this.#fallback();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#onStorage = (event: StorageEvent) => {
|
|
47
|
+
if (event.key !== this.#key) return;
|
|
48
|
+
this.current = this.#read();
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
save() {
|
|
52
|
+
if (!browser) return;
|
|
53
|
+
try {
|
|
54
|
+
localStorage.setItem(this.#key, JSON.stringify(this.current));
|
|
55
|
+
} catch (error) {
|
|
56
|
+
// Most likely a full or blocked (private-mode) quota. The in-memory value
|
|
57
|
+
// stays correct for this session, so surface it without throwing.
|
|
58
|
+
console.error(`[store] Could not persist "${this.#key}".`, error);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
reset() {
|
|
63
|
+
this.current = this.#fallback();
|
|
64
|
+
this.save();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const STORAGE_KEYS = {
|
|
69
|
+
favorites: 'recipe-ui:favorites:v1',
|
|
70
|
+
myRecipes: 'recipe-ui:my-recipes:v1',
|
|
71
|
+
planner: 'recipe-ui:planner:v1',
|
|
72
|
+
theme: 'recipe-ui:theme:v1',
|
|
73
|
+
} as const;
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import type { RecipeSummary } from '$domain/recipe';
|
|
2
|
+
import { Persisted, STORAGE_KEYS } from './persisted.svelte';
|
|
3
|
+
|
|
4
|
+
export const DAYS = [
|
|
5
|
+
{ key: 'mon', label: 'Monday', short: 'Mon' },
|
|
6
|
+
{ key: 'tue', label: 'Tuesday', short: 'Tue' },
|
|
7
|
+
{ key: 'wed', label: 'Wednesday', short: 'Wed' },
|
|
8
|
+
{ key: 'thu', label: 'Thursday', short: 'Thu' },
|
|
9
|
+
{ key: 'fri', label: 'Friday', short: 'Fri' },
|
|
10
|
+
{ key: 'sat', label: 'Saturday', short: 'Sat' },
|
|
11
|
+
{ key: 'sun', label: 'Sunday', short: 'Sun' },
|
|
12
|
+
] as const;
|
|
13
|
+
|
|
14
|
+
export const MEALS = [
|
|
15
|
+
{ key: 'breakfast', label: 'Breakfast' },
|
|
16
|
+
{ key: 'lunch', label: 'Lunch' },
|
|
17
|
+
{ key: 'dinner', label: 'Dinner' },
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
export type DayKey = (typeof DAYS)[number]['key'];
|
|
21
|
+
export type MealKey = (typeof MEALS)[number]['key'];
|
|
22
|
+
|
|
23
|
+
export interface PlannedMeal {
|
|
24
|
+
recipeId: string;
|
|
25
|
+
name: string;
|
|
26
|
+
image?: string;
|
|
27
|
+
category: string;
|
|
28
|
+
area: string;
|
|
29
|
+
origin: 'api' | 'custom';
|
|
30
|
+
servings?: number;
|
|
31
|
+
addedAt: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** `{ mon: { dinner: PlannedMeal } }` — sparse; absent keys mean "unplanned". */
|
|
35
|
+
type WeekPlan = Partial<Record<DayKey, Partial<Record<MealKey, PlannedMeal>>>>;
|
|
36
|
+
|
|
37
|
+
/** Plans are keyed by the Monday of their week, so weeks are addressable. */
|
|
38
|
+
type PlanArchive = Record<string, WeekPlan>;
|
|
39
|
+
|
|
40
|
+
/* -------------------------------------------------------------- week helpers */
|
|
41
|
+
|
|
42
|
+
/** ISO week start (Monday) for a date, as a `YYYY-MM-DD` key. */
|
|
43
|
+
export function weekKeyOf(date: Date): string {
|
|
44
|
+
const copy = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
45
|
+
// getDay(): 0 = Sunday. Shift so Monday is the first day of the week.
|
|
46
|
+
const offset = (copy.getDay() + 6) % 7;
|
|
47
|
+
copy.setDate(copy.getDate() - offset);
|
|
48
|
+
const month = String(copy.getMonth() + 1).padStart(2, '0');
|
|
49
|
+
const day = String(copy.getDate()).padStart(2, '0');
|
|
50
|
+
return `${copy.getFullYear()}-${month}-${day}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function currentWeekKey(): string {
|
|
54
|
+
return weekKeyOf(new Date());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function shiftWeekKey(key: string, weeks: number): string {
|
|
58
|
+
const start = parseWeekKey(key);
|
|
59
|
+
start.setDate(start.getDate() + weeks * 7);
|
|
60
|
+
return weekKeyOf(start);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function parseWeekKey(key: string): Date {
|
|
64
|
+
const [year, month, day] = key.split('-').map(Number);
|
|
65
|
+
return new Date(year, (month ?? 1) - 1, day ?? 1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** "25 Aug – 31 Aug 2026" */
|
|
69
|
+
export function formatWeekRange(key: string): string {
|
|
70
|
+
const start = parseWeekKey(key);
|
|
71
|
+
const end = new Date(start);
|
|
72
|
+
end.setDate(end.getDate() + 6);
|
|
73
|
+
|
|
74
|
+
const sameYear = start.getFullYear() === end.getFullYear();
|
|
75
|
+
const startText = start.toLocaleDateString(undefined, { day: 'numeric', month: 'short', ...(sameYear ? {} : { year: 'numeric' }) });
|
|
76
|
+
const endText = end.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
|
|
77
|
+
return `${startText} – ${endText}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The calendar date a given day slot falls on, for date labels in the grid. */
|
|
81
|
+
export function dateForDay(weekKey: string, day: DayKey): Date {
|
|
82
|
+
const index = DAYS.findIndex((entry) => entry.key === day);
|
|
83
|
+
const date = parseWeekKey(weekKey);
|
|
84
|
+
date.setDate(date.getDate() + Math.max(0, index));
|
|
85
|
+
return date;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* --------------------------------------------------------------------- store */
|
|
89
|
+
|
|
90
|
+
class PlannerStore {
|
|
91
|
+
#store = new Persisted<PlanArchive>(
|
|
92
|
+
STORAGE_KEYS.planner,
|
|
93
|
+
() => ({}),
|
|
94
|
+
(raw) => (raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as PlanArchive) : {})
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
/** The week currently shown in the planner UI. */
|
|
98
|
+
selectedWeek = $state(currentWeekKey());
|
|
99
|
+
|
|
100
|
+
get archive(): PlanArchive {
|
|
101
|
+
return this.#store.current;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
week(weekKey: string = this.selectedWeek): WeekPlan {
|
|
105
|
+
return this.#store.current[weekKey] ?? {};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
meal(day: DayKey, mealType: MealKey, weekKey: string = this.selectedWeek): PlannedMeal | undefined {
|
|
109
|
+
return this.#store.current[weekKey]?.[day]?.[mealType];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Number of filled slots in a week — used for the "N meals planned" summary. */
|
|
113
|
+
countFor(weekKey: string = this.selectedWeek): number {
|
|
114
|
+
const week = this.week(weekKey);
|
|
115
|
+
return Object.values(week).reduce((total, day) => total + Object.keys(day ?? {}).length, 0);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Every week that has at least one planned meal, oldest first. */
|
|
119
|
+
get plannedWeeks(): string[] {
|
|
120
|
+
return Object.keys(this.#store.current)
|
|
121
|
+
.filter((key) => this.countFor(key) > 0)
|
|
122
|
+
.sort();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Distinct recipes in a week, with how many slots each occupies. */
|
|
126
|
+
recipesFor(weekKey: string = this.selectedWeek): { meal: PlannedMeal; slots: number }[] {
|
|
127
|
+
const tally = new Map<string, { meal: PlannedMeal; slots: number }>();
|
|
128
|
+
for (const day of Object.values(this.week(weekKey))) {
|
|
129
|
+
for (const meal of Object.values(day ?? {})) {
|
|
130
|
+
if (!meal) continue;
|
|
131
|
+
const existing = tally.get(meal.recipeId);
|
|
132
|
+
if (existing) existing.slots += 1;
|
|
133
|
+
else tally.set(meal.recipeId, { meal, slots: 1 });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return [...tally.values()].sort((a, b) => b.slots - a.slots || a.meal.name.localeCompare(b.meal.name));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
assign(day: DayKey, mealType: MealKey, recipe: RecipeSummary, weekKey: string = this.selectedWeek) {
|
|
140
|
+
const week = { ...(this.#store.current[weekKey] ?? {}) };
|
|
141
|
+
week[day] = {
|
|
142
|
+
...(week[day] ?? {}),
|
|
143
|
+
[mealType]: {
|
|
144
|
+
recipeId: recipe.id,
|
|
145
|
+
name: recipe.name,
|
|
146
|
+
image: recipe.image,
|
|
147
|
+
category: recipe.category,
|
|
148
|
+
area: recipe.area,
|
|
149
|
+
origin: recipe.origin,
|
|
150
|
+
servings: recipe.servings,
|
|
151
|
+
addedAt: Date.now(),
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
this.#store.current = { ...this.#store.current, [weekKey]: week };
|
|
155
|
+
this.#store.save();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
remove(day: DayKey, mealType: MealKey, weekKey: string = this.selectedWeek) {
|
|
159
|
+
const week = this.#store.current[weekKey];
|
|
160
|
+
if (!week?.[day]?.[mealType]) return;
|
|
161
|
+
|
|
162
|
+
const dayPlan = { ...week[day] };
|
|
163
|
+
delete dayPlan[mealType];
|
|
164
|
+
|
|
165
|
+
const nextWeek = { ...week };
|
|
166
|
+
// Prune empty containers so `countFor` and `plannedWeeks` stay accurate.
|
|
167
|
+
if (Object.keys(dayPlan).length === 0) delete nextWeek[day];
|
|
168
|
+
else nextWeek[day] = dayPlan;
|
|
169
|
+
|
|
170
|
+
const archive = { ...this.#store.current };
|
|
171
|
+
if (Object.keys(nextWeek).length === 0) delete archive[weekKey];
|
|
172
|
+
else archive[weekKey] = nextWeek;
|
|
173
|
+
|
|
174
|
+
this.#store.current = archive;
|
|
175
|
+
this.#store.save();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Moves a planned meal between slots, used by drag-and-drop. */
|
|
179
|
+
move(from: { day: DayKey; mealType: MealKey }, to: { day: DayKey; mealType: MealKey }, weekKey: string = this.selectedWeek) {
|
|
180
|
+
const meal = this.meal(from.day, from.mealType, weekKey);
|
|
181
|
+
if (!meal) return;
|
|
182
|
+
if (from.day === to.day && from.mealType === to.mealType) return;
|
|
183
|
+
|
|
184
|
+
this.remove(from.day, from.mealType, weekKey);
|
|
185
|
+
this.assign(to.day, to.mealType, { ...meal, id: meal.recipeId }, weekKey);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
clearWeek(weekKey: string = this.selectedWeek) {
|
|
189
|
+
if (!this.#store.current[weekKey]) return;
|
|
190
|
+
const archive = { ...this.#store.current };
|
|
191
|
+
delete archive[weekKey];
|
|
192
|
+
this.#store.current = archive;
|
|
193
|
+
this.#store.save();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Copies every meal from one week into another, for "repeat last week". */
|
|
197
|
+
copyWeek(fromKey: string, toKey: string) {
|
|
198
|
+
const source = this.#store.current[fromKey];
|
|
199
|
+
if (!source) return;
|
|
200
|
+
const clone: WeekPlan = {};
|
|
201
|
+
for (const [day, meals] of Object.entries(source)) {
|
|
202
|
+
clone[day as DayKey] = { ...meals };
|
|
203
|
+
}
|
|
204
|
+
this.#store.current = { ...this.#store.current, [toKey]: clone };
|
|
205
|
+
this.#store.save();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Mirrors an edited recipe's new title / image into every planned slot. */
|
|
209
|
+
syncRecipe(recipe: RecipeSummary) {
|
|
210
|
+
let changed = false;
|
|
211
|
+
const archive: PlanArchive = {};
|
|
212
|
+
|
|
213
|
+
for (const [weekKey, week] of Object.entries(this.#store.current)) {
|
|
214
|
+
const nextWeek: WeekPlan = {};
|
|
215
|
+
for (const [day, meals] of Object.entries(week)) {
|
|
216
|
+
const nextMeals: Partial<Record<MealKey, PlannedMeal>> = {};
|
|
217
|
+
for (const [mealType, meal] of Object.entries(meals ?? {})) {
|
|
218
|
+
if (meal && meal.recipeId === recipe.id) {
|
|
219
|
+
nextMeals[mealType as MealKey] = {
|
|
220
|
+
...meal,
|
|
221
|
+
name: recipe.name,
|
|
222
|
+
image: recipe.image,
|
|
223
|
+
category: recipe.category,
|
|
224
|
+
area: recipe.area,
|
|
225
|
+
};
|
|
226
|
+
changed = true;
|
|
227
|
+
} else if (meal) {
|
|
228
|
+
nextMeals[mealType as MealKey] = meal;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
nextWeek[day as DayKey] = nextMeals;
|
|
232
|
+
}
|
|
233
|
+
archive[weekKey] = nextWeek;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!changed) return;
|
|
237
|
+
this.#store.current = archive;
|
|
238
|
+
this.#store.save();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Drops a recipe from every slot of every week — called when it is deleted. */
|
|
242
|
+
removeRecipeEverywhere(recipeId: string) {
|
|
243
|
+
let changed = false;
|
|
244
|
+
const archive: PlanArchive = {};
|
|
245
|
+
|
|
246
|
+
for (const [weekKey, week] of Object.entries(this.#store.current)) {
|
|
247
|
+
const nextWeek: WeekPlan = {};
|
|
248
|
+
for (const [day, meals] of Object.entries(week)) {
|
|
249
|
+
const nextMeals: Partial<Record<MealKey, PlannedMeal>> = {};
|
|
250
|
+
for (const [mealType, meal] of Object.entries(meals ?? {})) {
|
|
251
|
+
if (meal && meal.recipeId === recipeId) {
|
|
252
|
+
changed = true;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (meal) nextMeals[mealType as MealKey] = meal;
|
|
256
|
+
}
|
|
257
|
+
if (Object.keys(nextMeals).length > 0) nextWeek[day as DayKey] = nextMeals;
|
|
258
|
+
}
|
|
259
|
+
if (Object.keys(nextWeek).length > 0) archive[weekKey] = nextWeek;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (!changed) return;
|
|
263
|
+
this.#store.current = archive;
|
|
264
|
+
this.#store.save();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Wipes every week and returns the UI to the current week. */
|
|
268
|
+
clearAll() {
|
|
269
|
+
this.#store.reset();
|
|
270
|
+
this.selectedWeek = currentWeekKey();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export const planner = new PlannerStore();
|
|
275
|
+
export type { WeekPlan, PlanArchive };
|