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;