@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,170 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import RecipeGrid from '$components/RecipeGrid.svelte';
|
|
3
|
+
import type { RecipeSummary } from '$domain/recipe';
|
|
4
|
+
import WebComponent from '$lib/stencil/WebComponent.svelte';
|
|
5
|
+
import { favorites } from '$stores/favorites.svelte';
|
|
6
|
+
import { toasts } from '$stores/toast.svelte';
|
|
7
|
+
|
|
8
|
+
let query = $state('');
|
|
9
|
+
let activeCategory = $state('');
|
|
10
|
+
|
|
11
|
+
const all = $derived(favorites.all);
|
|
12
|
+
|
|
13
|
+
/** Category facets, derived from what is actually favorited. */
|
|
14
|
+
const categories = $derived.by(() => {
|
|
15
|
+
const tally = new Map<string, number>();
|
|
16
|
+
for (const recipe of all) {
|
|
17
|
+
tally.set(recipe.category, (tally.get(recipe.category) ?? 0) + 1);
|
|
18
|
+
}
|
|
19
|
+
return [...tally.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const visible = $derived.by<RecipeSummary[]>(() => {
|
|
23
|
+
const needle = query.trim().toLowerCase();
|
|
24
|
+
return all.filter((recipe) => {
|
|
25
|
+
if (activeCategory && recipe.category !== activeCategory) return false;
|
|
26
|
+
if (!needle) return true;
|
|
27
|
+
return recipe.name.toLowerCase().includes(needle) || recipe.area.toLowerCase().includes(needle);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
let confirmClear = $state(false);
|
|
32
|
+
|
|
33
|
+
function clearAll() {
|
|
34
|
+
const snapshot = favorites.all;
|
|
35
|
+
favorites.clear();
|
|
36
|
+
confirmClear = false;
|
|
37
|
+
toasts.push(`Cleared ${snapshot.length} ${snapshot.length === 1 ? 'favorite' : 'favorites'}.`, {
|
|
38
|
+
tone: 'info',
|
|
39
|
+
action: {
|
|
40
|
+
label: 'Undo',
|
|
41
|
+
run: () => {
|
|
42
|
+
// Re-add oldest first so the original ordering is preserved.
|
|
43
|
+
for (const recipe of [...snapshot].reverse()) favorites.add(recipe);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<svelte:head>
|
|
51
|
+
<title>Favorites — Mise</title>
|
|
52
|
+
</svelte:head>
|
|
53
|
+
|
|
54
|
+
<div class="page-head">
|
|
55
|
+
<div>
|
|
56
|
+
<p class="eyebrow">Saved</p>
|
|
57
|
+
<h1 class="page-title">Favorites</h1>
|
|
58
|
+
<p class="page-lede">
|
|
59
|
+
{#if favorites.count === 0}
|
|
60
|
+
Recipes you favorite are kept here, in this browser.
|
|
61
|
+
{:else}
|
|
62
|
+
{favorites.count} {favorites.count === 1 ? 'recipe' : 'recipes'} saved. Tap the heart on any card to remove it.
|
|
63
|
+
{/if}
|
|
64
|
+
</p>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
{#if favorites.count > 0}
|
|
68
|
+
<button class="btn btn-danger btn-sm" type="button" onclick={() => (confirmClear = true)}>Clear all</button>
|
|
69
|
+
{/if}
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
{#if favorites.count === 0}
|
|
73
|
+
<rk-empty-state icon="🤍" heading="No favorites yet" message="Browse the catalogue and tap the heart on any recipe to save it here.">
|
|
74
|
+
<a class="btn btn-primary" href="/">Discover recipes</a>
|
|
75
|
+
</rk-empty-state>
|
|
76
|
+
{:else}
|
|
77
|
+
<div class="controls">
|
|
78
|
+
<WebComponent
|
|
79
|
+
tag="rk-search-bar"
|
|
80
|
+
props={{ value: query, placeholder: 'Filter your favorites…', debounce: 180 }}
|
|
81
|
+
events={{ rkSearch: ((event: CustomEvent<string>) => (query = event.detail)) as (event: CustomEvent<never>) => void }}
|
|
82
|
+
/>
|
|
83
|
+
|
|
84
|
+
{#if categories.length > 1}
|
|
85
|
+
<div class="chips" role="group" aria-label="Filter by category">
|
|
86
|
+
<!-- Chips: `selected` down as a prop, `rkChipToggle` back up. -->
|
|
87
|
+
<WebComponent
|
|
88
|
+
tag="rk-chip"
|
|
89
|
+
props={{ label: 'All', value: '', selected: activeCategory === '', count: all.length, tone: 'primary' }}
|
|
90
|
+
events={{ rkChipToggle: (() => (activeCategory = '')) as (event: CustomEvent<never>) => void }}
|
|
91
|
+
/>
|
|
92
|
+
{#each categories as [name, count] (name)}
|
|
93
|
+
<WebComponent
|
|
94
|
+
tag="rk-chip"
|
|
95
|
+
props={{ label: name, value: name, selected: activeCategory === name, count, tone: 'primary' }}
|
|
96
|
+
events={{
|
|
97
|
+
rkChipToggle: ((event: CustomEvent<{ selected: boolean }>) => {
|
|
98
|
+
activeCategory = event.detail.selected ? name : '';
|
|
99
|
+
}) as (event: CustomEvent<never>) => void
|
|
100
|
+
}}
|
|
101
|
+
/>
|
|
102
|
+
{/each}
|
|
103
|
+
</div>
|
|
104
|
+
{/if}
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<div class="section">
|
|
108
|
+
<RecipeGrid recipes={visible}>
|
|
109
|
+
{#snippet empty()}
|
|
110
|
+
<rk-empty-state
|
|
111
|
+
bare
|
|
112
|
+
icon="🫙"
|
|
113
|
+
heading="Nothing matches that filter"
|
|
114
|
+
message="No favorite recipe matches the current search and category."
|
|
115
|
+
>
|
|
116
|
+
<button
|
|
117
|
+
class="btn"
|
|
118
|
+
type="button"
|
|
119
|
+
onclick={() => {
|
|
120
|
+
query = '';
|
|
121
|
+
activeCategory = '';
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
Reset filters
|
|
125
|
+
</button>
|
|
126
|
+
</rk-empty-state>
|
|
127
|
+
{/snippet}
|
|
128
|
+
</RecipeGrid>
|
|
129
|
+
</div>
|
|
130
|
+
{/if}
|
|
131
|
+
|
|
132
|
+
<!-- Confirm dialog is driven by the Stencil modal via the bridge component. -->
|
|
133
|
+
<WebComponent
|
|
134
|
+
tag="rk-modal"
|
|
135
|
+
props={{
|
|
136
|
+
open: confirmClear,
|
|
137
|
+
heading: 'Clear all favorites?',
|
|
138
|
+
subheading: `${favorites.count} ${favorites.count === 1 ? 'recipe' : 'recipes'} will be removed.`,
|
|
139
|
+
size: 'sm'
|
|
140
|
+
}}
|
|
141
|
+
events={{ rkModalClose: (() => (confirmClear = false)) as (event: CustomEvent<never>) => void }}
|
|
142
|
+
>
|
|
143
|
+
{#snippet children()}
|
|
144
|
+
<p>Your own recipes are not affected — only the list of favorites is cleared. You can undo this straight after.</p>
|
|
145
|
+
<div slot="footer" class="dialog-actions">
|
|
146
|
+
<button class="btn" type="button" onclick={() => (confirmClear = false)}>Cancel</button>
|
|
147
|
+
<button class="btn btn-danger" type="button" onclick={clearAll}>Clear favorites</button>
|
|
148
|
+
</div>
|
|
149
|
+
{/snippet}
|
|
150
|
+
</WebComponent>
|
|
151
|
+
|
|
152
|
+
<style>
|
|
153
|
+
.controls {
|
|
154
|
+
display: grid;
|
|
155
|
+
gap: 14px;
|
|
156
|
+
max-width: 720px;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.chips {
|
|
160
|
+
display: flex;
|
|
161
|
+
flex-wrap: wrap;
|
|
162
|
+
gap: 8px;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.dialog-actions {
|
|
166
|
+
display: flex;
|
|
167
|
+
gap: 10px;
|
|
168
|
+
justify-content: flex-end;
|
|
169
|
+
}
|
|
170
|
+
</style>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { listAreas, listCategories } from '$api/mealdb';
|
|
2
|
+
import type { LayoutLoad } from './$types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Category and cuisine lists power the create/edit selects. They are fetched
|
|
6
|
+
* once for the whole `/my-recipes` subtree rather than per page, and fall back
|
|
7
|
+
* to a built-in list so the form still works if the API is unreachable.
|
|
8
|
+
*/
|
|
9
|
+
const FALLBACK_CATEGORIES = [
|
|
10
|
+
'Beef',
|
|
11
|
+
'Breakfast',
|
|
12
|
+
'Chicken',
|
|
13
|
+
'Dessert',
|
|
14
|
+
'Goat',
|
|
15
|
+
'Lamb',
|
|
16
|
+
'Miscellaneous',
|
|
17
|
+
'Pasta',
|
|
18
|
+
'Pork',
|
|
19
|
+
'Seafood',
|
|
20
|
+
'Side',
|
|
21
|
+
'Starter',
|
|
22
|
+
'Vegan',
|
|
23
|
+
'Vegetarian'
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const FALLBACK_AREAS = [
|
|
27
|
+
'American',
|
|
28
|
+
'British',
|
|
29
|
+
'Chinese',
|
|
30
|
+
'French',
|
|
31
|
+
'Greek',
|
|
32
|
+
'Indian',
|
|
33
|
+
'Italian',
|
|
34
|
+
'Japanese',
|
|
35
|
+
'Mexican',
|
|
36
|
+
'Moroccan',
|
|
37
|
+
'Spanish',
|
|
38
|
+
'Thai',
|
|
39
|
+
'Turkish',
|
|
40
|
+
'Vietnamese'
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export const load: LayoutLoad = async ({ fetch }) => {
|
|
44
|
+
const [categories, areas] = await Promise.all([
|
|
45
|
+
listCategories(fetch)
|
|
46
|
+
.then((list) => list.map((category) => category.name))
|
|
47
|
+
.catch(() => FALLBACK_CATEGORIES),
|
|
48
|
+
listAreas(fetch).catch(() => FALLBACK_AREAS)
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
categories: categories.length > 0 ? categories : FALLBACK_CATEGORIES,
|
|
53
|
+
areas: areas.length > 0 ? areas : FALLBACK_AREAS
|
|
54
|
+
};
|
|
55
|
+
};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import ConfirmDialog from '$components/ConfirmDialog.svelte';
|
|
3
|
+
import RecipeGrid from '$components/RecipeGrid.svelte';
|
|
4
|
+
import { toSummary, type Recipe } from '$domain/recipe';
|
|
5
|
+
import WebComponent from '$lib/stencil/WebComponent.svelte';
|
|
6
|
+
import { myRecipes } from '$stores/myRecipes.svelte';
|
|
7
|
+
import { planner } from '$stores/planner.svelte';
|
|
8
|
+
import { toasts } from '$stores/toast.svelte';
|
|
9
|
+
|
|
10
|
+
let query = $state('');
|
|
11
|
+
let sort = $state<'recent' | 'az'>('recent');
|
|
12
|
+
|
|
13
|
+
const SORT_OPTIONS = [
|
|
14
|
+
{ label: 'Recently updated', value: 'recent' },
|
|
15
|
+
{ label: 'A–Z', value: 'az' }
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const visible = $derived.by(() => {
|
|
19
|
+
const needle = query.trim().toLowerCase();
|
|
20
|
+
const list = myRecipes.all.filter((recipe) => {
|
|
21
|
+
if (!needle) return true;
|
|
22
|
+
return (
|
|
23
|
+
recipe.name.toLowerCase().includes(needle) ||
|
|
24
|
+
recipe.category.toLowerCase().includes(needle) ||
|
|
25
|
+
recipe.area.toLowerCase().includes(needle) ||
|
|
26
|
+
recipe.tags.some((tag) => tag.toLowerCase().includes(needle)) ||
|
|
27
|
+
recipe.ingredients.some((row) => row.name.toLowerCase().includes(needle))
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return sort === 'az' ? [...list].sort((a, b) => a.name.localeCompare(b.name)) : list;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const summaries = $derived(visible.map(toSummary));
|
|
35
|
+
|
|
36
|
+
/* --------------------------------------------------------------- deleting */
|
|
37
|
+
|
|
38
|
+
let pendingDelete = $state<Recipe | null>(null);
|
|
39
|
+
|
|
40
|
+
/** How many planner slots the recipe occupies, so the warning is specific. */
|
|
41
|
+
const plannedSlots = $derived.by(() => {
|
|
42
|
+
if (!pendingDelete) return 0;
|
|
43
|
+
const id = pendingDelete.id;
|
|
44
|
+
return Object.keys(planner.archive).reduce((total, weekKey) => {
|
|
45
|
+
const week = planner.week(weekKey);
|
|
46
|
+
return (
|
|
47
|
+
total +
|
|
48
|
+
Object.values(week).reduce(
|
|
49
|
+
(dayTotal, day) => dayTotal + Object.values(day ?? {}).filter((meal) => meal?.recipeId === id).length,
|
|
50
|
+
0
|
|
51
|
+
)
|
|
52
|
+
);
|
|
53
|
+
}, 0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
function confirmDelete() {
|
|
57
|
+
if (!pendingDelete) return;
|
|
58
|
+
const removed = myRecipes.remove(pendingDelete.id);
|
|
59
|
+
pendingDelete = null;
|
|
60
|
+
if (!removed) return;
|
|
61
|
+
|
|
62
|
+
toasts.push(`Deleted “${removed.name}”.`, {
|
|
63
|
+
tone: 'info',
|
|
64
|
+
action: { label: 'Undo', run: () => myRecipes.restore(removed) }
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const deleteMessage = $derived.by(() => {
|
|
69
|
+
if (!pendingDelete) return '';
|
|
70
|
+
const base = `“${pendingDelete.name}” will be removed from your collection and your favorites.`;
|
|
71
|
+
if (plannedSlots === 0) return `${base} You can undo this straight after.`;
|
|
72
|
+
return `${base} It is also planned in ${plannedSlots} meal ${plannedSlots === 1 ? 'slot' : 'slots'}, which will be cleared. You can undo this straight after.`;
|
|
73
|
+
});
|
|
74
|
+
</script>
|
|
75
|
+
|
|
76
|
+
<svelte:head>
|
|
77
|
+
<title>My recipes — Mise</title>
|
|
78
|
+
</svelte:head>
|
|
79
|
+
|
|
80
|
+
<div class="page-head">
|
|
81
|
+
<div>
|
|
82
|
+
<p class="eyebrow">Your collection</p>
|
|
83
|
+
<h1 class="page-title">My recipes</h1>
|
|
84
|
+
<p class="page-lede">
|
|
85
|
+
{#if myRecipes.count === 0}
|
|
86
|
+
Add your own recipes to search, favorite and plan them alongside the catalogue.
|
|
87
|
+
{:else}
|
|
88
|
+
{myRecipes.count} {myRecipes.count === 1 ? 'recipe' : 'recipes'} you wrote. They appear in Discover and the planner too.
|
|
89
|
+
{/if}
|
|
90
|
+
</p>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<a class="btn btn-primary" href="/my-recipes/new">
|
|
94
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
|
|
95
|
+
<path d="M12 5v14M5 12h14" />
|
|
96
|
+
</svg>
|
|
97
|
+
New recipe
|
|
98
|
+
</a>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
{#if myRecipes.count === 0}
|
|
102
|
+
<rk-empty-state
|
|
103
|
+
icon="📝"
|
|
104
|
+
heading="Your collection is empty"
|
|
105
|
+
message="Write up a recipe once and it becomes searchable, favoritable and plannable just like the rest."
|
|
106
|
+
>
|
|
107
|
+
<a class="btn btn-primary" href="/my-recipes/new">Add your first recipe</a>
|
|
108
|
+
</rk-empty-state>
|
|
109
|
+
{:else}
|
|
110
|
+
<div class="controls">
|
|
111
|
+
<WebComponent
|
|
112
|
+
tag="rk-search-bar"
|
|
113
|
+
props={{ value: query, placeholder: 'Search your recipes, tags or ingredients…', debounce: 180 }}
|
|
114
|
+
events={{ rkSearch: ((event: CustomEvent<string>) => (query = event.detail)) as (event: CustomEvent<never>) => void }}
|
|
115
|
+
/>
|
|
116
|
+
|
|
117
|
+
<WebComponent
|
|
118
|
+
tag="rk-segmented-control"
|
|
119
|
+
props={{ options: SORT_OPTIONS, value: sort, label: 'Sort your recipes' }}
|
|
120
|
+
events={{ rkSegmentChange: ((event: CustomEvent<string>) => (sort = event.detail as 'recent' | 'az')) as (event: CustomEvent<never>) => void }}
|
|
121
|
+
/>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<div class="section">
|
|
125
|
+
<RecipeGrid recipes={summaries}>
|
|
126
|
+
{#snippet actions(recipe)}
|
|
127
|
+
<div slot="actions" class="card-actions">
|
|
128
|
+
<a class="btn btn-sm" href={`/my-recipes/${recipe.id}/edit`}>Edit</a>
|
|
129
|
+
<button
|
|
130
|
+
class="btn btn-sm btn-danger"
|
|
131
|
+
type="button"
|
|
132
|
+
onclick={() => (pendingDelete = myRecipes.get(recipe.id) ?? null)}
|
|
133
|
+
>
|
|
134
|
+
Delete
|
|
135
|
+
</button>
|
|
136
|
+
</div>
|
|
137
|
+
{/snippet}
|
|
138
|
+
|
|
139
|
+
{#snippet empty()}
|
|
140
|
+
<rk-empty-state bare icon="🫙" heading="No matches" message={`Nothing in your collection matches “${query}”.`}>
|
|
141
|
+
<button class="btn" type="button" onclick={() => (query = '')}>Clear search</button>
|
|
142
|
+
</rk-empty-state>
|
|
143
|
+
{/snippet}
|
|
144
|
+
</RecipeGrid>
|
|
145
|
+
</div>
|
|
146
|
+
{/if}
|
|
147
|
+
|
|
148
|
+
<ConfirmDialog
|
|
149
|
+
open={pendingDelete !== null}
|
|
150
|
+
heading="Delete this recipe?"
|
|
151
|
+
message={deleteMessage}
|
|
152
|
+
confirmLabel="Delete recipe"
|
|
153
|
+
destructive
|
|
154
|
+
onconfirm={confirmDelete}
|
|
155
|
+
oncancel={() => (pendingDelete = null)}
|
|
156
|
+
/>
|
|
157
|
+
|
|
158
|
+
<style>
|
|
159
|
+
.controls {
|
|
160
|
+
display: flex;
|
|
161
|
+
flex-wrap: wrap;
|
|
162
|
+
align-items: center;
|
|
163
|
+
gap: 14px;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.controls :global(rk-search-bar) {
|
|
167
|
+
flex: 1 1 320px;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.card-actions {
|
|
171
|
+
display: flex;
|
|
172
|
+
gap: 8px;
|
|
173
|
+
width: 100%;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.card-actions > * {
|
|
177
|
+
flex: 1 1 0;
|
|
178
|
+
justify-content: center;
|
|
179
|
+
}
|
|
180
|
+
</style>
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { goto } from '$app/navigation';
|
|
3
|
+
import { page } from '$app/state';
|
|
4
|
+
import RecipeForm from '$components/RecipeForm.svelte';
|
|
5
|
+
import type { Recipe } from '$domain/recipe';
|
|
6
|
+
import { myRecipes } from '$stores/myRecipes.svelte';
|
|
7
|
+
import { toasts } from '$stores/toast.svelte';
|
|
8
|
+
import type { LayoutData } from '../../$types';
|
|
9
|
+
|
|
10
|
+
let { data }: { data: LayoutData } = $props();
|
|
11
|
+
|
|
12
|
+
const id = $derived(page.params.id ?? '');
|
|
13
|
+
const recipe = $derived<Recipe | undefined>(myRecipes.get(id));
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The store only exists in the browser, so during SSR (and the first render
|
|
17
|
+
* before hydration) `recipe` is undefined. `hydrated` keeps the page from
|
|
18
|
+
* flashing "not found" in that window.
|
|
19
|
+
*/
|
|
20
|
+
let hydrated = $state(false);
|
|
21
|
+
$effect(() => {
|
|
22
|
+
hydrated = true;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function handleSubmit(payload: Omit<Recipe, 'id' | 'origin' | 'createdAt' | 'updatedAt'>): string | void {
|
|
26
|
+
if (myRecipes.nameTaken(payload.name, id)) {
|
|
27
|
+
return `You already have another recipe called “${payload.name}”.`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const updated = myRecipes.update(id, payload);
|
|
31
|
+
if (!updated) return 'That recipe no longer exists.';
|
|
32
|
+
|
|
33
|
+
toasts.success(`Saved changes to “${updated.name}”.`);
|
|
34
|
+
void goto(`/recipes/${updated.id}`);
|
|
35
|
+
}
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<svelte:head>
|
|
39
|
+
<title>{recipe ? `Edit ${recipe.name} — Mise` : 'Edit recipe — Mise'}</title>
|
|
40
|
+
</svelte:head>
|
|
41
|
+
|
|
42
|
+
{#if !recipe}
|
|
43
|
+
{#if hydrated}
|
|
44
|
+
<rk-empty-state
|
|
45
|
+
icon="🔎"
|
|
46
|
+
heading="Recipe not found"
|
|
47
|
+
message="Only recipes you created in this browser can be edited. This one may have been deleted."
|
|
48
|
+
>
|
|
49
|
+
<a class="btn btn-primary" href="/my-recipes">Back to my recipes</a>
|
|
50
|
+
</rk-empty-state>
|
|
51
|
+
{:else}
|
|
52
|
+
<p class="muted">Loading…</p>
|
|
53
|
+
{/if}
|
|
54
|
+
{:else}
|
|
55
|
+
<nav class="crumbs small" aria-label="Breadcrumb">
|
|
56
|
+
<a href="/my-recipes">My recipes</a>
|
|
57
|
+
<span aria-hidden="true">/</span>
|
|
58
|
+
<a href={`/recipes/${recipe.id}`}>{recipe.name}</a>
|
|
59
|
+
<span aria-hidden="true">/</span>
|
|
60
|
+
<span class="muted">Edit</span>
|
|
61
|
+
</nav>
|
|
62
|
+
|
|
63
|
+
<div class="page-head">
|
|
64
|
+
<div>
|
|
65
|
+
<p class="eyebrow">Edit</p>
|
|
66
|
+
<h1 class="page-title">{recipe.name}</h1>
|
|
67
|
+
<p class="page-lede">
|
|
68
|
+
Changes flow through to your favorites and every meal plan this recipe appears in.
|
|
69
|
+
{#if recipe.updatedAt}
|
|
70
|
+
Last saved {new Date(recipe.updatedAt).toLocaleString()}.
|
|
71
|
+
{/if}
|
|
72
|
+
</p>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<RecipeForm
|
|
77
|
+
categories={data.categories}
|
|
78
|
+
areas={data.areas}
|
|
79
|
+
{recipe}
|
|
80
|
+
submitLabel="Save changes"
|
|
81
|
+
onsubmit={handleSubmit}
|
|
82
|
+
oncancel={() => goto(`/recipes/${recipe.id}`)}
|
|
83
|
+
/>
|
|
84
|
+
{/if}
|
|
85
|
+
|
|
86
|
+
<style>
|
|
87
|
+
.crumbs {
|
|
88
|
+
display: flex;
|
|
89
|
+
flex-wrap: wrap;
|
|
90
|
+
align-items: center;
|
|
91
|
+
gap: 8px;
|
|
92
|
+
margin-bottom: 18px;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.crumbs a {
|
|
96
|
+
color: var(--rk-ink-2);
|
|
97
|
+
text-decoration: none;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.crumbs a:hover {
|
|
101
|
+
color: var(--rk-primary);
|
|
102
|
+
text-decoration: underline;
|
|
103
|
+
}
|
|
104
|
+
</style>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { goto } from '$app/navigation';
|
|
3
|
+
import RecipeForm from '$components/RecipeForm.svelte';
|
|
4
|
+
import type { Recipe } from '$domain/recipe';
|
|
5
|
+
import { myRecipes } from '$stores/myRecipes.svelte';
|
|
6
|
+
import { toasts } from '$stores/toast.svelte';
|
|
7
|
+
import type { LayoutData } from '../$types';
|
|
8
|
+
|
|
9
|
+
let { data }: { data: LayoutData } = $props();
|
|
10
|
+
|
|
11
|
+
function handleSubmit(payload: Omit<Recipe, 'id' | 'origin' | 'createdAt' | 'updatedAt'>): string | void {
|
|
12
|
+
// Duplicate titles are checked here rather than in `validateDraft`, because
|
|
13
|
+
// the rule depends on the store rather than the shape of the input.
|
|
14
|
+
if (myRecipes.nameTaken(payload.name)) {
|
|
15
|
+
return `You already have a recipe called “${payload.name}”. Pick a different name.`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const created = myRecipes.create(payload);
|
|
19
|
+
toasts.success(`Added “${created.name}” to your collection.`);
|
|
20
|
+
void goto(`/recipes/${created.id}`);
|
|
21
|
+
}
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<svelte:head>
|
|
25
|
+
<title>New recipe — Mise</title>
|
|
26
|
+
</svelte:head>
|
|
27
|
+
|
|
28
|
+
<nav class="crumbs small" aria-label="Breadcrumb">
|
|
29
|
+
<a href="/my-recipes">My recipes</a>
|
|
30
|
+
<span aria-hidden="true">/</span>
|
|
31
|
+
<span class="muted">New</span>
|
|
32
|
+
</nav>
|
|
33
|
+
|
|
34
|
+
<div class="page-head">
|
|
35
|
+
<div>
|
|
36
|
+
<p class="eyebrow">Add</p>
|
|
37
|
+
<h1 class="page-title">New recipe</h1>
|
|
38
|
+
<p class="page-lede">Everything except the image, time, servings, tags and notes is required.</p>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<RecipeForm
|
|
43
|
+
categories={data.categories}
|
|
44
|
+
areas={data.areas}
|
|
45
|
+
submitLabel="Add recipe"
|
|
46
|
+
onsubmit={handleSubmit}
|
|
47
|
+
oncancel={() => goto('/my-recipes')}
|
|
48
|
+
/>
|
|
49
|
+
|
|
50
|
+
<style>
|
|
51
|
+
.crumbs {
|
|
52
|
+
display: flex;
|
|
53
|
+
align-items: center;
|
|
54
|
+
gap: 8px;
|
|
55
|
+
margin-bottom: 18px;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.crumbs a {
|
|
59
|
+
color: var(--rk-ink-2);
|
|
60
|
+
text-decoration: none;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.crumbs a:hover {
|
|
64
|
+
color: var(--rk-primary);
|
|
65
|
+
text-decoration: underline;
|
|
66
|
+
}
|
|
67
|
+
</style>
|