diff --git a/app/api/endpoints/saved_recipes.py b/app/api/endpoints/saved_recipes.py
index c0a2efa..8f9616e 100644
--- a/app/api/endpoints/saved_recipes.py
+++ b/app/api/endpoints/saved_recipes.py
@@ -35,7 +35,7 @@ def _to_summary(row: dict, store: Store) -> SavedRecipeSummary:
return SavedRecipeSummary(
id=row["id"],
recipe_id=row["recipe_id"],
- title=row.get("title", ""),
+ title=row.get("title") or "",
saved_at=row["saved_at"],
notes=row.get("notes"),
rating=row.get("rating"),
@@ -104,8 +104,10 @@ async def list_saved_recipes(
async def list_collections(
session: CloudUser = Depends(get_session),
) -> list[CollectionSummary]:
+ # Free users can list (they'll always have zero — creating requires Paid).
+ # Returning 403 here breaks savedStore.load() via Promise.all for non-Paid users.
if not can_use("recipe_collections", session.tier):
- raise HTTPException(status_code=403, detail="Collections require Paid tier.")
+ return []
rows = await asyncio.to_thread(
_in_thread, session.db, lambda s: s.get_collections()
)
diff --git a/app/db/migrations/039_saved_recipes_drop_fk.sql b/app/db/migrations/039_saved_recipes_drop_fk.sql
new file mode 100644
index 0000000..fa03e49
--- /dev/null
+++ b/app/db/migrations/039_saved_recipes_drop_fk.sql
@@ -0,0 +1,31 @@
+-- Migration 039: Drop FK constraint on saved_recipes.recipe_id.
+--
+-- In cloud mode the recipe corpus is ATTACHed as a separate database.
+-- SQLite FK constraints only resolve against the `main` schema, so
+-- `REFERENCES recipes(id)` was always failing for cloud saves (the
+-- main.recipes table is empty; all data lives in corpus.recipes).
+-- The corpus is read-only and never modified by the app, so cascade-on-delete
+-- is meaningless anyway. Remove the constraint without changing any data.
+
+PRAGMA foreign_keys = OFF;
+
+CREATE TABLE saved_recipes_new (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ recipe_id INTEGER NOT NULL,
+ saved_at TEXT NOT NULL DEFAULT (datetime('now')),
+ notes TEXT,
+ rating INTEGER CHECK (rating IS NULL OR (rating >= 0 AND rating <= 5)),
+ style_tags TEXT NOT NULL DEFAULT '[]',
+ UNIQUE (recipe_id)
+);
+
+INSERT INTO saved_recipes_new SELECT * FROM saved_recipes;
+
+DROP TABLE saved_recipes;
+
+ALTER TABLE saved_recipes_new RENAME TO saved_recipes;
+
+CREATE INDEX IF NOT EXISTS idx_saved_recipes_saved_at ON saved_recipes (saved_at DESC);
+CREATE INDEX IF NOT EXISTS idx_saved_recipes_rating ON saved_recipes (rating);
+
+PRAGMA foreign_keys = ON;
diff --git a/frontend/src/components/RecipesView.vue b/frontend/src/components/RecipesView.vue
index 919e13b..f139746 100644
--- a/frontend/src/components/RecipesView.vue
+++ b/frontend/src/components/RecipesView.vue
@@ -513,12 +513,12 @@
~{{ recipe.estimated_time_min }}m
Level {{ recipe.level }}
Wildcard
-
+ :class="['btn-icon', 'btn-bookmark', { active: savedStore.isSaved(recipe.id) }]"
+ :aria-label="savedStore.isSaved(recipe.id) ? 'Saved: ' + recipe.title : recipe.title"
+ :title="savedStore.isSaved(recipe.id) ? 'Saved' : 'Not saved'"
+ >{{ savedStore.isSaved(recipe.id) ? '★' : '☆' }}