From 0e0d48acecec21d2170a26e059fc17a8f7cd7ace Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 24 Apr 2026 14:04:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(recipes):=20M5.b=20=E2=80=94=20recipes=20a?= =?UTF-8?q?dopt=20the=20unified=20visibility=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventh consumer of @mana/shared-privacy. Recipes now carry a VisibilityLevel; the recipes.recipes embed powers "my cookbook" / "tested recipes" sections on the owner's website. Changes: - recipes/types: visibility + unlistedToken + visibilityChangedAt + visibilityChangedBy on LocalRecipe; Recipe (UI) requires visibility - recipes/queries: toRecipe forwards visibility with 'space' fallback - recipes/stores/recipes: createRecipe stamps defaultVisibilityFor(activeSpace.type); duplicateRecipe resets to the space default (copies don't inherit public status — same rule as picture boards); new setVisibility(id, level) emits cross-module VisibilityChanged - recipes/ListView: as the first row of the detail-panel when a card is expanded. Recipes has no dedicated detail route so inline-expand is the canonical surface website embed: - website-blocks/moduleEmbed/schema: 'recipes.recipes' added to EmbedSourceSchema - website/embeds: resolveRecipes gates hard on canEmbedOnWebsite, optional isFavorite + tagIds filters, favourites-first then newest, inlines { title, subtitle ('30 Min · 4 Port.'), imageUrl }. Ingredients + steps + internal tag labels stay out of the snapshot — the embed is a teaser; full recipes are a later M8 unlisted-page feature. Verified: - pnpm check (web): 7450 files, 0 errors Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/lib/modules/recipes/ListView.svelte | 8 +++ .../web/src/lib/modules/recipes/queries.ts | 1 + .../modules/recipes/stores/recipes.svelte.ts | 48 +++++++++++++++++ .../apps/web/src/lib/modules/recipes/types.ts | 6 +++ .../web/src/lib/modules/website/embeds.ts | 54 +++++++++++++++++++ .../website-blocks/src/moduleEmbed/schema.ts | 1 + 6 files changed, 118 insertions(+) diff --git a/apps/mana/apps/web/src/lib/modules/recipes/ListView.svelte b/apps/mana/apps/web/src/lib/modules/recipes/ListView.svelte index 91e9babd4..3d8041a26 100644 --- a/apps/mana/apps/web/src/lib/modules/recipes/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/recipes/ListView.svelte @@ -25,6 +25,7 @@ import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui'; import { useItemContextMenu } from '$lib/data/item-context-menu.svelte'; import { Trash, Heart, Copy, Star } from '@mana/shared-icons'; + import { VisibilityPicker } from '@mana/shared-privacy'; let recipes$ = useAllRecipes(); let recipes = $derived(recipes$.value); @@ -260,6 +261,13 @@ {#if expandedId === recipe.id}
+
+
Sichtbarkeit
+ recipesStore.setVisibility(recipe.id, next)} + /> +
{#if recipe.ingredients.length > 0}
diff --git a/apps/mana/apps/web/src/lib/modules/recipes/queries.ts b/apps/mana/apps/web/src/lib/modules/recipes/queries.ts index d01c7e750..00eb25d77 100644 --- a/apps/mana/apps/web/src/lib/modules/recipes/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/recipes/queries.ts @@ -27,6 +27,7 @@ export function toRecipe(local: LocalRecipe): Recipe { photoMediaId: local.photoMediaId ?? null, photoUrl: local.photoUrl ?? null, photoThumbnailUrl: local.photoThumbnailUrl ?? null, + visibility: local.visibility ?? 'space', createdAt: local.createdAt ?? now, updatedAt: local.updatedAt ?? now, }; diff --git a/apps/mana/apps/web/src/lib/modules/recipes/stores/recipes.svelte.ts b/apps/mana/apps/web/src/lib/modules/recipes/stores/recipes.svelte.ts index 907de7f94..bf940359c 100644 --- a/apps/mana/apps/web/src/lib/modules/recipes/stores/recipes.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/recipes/stores/recipes.svelte.ts @@ -6,6 +6,13 @@ import { encryptRecord } from '$lib/data/crypto'; import { emitDomainEvent } from '$lib/data/events'; +import { getActiveSpace } from '$lib/data/scope'; +import { getEffectiveUserId } from '$lib/data/current-user'; +import { + defaultVisibilityFor, + generateUnlistedToken, + type VisibilityLevel, +} from '@mana/shared-privacy'; import { recipeTable } from '../collections'; import { toRecipe } from '../queries'; import type { LocalRecipe, Difficulty, Ingredient } from '../types'; @@ -37,6 +44,7 @@ export const recipesStore = { photoMediaId: null, photoUrl: null, photoThumbnailUrl: null, + visibility: defaultVisibilityFor(getActiveSpace()?.type), }; const snapshot = toRecipe({ ...newLocal }); await encryptRecord('recipes', newLocal); @@ -93,6 +101,39 @@ export const recipesStore = { }); }, + /** + * Flip the recipe's visibility. Typical use-case: mark tested + * recipes 'public' so they land in the recipes.recipes embed on + * the owner's website. + */ + async setVisibility(id: string, next: VisibilityLevel) { + const existing = await recipeTable.get(id); + if (!existing) throw new Error(`Recipe ${id} not found`); + const before: VisibilityLevel = existing.visibility ?? 'space'; + if (before === next) return; + + const now = new Date().toISOString(); + const patch: Partial = { + visibility: next, + visibilityChangedAt: now, + visibilityChangedBy: getEffectiveUserId(), + updatedAt: now, + }; + if (next === 'unlisted' && !existing.unlistedToken) { + patch.unlistedToken = generateUnlistedToken(); + } else if (next !== 'unlisted' && existing.unlistedToken) { + patch.unlistedToken = undefined; + } + await recipeTable.update(id, patch); + + emitDomainEvent('VisibilityChanged', 'recipes', 'recipes', id, { + recordId: id, + collection: 'recipes', + before, + after: next, + }); + }, + async duplicateRecipe(id: string) { const existing = await recipeTable.get(id); if (!existing) return null; @@ -102,6 +143,13 @@ export const recipesStore = { id: crypto.randomUUID(), title: `Kopie von ${existing.title}`, isFavorite: false, + // Duplicate resets to the space default; copies should not + // inherit a public flag from the original (same rule as picture + // boards — makes sharing explicit, not transitive). + visibility: defaultVisibilityFor(getActiveSpace()?.type), + visibilityChangedAt: undefined, + visibilityChangedBy: undefined, + unlistedToken: undefined, createdAt: undefined, updatedAt: undefined, deletedAt: undefined, diff --git a/apps/mana/apps/web/src/lib/modules/recipes/types.ts b/apps/mana/apps/web/src/lib/modules/recipes/types.ts index 6b17d35ac..f565b6581 100644 --- a/apps/mana/apps/web/src/lib/modules/recipes/types.ts +++ b/apps/mana/apps/web/src/lib/modules/recipes/types.ts @@ -5,6 +5,7 @@ */ import type { BaseRecord } from '@mana/local-store'; +import type { VisibilityLevel } from '@mana/shared-privacy'; // ─── Subtypes ─────────────────────────────────────────── @@ -32,6 +33,10 @@ export interface LocalRecipe extends BaseRecord { photoMediaId: string | null; photoUrl: string | null; photoThumbnailUrl: string | null; + visibility?: VisibilityLevel; + visibilityChangedAt?: string; + visibilityChangedBy?: string; + unlistedToken?: string; } // ─── Domain Type ──────────────────────────────────────── @@ -51,6 +56,7 @@ export interface Recipe { photoMediaId: string | null; photoUrl: string | null; photoThumbnailUrl: string | null; + visibility: VisibilityLevel; createdAt: string; updatedAt: string; } diff --git a/apps/mana/apps/web/src/lib/modules/website/embeds.ts b/apps/mana/apps/web/src/lib/modules/website/embeds.ts index fde9e1ad4..61b0dfd03 100644 --- a/apps/mana/apps/web/src/lib/modules/website/embeds.ts +++ b/apps/mana/apps/web/src/lib/modules/website/embeds.ts @@ -27,6 +27,7 @@ import type { LocalTask } from '$lib/modules/todo/types'; import type { LocalTaskTag } from '$lib/modules/todo/types'; import type { LocalGoal } from '$lib/companion/goals/types'; import type { LocalPlace } from '$lib/modules/places/types'; +import type { LocalRecipe } from '$lib/modules/recipes/types'; import type { LocalTimeBlock } from '$lib/data/time-blocks/types'; export interface ResolvedEmbed { @@ -59,6 +60,9 @@ export async function resolveEmbed(props: ModuleEmbedProps): Promise { subtitle: p.address ?? undefined, })); } + +/** + * Recipes: "my tested recipes" / "cookbook". Hard-gated on + * canEmbedOnWebsite. + * + * Whitelist (plan §2): title + description + photo URL + compact + * time/difficulty line. Ingredient list, cooking steps, and internal + * tags stay out of the snapshot — the public embed is a teaser, not + * the full recipe. A future extension could surface the full recipe + * on a dedicated /s//recipes/ page behind an unlisted token, + * but that's M8 scope. + */ +async function resolveRecipes(props: ModuleEmbedProps): Promise { + let recipes = await db.table('recipes').toArray(); + recipes = recipes.filter((r) => !r.deletedAt && canEmbedOnWebsite(r.visibility ?? 'private')); + + if (props.filter?.isFavorite === true) { + recipes = recipes.filter((r) => r.isFavorite === true); + } + if (props.filter?.tagIds?.length) { + const wanted = new Set(props.filter.tagIds); + recipes = recipes.filter((r) => (r.tags ?? []).some((t) => wanted.has(t))); + } + + const decrypted = (await decryptRecords('recipes', recipes)) as LocalRecipe[]; + + // Favourites first, then newest. + decrypted.sort((a, b) => { + const favA = a.isFavorite ? 0 : 1; + const favB = b.isFavorite ? 0 : 1; + if (favA !== favB) return favA - favB; + return (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''); + }); + + return decrypted.map((r) => { + const total = (r.prepTimeMin ?? 0) + (r.cookTimeMin ?? 0) || null; + const timeLabel = + total !== null && total > 0 + ? total < 60 + ? `${total} Min` + : `${Math.floor(total / 60)}h ${total % 60}m` + : null; + const parts = [timeLabel, `${r.servings} Port.`].filter((x): x is string => Boolean(x)); + return { + title: r.title, + subtitle: parts.join(' · ') || undefined, + imageUrl: r.photoThumbnailUrl ?? r.photoUrl ?? undefined, + }; + }); +} diff --git a/packages/website-blocks/src/moduleEmbed/schema.ts b/packages/website-blocks/src/moduleEmbed/schema.ts index 214b2e246..2473de5dd 100644 --- a/packages/website-blocks/src/moduleEmbed/schema.ts +++ b/packages/website-blocks/src/moduleEmbed/schema.ts @@ -33,6 +33,7 @@ export const EmbedSourceSchema = z.enum([ 'todo.tasks', 'goals.goals', 'places.places', + 'recipes.recipes', ]); export type EmbedSource = z.infer;