mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
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:
parent
2af2a4d5c0
commit
0e0d48acec
6 changed files with 118 additions and 0 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue