feat(recipes): M5.b — recipes adopt the unified visibility system

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: <VisibilityPicker> 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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-24 14:04:14 +02:00
parent 2af2a4d5c0
commit 0e0d48acec
6 changed files with 118 additions and 0 deletions

View file

@ -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}
<div class="detail-panel">
<div class="detail-section">
<div class="detail-heading">Sichtbarkeit</div>
<VisibilityPicker
level={recipe.visibility ?? 'private'}
onChange={(next) => recipesStore.setVisibility(recipe.id, next)}
/>
</div>
{#if recipe.ingredients.length > 0}
<div class="detail-section">
<div class="detail-heading">

View file

@ -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,
};

View file

@ -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<LocalRecipe> = {
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,

View file

@ -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;
}

View file

@ -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<ResolvedEmb
case 'places.places':
items = await resolvePlaces(props);
break;
case 'recipes.recipes':
items = await resolveRecipes(props);
break;
default:
return {
items: [],
@ -399,3 +403,53 @@ async function resolvePlaces(props: ModuleEmbedProps): Promise<EmbedItem[]> {
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/<slug>/recipes/<id> page behind an unlisted token,
* but that's M8 scope.
*/
async function resolveRecipes(props: ModuleEmbedProps): Promise<EmbedItem[]> {
let recipes = await db.table<LocalRecipe>('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,
};
});
}

View file

@ -33,6 +33,7 @@ export const EmbedSourceSchema = z.enum([
'todo.tasks',
'goals.goals',
'places.places',
'recipes.recipes',
]);
export type EmbedSource = z.infer<typeof EmbedSourceSchema>;