@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,185 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Recipe picker used by the weekly planner.
|
|
3
|
+
|
|
4
|
+
Composes four Stencil components inside one dialog: `rk-modal` for the shell,
|
|
5
|
+
`rk-segmented-control` to switch source, `rk-search-bar` to query TheMealDB,
|
|
6
|
+
and `rk-recipe-card` for each result.
|
|
7
|
+
-->
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import { searchRecipes } from '$api/mealdb';
|
|
10
|
+
import type { RecipeSummary } from '$domain/recipe';
|
|
11
|
+
import { toSummary } from '$domain/recipe';
|
|
12
|
+
import WebComponent from '$lib/stencil/WebComponent.svelte';
|
|
13
|
+
import { favorites } from '$stores/favorites.svelte';
|
|
14
|
+
import { myRecipes } from '$stores/myRecipes.svelte';
|
|
15
|
+
import Modal from './Modal.svelte';
|
|
16
|
+
import RecipeCard from './RecipeCard.svelte';
|
|
17
|
+
|
|
18
|
+
interface Props {
|
|
19
|
+
open: boolean;
|
|
20
|
+
/** Describes the slot being filled, e.g. "Monday · Dinner". */
|
|
21
|
+
slotLabel: string;
|
|
22
|
+
onpick: (recipe: RecipeSummary) => void;
|
|
23
|
+
onclose: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let { open, slotLabel, onpick, onclose }: Props = $props();
|
|
27
|
+
|
|
28
|
+
type Source = 'favorites' | 'mine' | 'search';
|
|
29
|
+
|
|
30
|
+
let source = $state<Source>('favorites');
|
|
31
|
+
let query = $state('');
|
|
32
|
+
let searchResults = $state<RecipeSummary[]>([]);
|
|
33
|
+
let searching = $state(false);
|
|
34
|
+
let searchError = $state<string | null>(null);
|
|
35
|
+
|
|
36
|
+
const sourceOptions = $derived([
|
|
37
|
+
{ label: 'Favorites', value: 'favorites', count: favorites.count },
|
|
38
|
+
{ label: 'My recipes', value: 'mine', count: myRecipes.count },
|
|
39
|
+
{ label: 'Search all', value: 'search' }
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const localMatches = $derived.by(() => {
|
|
43
|
+
const pool: RecipeSummary[] = source === 'mine' ? myRecipes.all.map(toSummary) : favorites.all;
|
|
44
|
+
const needle = query.trim().toLowerCase();
|
|
45
|
+
if (!needle) return pool;
|
|
46
|
+
return pool.filter(
|
|
47
|
+
(recipe) =>
|
|
48
|
+
recipe.name.toLowerCase().includes(needle) ||
|
|
49
|
+
recipe.category.toLowerCase().includes(needle) ||
|
|
50
|
+
recipe.area.toLowerCase().includes(needle)
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const visible = $derived(source === 'search' ? searchResults : localMatches);
|
|
55
|
+
|
|
56
|
+
// Reset transient state each time the dialog opens, so it never reopens
|
|
57
|
+
// showing the previous slot's search.
|
|
58
|
+
$effect(() => {
|
|
59
|
+
if (!open) return;
|
|
60
|
+
query = '';
|
|
61
|
+
searchResults = [];
|
|
62
|
+
searchError = null;
|
|
63
|
+
source = favorites.count > 0 ? 'favorites' : myRecipes.count > 0 ? 'mine' : 'search';
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
let requestToken = 0;
|
|
67
|
+
|
|
68
|
+
async function runSearch(term: string) {
|
|
69
|
+
const trimmed = term.trim();
|
|
70
|
+
if (trimmed.length < 2) {
|
|
71
|
+
searchResults = [];
|
|
72
|
+
searchError = null;
|
|
73
|
+
searching = false;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Guard against a slow earlier request overwriting a newer one.
|
|
78
|
+
const token = ++requestToken;
|
|
79
|
+
searching = true;
|
|
80
|
+
searchError = null;
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const results = await searchRecipes(trimmed);
|
|
84
|
+
if (token !== requestToken) return;
|
|
85
|
+
searchResults = results;
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (token !== requestToken) return;
|
|
88
|
+
searchResults = [];
|
|
89
|
+
searchError = error instanceof Error ? error.message : 'Search failed.';
|
|
90
|
+
} finally {
|
|
91
|
+
if (token === requestToken) searching = false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function handleSearch(event: CustomEvent<string>) {
|
|
96
|
+
query = event.detail;
|
|
97
|
+
if (source === 'search') void runSearch(event.detail);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function handleSourceChange(event: CustomEvent<string>) {
|
|
101
|
+
source = event.detail as Source;
|
|
102
|
+
if (source === 'search' && query.trim().length >= 2) void runSearch(query);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const emptyCopy = $derived.by(() => {
|
|
106
|
+
if (source === 'search') {
|
|
107
|
+
if (searchError) return { icon: '⚠️', heading: 'Search failed', message: searchError, tone: 'error' as const };
|
|
108
|
+
if (query.trim().length < 2) return { icon: '🔎', heading: 'Search the catalogue', message: 'Type at least two characters to search thousands of recipes.', tone: 'neutral' as const };
|
|
109
|
+
return { icon: '🫙', heading: `No recipes match “${query.trim()}”`, message: 'Try a different name or ingredient.', tone: 'neutral' as const };
|
|
110
|
+
}
|
|
111
|
+
if (source === 'mine') {
|
|
112
|
+
return { icon: '📝', heading: 'No recipes of your own yet', message: 'Recipes you create appear here, ready to drop into the plan.', tone: 'neutral' as const };
|
|
113
|
+
}
|
|
114
|
+
return { icon: '🤍', heading: 'No favorites yet', message: 'Favorite a few recipes and they will show up here first.', tone: 'neutral' as const };
|
|
115
|
+
});
|
|
116
|
+
</script>
|
|
117
|
+
|
|
118
|
+
<Modal {open} heading="Add a recipe" subheading={slotLabel} size="lg" {onclose}>
|
|
119
|
+
<div class="picker">
|
|
120
|
+
<WebComponent
|
|
121
|
+
tag="rk-segmented-control"
|
|
122
|
+
props={{ options: sourceOptions, value: source, label: 'Recipe source', stretch: true }}
|
|
123
|
+
events={{ rkSegmentChange: handleSourceChange as (event: CustomEvent<never>) => void }}
|
|
124
|
+
/>
|
|
125
|
+
|
|
126
|
+
<WebComponent
|
|
127
|
+
tag="rk-search-bar"
|
|
128
|
+
props={{
|
|
129
|
+
value: query,
|
|
130
|
+
placeholder: source === 'search' ? 'Search every recipe…' : 'Filter this list…',
|
|
131
|
+
loading: searching,
|
|
132
|
+
debounce: 400
|
|
133
|
+
}}
|
|
134
|
+
events={{ rkSearch: handleSearch as (event: CustomEvent<never>) => void }}
|
|
135
|
+
/>
|
|
136
|
+
|
|
137
|
+
{#if visible.length > 0}
|
|
138
|
+
<p class="count small muted">{visible.length} {visible.length === 1 ? 'recipe' : 'recipes'}</p>
|
|
139
|
+
<ul class="results">
|
|
140
|
+
{#each visible as recipe (recipe.id)}
|
|
141
|
+
<li>
|
|
142
|
+
<RecipeCard
|
|
143
|
+
{recipe}
|
|
144
|
+
compact
|
|
145
|
+
hideFavorite
|
|
146
|
+
onselect={() => {
|
|
147
|
+
onpick(recipe);
|
|
148
|
+
return true;
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
{#snippet actions(item)}
|
|
152
|
+
<button slot="actions" class="btn btn-primary btn-sm" type="button" onclick={() => onpick(item)}>Add</button>
|
|
153
|
+
{/snippet}
|
|
154
|
+
</RecipeCard>
|
|
155
|
+
</li>
|
|
156
|
+
{/each}
|
|
157
|
+
</ul>
|
|
158
|
+
{:else}
|
|
159
|
+
<rk-empty-state bare icon={emptyCopy.icon} heading={emptyCopy.heading} message={emptyCopy.message} tone={emptyCopy.tone}></rk-empty-state>
|
|
160
|
+
{/if}
|
|
161
|
+
</div>
|
|
162
|
+
</Modal>
|
|
163
|
+
|
|
164
|
+
<style>
|
|
165
|
+
.picker {
|
|
166
|
+
display: grid;
|
|
167
|
+
gap: 14px;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.count {
|
|
171
|
+
margin: 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.results {
|
|
175
|
+
list-style: none;
|
|
176
|
+
margin: 0;
|
|
177
|
+
padding: 0;
|
|
178
|
+
display: grid;
|
|
179
|
+
gap: 10px;
|
|
180
|
+
max-height: 46vh;
|
|
181
|
+
overflow-y: auto;
|
|
182
|
+
/* Room for the focus ring of the last card. */
|
|
183
|
+
padding: 2px;
|
|
184
|
+
}
|
|
185
|
+
</style>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { fly } from 'svelte/transition';
|
|
3
|
+
import { toasts } from '$stores/toast.svelte';
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<div class="toaster" role="region" aria-label="Notifications" aria-live="polite">
|
|
7
|
+
{#each toasts.all as toast (toast.id)}
|
|
8
|
+
<div class="toast tone-{toast.tone}" transition:fly={{ y: 14, duration: 180 }}>
|
|
9
|
+
<p class="message">{toast.message}</p>
|
|
10
|
+
|
|
11
|
+
{#if toast.action}
|
|
12
|
+
<button class="action" type="button" onclick={() => toasts.runAction(toast.id)}>
|
|
13
|
+
{toast.action.label}
|
|
14
|
+
</button>
|
|
15
|
+
{/if}
|
|
16
|
+
|
|
17
|
+
<button class="close" type="button" aria-label="Dismiss notification" onclick={() => toasts.dismiss(toast.id)}>
|
|
18
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true">
|
|
19
|
+
<path d="M6 6l12 12M18 6 6 18" />
|
|
20
|
+
</svg>
|
|
21
|
+
</button>
|
|
22
|
+
</div>
|
|
23
|
+
{/each}
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<style>
|
|
27
|
+
.toaster {
|
|
28
|
+
position: fixed;
|
|
29
|
+
bottom: 20px;
|
|
30
|
+
left: 50%;
|
|
31
|
+
transform: translateX(-50%);
|
|
32
|
+
z-index: 1100;
|
|
33
|
+
display: grid;
|
|
34
|
+
gap: 8px;
|
|
35
|
+
width: min(460px, calc(100vw - 32px));
|
|
36
|
+
pointer-events: none;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.toast {
|
|
40
|
+
display: flex;
|
|
41
|
+
align-items: center;
|
|
42
|
+
gap: 12px;
|
|
43
|
+
padding: 12px 12px 12px 16px;
|
|
44
|
+
border-radius: var(--rk-radius);
|
|
45
|
+
background: var(--rk-ink);
|
|
46
|
+
color: var(--rk-bg);
|
|
47
|
+
box-shadow: var(--rk-shadow-lg);
|
|
48
|
+
pointer-events: auto;
|
|
49
|
+
font-size: 0.89rem;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.toast.tone-success {
|
|
53
|
+
background: var(--rk-success);
|
|
54
|
+
color: #fff;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.toast.tone-error {
|
|
58
|
+
background: var(--rk-danger);
|
|
59
|
+
color: #fff;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.message {
|
|
63
|
+
flex: 1 1 auto;
|
|
64
|
+
line-height: 1.45;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.action {
|
|
68
|
+
flex: 0 0 auto;
|
|
69
|
+
padding: 6px 12px;
|
|
70
|
+
border: 1px solid currentColor;
|
|
71
|
+
border-radius: var(--rk-radius-pill);
|
|
72
|
+
background: transparent;
|
|
73
|
+
color: inherit;
|
|
74
|
+
font: inherit;
|
|
75
|
+
font-size: 0.83rem;
|
|
76
|
+
font-weight: 650;
|
|
77
|
+
cursor: pointer;
|
|
78
|
+
opacity: 0.9;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.action:hover {
|
|
82
|
+
opacity: 1;
|
|
83
|
+
background: rgba(255, 255, 255, 0.16);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.close {
|
|
87
|
+
flex: 0 0 auto;
|
|
88
|
+
display: inline-flex;
|
|
89
|
+
align-items: center;
|
|
90
|
+
justify-content: center;
|
|
91
|
+
width: 26px;
|
|
92
|
+
height: 26px;
|
|
93
|
+
padding: 0;
|
|
94
|
+
border: 0;
|
|
95
|
+
border-radius: 50%;
|
|
96
|
+
background: transparent;
|
|
97
|
+
color: inherit;
|
|
98
|
+
opacity: 0.7;
|
|
99
|
+
cursor: pointer;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.close:hover {
|
|
103
|
+
opacity: 1;
|
|
104
|
+
background: rgba(255, 255, 255, 0.16);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.close svg {
|
|
108
|
+
width: 12px;
|
|
109
|
+
height: 12px;
|
|
110
|
+
}
|
|
111
|
+
</style>
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The application's own recipe model.
|
|
3
|
+
*
|
|
4
|
+
* Everything the UI renders speaks this shape. TheMealDB's wire format and
|
|
5
|
+
* user-authored recipes are both normalised into it, so no component ever needs
|
|
6
|
+
* to know where a recipe came from.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type RecipeOrigin = 'api' | 'custom';
|
|
10
|
+
|
|
11
|
+
export interface Ingredient {
|
|
12
|
+
name: string;
|
|
13
|
+
/** Free-text quantity, e.g. "3/4 cup". Optional — many recipes omit it. */
|
|
14
|
+
measure: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Recipe {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
category: string;
|
|
21
|
+
area: string;
|
|
22
|
+
image?: string;
|
|
23
|
+
/** Instruction steps, already split into individual steps. */
|
|
24
|
+
steps: string[];
|
|
25
|
+
ingredients: Ingredient[];
|
|
26
|
+
tags: string[];
|
|
27
|
+
origin: RecipeOrigin;
|
|
28
|
+
youtubeUrl?: string;
|
|
29
|
+
sourceUrl?: string;
|
|
30
|
+
/** Total time in minutes. Only user-authored recipes carry this. */
|
|
31
|
+
minutes?: number;
|
|
32
|
+
servings?: number;
|
|
33
|
+
notes?: string;
|
|
34
|
+
createdAt?: number;
|
|
35
|
+
updatedAt?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The reduced shape used in grids, favorites and the planner. Storing this
|
|
40
|
+
* snapshot alongside a favorite or a planned meal means those views render
|
|
41
|
+
* instantly and keep working offline, without re-fetching the full recipe.
|
|
42
|
+
*/
|
|
43
|
+
export interface RecipeSummary {
|
|
44
|
+
id: string;
|
|
45
|
+
name: string;
|
|
46
|
+
image?: string;
|
|
47
|
+
category: string;
|
|
48
|
+
area: string;
|
|
49
|
+
origin: RecipeOrigin;
|
|
50
|
+
ingredientCount?: number;
|
|
51
|
+
minutes?: number;
|
|
52
|
+
servings?: number;
|
|
53
|
+
tags?: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function toSummary(recipe: Recipe): RecipeSummary {
|
|
57
|
+
return {
|
|
58
|
+
id: recipe.id,
|
|
59
|
+
name: recipe.name,
|
|
60
|
+
image: recipe.image,
|
|
61
|
+
category: recipe.category,
|
|
62
|
+
area: recipe.area,
|
|
63
|
+
origin: recipe.origin,
|
|
64
|
+
ingredientCount: recipe.ingredients.length,
|
|
65
|
+
minutes: recipe.minutes,
|
|
66
|
+
servings: recipe.servings,
|
|
67
|
+
tags: recipe.tags,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** User-authored recipe ids are prefixed so they never collide with API ids. */
|
|
72
|
+
export const CUSTOM_ID_PREFIX = 'my-';
|
|
73
|
+
|
|
74
|
+
export function isCustomId(id: string): boolean {
|
|
75
|
+
return id.startsWith(CUSTOM_ID_PREFIX);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createCustomId(): string {
|
|
79
|
+
// `crypto.randomUUID` needs a secure context; the timestamp form is a safe
|
|
80
|
+
// fallback and still collision-free for a single-user local store.
|
|
81
|
+
const unique =
|
|
82
|
+
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
|
83
|
+
? crypto.randomUUID()
|
|
84
|
+
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
85
|
+
return `${CUSTOM_ID_PREFIX}${unique}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Splits an instruction blob into steps. TheMealDB is inconsistent: some
|
|
90
|
+
* recipes use blank lines, some use "STEP 1", some are one long paragraph.
|
|
91
|
+
*/
|
|
92
|
+
export function splitInstructions(instructions: string): string[] {
|
|
93
|
+
if (!instructions) return [];
|
|
94
|
+
|
|
95
|
+
const normalised = instructions.replace(/\r\n/g, '\n').trim();
|
|
96
|
+
|
|
97
|
+
// Prefer explicit line breaks when the source provides them.
|
|
98
|
+
const byLine = normalised
|
|
99
|
+
.split(/\n+/)
|
|
100
|
+
.map((line) => line.replace(/^\s*(?:step\s*)?\d+[.)::]?\s*/i, '').trim())
|
|
101
|
+
.filter((line) => line.length > 0);
|
|
102
|
+
|
|
103
|
+
if (byLine.length > 1) return byLine;
|
|
104
|
+
|
|
105
|
+
// Otherwise fall back to sentence boundaries, grouped so steps stay readable.
|
|
106
|
+
const sentences = normalised.match(/[^.!?]+[.!?]+(?:\s|$)|[^.!?]+$/g) ?? [normalised];
|
|
107
|
+
const steps: string[] = [];
|
|
108
|
+
let buffer = '';
|
|
109
|
+
|
|
110
|
+
for (const sentence of sentences) {
|
|
111
|
+
buffer += sentence;
|
|
112
|
+
if (buffer.trim().length >= 90) {
|
|
113
|
+
steps.push(buffer.trim());
|
|
114
|
+
buffer = '';
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (buffer.trim()) steps.push(buffer.trim());
|
|
118
|
+
|
|
119
|
+
return steps.length > 0 ? steps : [normalised];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function joinSteps(steps: string[]): string {
|
|
123
|
+
return steps.join('\n');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Human-readable summary line used in cards and list rows. */
|
|
127
|
+
export function describeRecipe(recipe: RecipeSummary): string {
|
|
128
|
+
const parts = [recipe.category, recipe.area].filter((part) => part && part !== 'Unknown');
|
|
129
|
+
return parts.join(' · ');
|
|
130
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation for user-authored recipes.
|
|
3
|
+
*
|
|
4
|
+
* Kept free of Svelte imports so it can be unit-tested, and so the same rules
|
|
5
|
+
* could be reused on a server if the local store is ever replaced by an API.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Ingredient } from './recipe';
|
|
9
|
+
|
|
10
|
+
export interface RecipeDraft {
|
|
11
|
+
name: string;
|
|
12
|
+
category: string;
|
|
13
|
+
area: string;
|
|
14
|
+
image: string;
|
|
15
|
+
minutes: string;
|
|
16
|
+
servings: string;
|
|
17
|
+
ingredients: Ingredient[];
|
|
18
|
+
steps: string[];
|
|
19
|
+
tags: string[];
|
|
20
|
+
notes: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type RecipeErrors = Partial<Record<keyof RecipeDraft, string>> & {
|
|
24
|
+
/** Per-row ingredient errors, keyed by index. */
|
|
25
|
+
ingredientRows?: Record<number, string>;
|
|
26
|
+
stepRows?: Record<number, string>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const LIMITS = {
|
|
30
|
+
nameMin: 3,
|
|
31
|
+
nameMax: 120,
|
|
32
|
+
minIngredients: 2,
|
|
33
|
+
maxIngredients: 20,
|
|
34
|
+
minSteps: 1,
|
|
35
|
+
maxSteps: 30,
|
|
36
|
+
stepMin: 5,
|
|
37
|
+
maxTags: 6,
|
|
38
|
+
tagMax: 24,
|
|
39
|
+
minutesMax: 1440,
|
|
40
|
+
servingsMax: 50,
|
|
41
|
+
notesMax: 500,
|
|
42
|
+
} as const;
|
|
43
|
+
|
|
44
|
+
export function emptyDraft(): RecipeDraft {
|
|
45
|
+
return {
|
|
46
|
+
name: '',
|
|
47
|
+
category: '',
|
|
48
|
+
area: '',
|
|
49
|
+
image: '',
|
|
50
|
+
minutes: '',
|
|
51
|
+
servings: '',
|
|
52
|
+
ingredients: [
|
|
53
|
+
{ name: '', measure: '' },
|
|
54
|
+
{ name: '', measure: '' },
|
|
55
|
+
],
|
|
56
|
+
steps: [''],
|
|
57
|
+
tags: [],
|
|
58
|
+
notes: '',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isHttpUrl(value: string): boolean {
|
|
63
|
+
try {
|
|
64
|
+
const url = new URL(value);
|
|
65
|
+
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Parses an optional positive-integer field.
|
|
73
|
+
* Returns `undefined` for blank input and `null` for malformed input, so the
|
|
74
|
+
* caller can tell "not provided" apart from "provided but invalid".
|
|
75
|
+
*/
|
|
76
|
+
function parseOptionalCount(raw: string, max: number): number | undefined | null {
|
|
77
|
+
const trimmed = raw.trim();
|
|
78
|
+
if (trimmed === '') return undefined;
|
|
79
|
+
if (!/^\d+$/.test(trimmed)) return null;
|
|
80
|
+
const parsed = Number(trimmed);
|
|
81
|
+
return parsed >= 1 && parsed <= max ? parsed : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function validateDraft(draft: RecipeDraft): RecipeErrors {
|
|
85
|
+
const errors: RecipeErrors = {};
|
|
86
|
+
|
|
87
|
+
/* ---- name ---- */
|
|
88
|
+
const name = draft.name.trim();
|
|
89
|
+
if (!name) {
|
|
90
|
+
errors.name = 'A recipe name is required.';
|
|
91
|
+
} else if (name.length < LIMITS.nameMin) {
|
|
92
|
+
errors.name = `Use at least ${LIMITS.nameMin} characters.`;
|
|
93
|
+
} else if (name.length > LIMITS.nameMax) {
|
|
94
|
+
errors.name = `Keep the name under ${LIMITS.nameMax} characters.`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* ---- category / area ---- */
|
|
98
|
+
if (!draft.category.trim()) errors.category = 'Pick a category.';
|
|
99
|
+
if (!draft.area.trim()) errors.area = 'Pick a cuisine.';
|
|
100
|
+
|
|
101
|
+
/* ---- image ---- */
|
|
102
|
+
const image = draft.image.trim();
|
|
103
|
+
if (image && !isHttpUrl(image)) {
|
|
104
|
+
errors.image = 'Enter a full http(s) image URL, or leave this blank.';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* ---- time & servings ---- */
|
|
108
|
+
const minutes = parseOptionalCount(draft.minutes, LIMITS.minutesMax);
|
|
109
|
+
if (minutes === null) errors.minutes = `Enter a whole number of minutes between 1 and ${LIMITS.minutesMax}.`;
|
|
110
|
+
|
|
111
|
+
const servings = parseOptionalCount(draft.servings, LIMITS.servingsMax);
|
|
112
|
+
if (servings === null) errors.servings = `Enter a whole number between 1 and ${LIMITS.servingsMax}.`;
|
|
113
|
+
|
|
114
|
+
/* ---- ingredients ---- */
|
|
115
|
+
const namedIngredients = draft.ingredients.filter((row) => row.name.trim() !== '');
|
|
116
|
+
if (namedIngredients.length < LIMITS.minIngredients) {
|
|
117
|
+
errors.ingredients = `List at least ${LIMITS.minIngredients} ingredients.`;
|
|
118
|
+
} else if (namedIngredients.length > LIMITS.maxIngredients) {
|
|
119
|
+
errors.ingredients = `A recipe can hold up to ${LIMITS.maxIngredients} ingredients.`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const ingredientRows: Record<number, string> = {};
|
|
123
|
+
const seen = new Map<string, number>();
|
|
124
|
+
draft.ingredients.forEach((row, index) => {
|
|
125
|
+
const rowName = row.name.trim();
|
|
126
|
+
// A blank row is a placeholder, not an error — unless a measure was typed.
|
|
127
|
+
if (!rowName) {
|
|
128
|
+
if (row.measure.trim()) ingredientRows[index] = 'Add an ingredient name for this quantity.';
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const key = rowName.toLowerCase();
|
|
132
|
+
if (seen.has(key)) {
|
|
133
|
+
ingredientRows[index] = 'This ingredient is already listed.';
|
|
134
|
+
} else {
|
|
135
|
+
seen.set(key, index);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
if (Object.keys(ingredientRows).length > 0) errors.ingredientRows = ingredientRows;
|
|
139
|
+
|
|
140
|
+
/* ---- steps ---- */
|
|
141
|
+
const filledSteps = draft.steps.filter((step) => step.trim() !== '');
|
|
142
|
+
if (filledSteps.length < LIMITS.minSteps) {
|
|
143
|
+
errors.steps = 'Add at least one instruction step.';
|
|
144
|
+
} else if (filledSteps.length > LIMITS.maxSteps) {
|
|
145
|
+
errors.steps = `A recipe can hold up to ${LIMITS.maxSteps} steps.`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const stepRows: Record<number, string> = {};
|
|
149
|
+
draft.steps.forEach((step, index) => {
|
|
150
|
+
const trimmed = step.trim();
|
|
151
|
+
if (trimmed && trimmed.length < LIMITS.stepMin) {
|
|
152
|
+
stepRows[index] = `Describe this step in at least ${LIMITS.stepMin} characters.`;
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
if (Object.keys(stepRows).length > 0) errors.stepRows = stepRows;
|
|
156
|
+
|
|
157
|
+
/* ---- tags ---- */
|
|
158
|
+
if (draft.tags.length > LIMITS.maxTags) {
|
|
159
|
+
errors.tags = `Use at most ${LIMITS.maxTags} tags.`;
|
|
160
|
+
} else if (draft.tags.some((tag) => tag.length > LIMITS.tagMax)) {
|
|
161
|
+
errors.tags = `Keep each tag under ${LIMITS.tagMax} characters.`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* ---- notes ---- */
|
|
165
|
+
if (draft.notes.trim().length > LIMITS.notesMax) {
|
|
166
|
+
errors.notes = `Keep notes under ${LIMITS.notesMax} characters.`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return errors;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function isValid(errors: RecipeErrors): boolean {
|
|
173
|
+
return Object.keys(errors).length === 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Counts every problem, including per-row ones, for the summary banner. */
|
|
177
|
+
export function countErrors(errors: RecipeErrors): number {
|
|
178
|
+
let total = 0;
|
|
179
|
+
for (const [key, value] of Object.entries(errors)) {
|
|
180
|
+
if (key === 'ingredientRows' || key === 'stepRows') {
|
|
181
|
+
total += Object.keys(value as Record<number, string>).length;
|
|
182
|
+
} else if (value) {
|
|
183
|
+
total += 1;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return total;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export { parseOptionalCount };
|