plan: Flavor B — 🔮 forgotten gems pulls from Discover too
The suggestion panel now folds enriched-but-unimported Discover entries
into the same Hecate-driven pool as library recipes. Pinning a discover
suggestion auto-imports it to the household's Mealie library and adds
the resulting slug to cauldron_meal_picks.
- db: list_discover_eligible_for_group(mealie_group_id, limit=80) — joins
cauldron_discovered_recipes against cauldron_discover_imports to find
enriched rows no household in the caller's group has imported yet
- forge.suggest_recipes: accepts a per-entry `source` field; when any
entry is source='discover' the prompt gains an explicit hint so Hecate
treats discover entries as "something new to try" with a soft cap on
proportion (~half max unless library exhausted)
- /api/plan/suggest: builds a unified pool with prefixed IDs (lib:<slug>
vs disc:<id>) so library and discover entries can coexist in Sonnet's
validation map; decorates each suggestion with kind+image+meta_summary+
hecate_quip; discover entries also carry source_url and source_host
- /api/plan/suggest/pin: new dispatch on body.kind:
- kind=library (default — back-compat with existing Flavor A callers):
same as before
- kind=discover: looks up the discover row, short-circuits to cached
Mealie slug if THIS household already imported it, else
mealie.import_from_url() + record_discover_import() + add to picks.
Returns {kind, mealie_slug, imported, added_to_picks}
- plan.html: card render is kind-aware. Discover entries get a
"📬 from Discover · <host>" footer instead of last-planned recall, an
"import + pin" button label, and use data-* attributes for the click
payload so the JS doesn't need to template-interpolate slugs into
onclick handlers
This commit is contained in:
parent
b41c93e559
commit
8752fcd340
4 changed files with 285 additions and 35 deletions
|
|
@ -2338,6 +2338,46 @@ class DB:
|
|||
row["imported_at"] = row["imported_at"].isoformat()
|
||||
return row
|
||||
|
||||
def list_discover_eligible_for_group(
|
||||
self, *, mealie_group_id: str | None, limit: int = 100
|
||||
) -> list[dict]:
|
||||
"""Return enriched discover rows that no household in the given Mealie
|
||||
group has imported yet. Used by Flavor B's pool builder — these are
|
||||
the 'new gems' Hecate can suggest alongside library recipes.
|
||||
|
||||
If `mealie_group_id` is None we still return the unimported global
|
||||
list (no group context yet — first-boot edge case)."""
|
||||
with self.conn() as c, c.cursor() as cur:
|
||||
if mealie_group_id:
|
||||
cur.execute(
|
||||
"""SELECT d.id, d.slug, d.source_url, d.name, d.description,
|
||||
d.image_url, d.meta_json
|
||||
FROM cauldron_discovered_recipes d
|
||||
LEFT JOIN cauldron_discover_imports i
|
||||
ON i.discover_id = d.id
|
||||
LEFT JOIN cauldron_households h
|
||||
ON h.id = i.household_id
|
||||
AND h.mealie_group_id = %s
|
||||
WHERE d.status = 'enriched'
|
||||
AND h.id IS NULL
|
||||
ORDER BY d.scraped_at DESC
|
||||
LIMIT %s""",
|
||||
(mealie_group_id, int(limit)),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""SELECT d.id, d.slug, d.source_url, d.name, d.description,
|
||||
d.image_url, d.meta_json
|
||||
FROM cauldron_discovered_recipes d
|
||||
LEFT JOIN cauldron_discover_imports i ON i.discover_id = d.id
|
||||
WHERE d.status = 'enriched'
|
||||
AND i.discover_id IS NULL
|
||||
ORDER BY d.scraped_at DESC
|
||||
LIMIT %s""",
|
||||
(int(limit),),
|
||||
)
|
||||
return list(cur.fetchall() or [])
|
||||
|
||||
def get_discovered_recipe(self, discover_id: int) -> dict | None:
|
||||
with self.conn() as c, c.cursor() as cur:
|
||||
cur.execute(
|
||||
|
|
|
|||
|
|
@ -983,11 +983,19 @@ class Forge:
|
|||
if isinstance(fit, dict) and fit:
|
||||
fit_str = ",".join(f"{n}:{s}" for n, s in fit.items())
|
||||
extras.append(f"fit:{fit_str}")
|
||||
# Source hint — Flavor B passes "library" or "discover" so Hecate
|
||||
# can reason about whether an entry is already in the household's
|
||||
# Mealie library (free pick) vs scraped from the open web (would
|
||||
# need to be imported on pin).
|
||||
src = r.get("source")
|
||||
if src:
|
||||
extras.append(f"source:{src}")
|
||||
line = f"- {slug} | {name}"
|
||||
if extras:
|
||||
line += f" [{' · '.join(extras)}]"
|
||||
pool_lines.append(line)
|
||||
pool_block = "\n".join(pool_lines)
|
||||
has_discover = any(r.get("source") == "discover" for r in eligible_pool)
|
||||
|
||||
# Picker profile block (same shape as planner uses, abbreviated)
|
||||
profile_block = ""
|
||||
|
|
@ -1025,13 +1033,25 @@ class Forge:
|
|||
if in_plan_slugs:
|
||||
already_block = f"\n(For context: this week's plan already has {len(in_plan_slugs)} recipe(s). They're NOT in the pool above.)\n"
|
||||
|
||||
discover_hint = (
|
||||
"\nNOTE: some entries below have `source:discover` — those are "
|
||||
"RECIPES NEW TO THIS HOUSEHOLD, scraped from the open web and "
|
||||
"enriched but not yet imported to the family's Mealie library. "
|
||||
"Treat them as 'something new to try' — bias toward them when "
|
||||
"the family's history is heavy on the same handful of cuisines/"
|
||||
"proteins, but don't overdo it (max ~half the picks should be "
|
||||
"discover-source unless the library is exhausted). Pinning a "
|
||||
"discover entry will trigger an automatic import.\n"
|
||||
if has_discover else ""
|
||||
)
|
||||
prompt = (
|
||||
"You are Hecate, a Greek-mythology witch goddess of crossroads, "
|
||||
"herbs, and magic — and the family's meal planner. The household "
|
||||
"asked you to look through their recipe library and surface "
|
||||
"'forgotten gems': recipes that fit who they are but haven't "
|
||||
"been served recently (or ever).\n\n"
|
||||
f"This is for the week of {week_start}.\n\n"
|
||||
f"This is for the week of {week_start}.\n"
|
||||
f"{discover_hint}\n"
|
||||
f"ELIGIBLE POOL (already filtered: nothing seen in the last 90 days, "
|
||||
f"nothing already on this week's plan):\n{pool_block}\n"
|
||||
f"{profile_block}"
|
||||
|
|
|
|||
|
|
@ -1113,6 +1113,10 @@ def create_app() -> Flask:
|
|||
|
||||
eligible: list[dict] = []
|
||||
today = date.today()
|
||||
# Library entries get prefixed slugs ("lib:<slug>") so they can
|
||||
# coexist with discover entries ("disc:<id>") in the same Sonnet
|
||||
# pool without collision. forge.suggest_recipes only needs the
|
||||
# slug to be unique; the prefix is server-side bookkeeping.
|
||||
for r in rows:
|
||||
slug = r["slug"]
|
||||
if slug in in_plan_slugs:
|
||||
|
|
@ -1140,10 +1144,11 @@ def create_app() -> Flask:
|
|||
if any(contains.get(e) for e in excl_set):
|
||||
continue
|
||||
entry = {
|
||||
"slug": slug,
|
||||
"slug": f"lib:{slug}",
|
||||
"name": r["name"],
|
||||
"meta": m,
|
||||
"history": {"weeks_ago": weeks_ago} if weeks_ago is not None else {},
|
||||
"source": "library",
|
||||
}
|
||||
# Per-user fit (same scoring as planner)
|
||||
fit_scores: dict[str, int] = {}
|
||||
|
|
@ -1158,6 +1163,52 @@ def create_app() -> Flask:
|
|||
entry["fit"] = fit_scores
|
||||
eligible.append(entry)
|
||||
|
||||
# Flavor B — also pull from Discover for "things you've never had"
|
||||
# variety. Group-aware: skip rows ANY household in this group has
|
||||
# already imported (since group-shared read makes them visible to
|
||||
# everyone). Tag entries with source='discover' so Hecate's prompt
|
||||
# treats them differently and the pin endpoint dispatches to
|
||||
# import-then-pick.
|
||||
my_household = db.get_household(hid)
|
||||
my_group_id = (my_household or {}).get("mealie_group_id")
|
||||
discover_rows = db.list_discover_eligible_for_group(
|
||||
mealie_group_id=my_group_id, limit=80
|
||||
)
|
||||
discover_by_id: dict[int, dict] = {}
|
||||
for d in discover_rows:
|
||||
d_meta = d.get("meta_json")
|
||||
if isinstance(d_meta, str):
|
||||
try:
|
||||
d_meta = _json_loads(d_meta)
|
||||
except Exception:
|
||||
d_meta = None
|
||||
if not isinstance(d_meta, dict):
|
||||
continue # Hecate needs meta to score fit — skip un-enriched
|
||||
if excl_set:
|
||||
contains = d_meta.get("contains") or {}
|
||||
if any(contains.get(e) for e in excl_set):
|
||||
continue
|
||||
d_id = d["id"]
|
||||
discover_by_id[d_id] = {**d, "meta_json": d_meta}
|
||||
entry = {
|
||||
"slug": f"disc:{d_id}",
|
||||
"name": d.get("name") or d.get("slug") or f"discover-{d_id}",
|
||||
"meta": d_meta,
|
||||
"history": {}, # never-planned by definition
|
||||
"source": "discover",
|
||||
}
|
||||
fit_scores: dict[str, int] = {}
|
||||
for sub, prof in (picker_profiles or {}).items():
|
||||
if not isinstance(prof, dict):
|
||||
continue
|
||||
score = _compute_fit_score(d_meta, prof)
|
||||
if score > 0:
|
||||
nm = (prof.get("display_name") or sub).split("@")[0][:12]
|
||||
fit_scores[nm] = score
|
||||
if fit_scores:
|
||||
entry["fit"] = fit_scores
|
||||
eligible.append(entry)
|
||||
|
||||
if not eligible:
|
||||
return jsonify({
|
||||
"suggestions": [],
|
||||
|
|
@ -1188,42 +1239,141 @@ def create_app() -> Flask:
|
|||
return jsonify({"error": "forge_failed", "detail": str(e)}), 502
|
||||
|
||||
# Decorate with image_url + a tiny meta summary for the UI cards.
|
||||
# Look up image_url from the indexed recipe row (raw_json.image when present).
|
||||
# Each suggestion's `recipe_slug` is prefixed (lib: or disc:) — split
|
||||
# on the prefix to figure out where to source the display data.
|
||||
meta_summary_keys = ("cuisine", "complexity", "estimated_minutes", "primary_protein", "comfort_tier")
|
||||
rows_by_slug = {r["slug"]: r for r in rows}
|
||||
decorated = []
|
||||
for s in suggestions:
|
||||
slug = s["recipe_slug"]
|
||||
m = meta_by_slug.get(slug) or {}
|
||||
r = rows_by_slug.get(slug) or {}
|
||||
img = None
|
||||
raw = r.get("raw_json")
|
||||
if isinstance(raw, str):
|
||||
prefixed = s.get("recipe_slug") or ""
|
||||
if prefixed.startswith("disc:"):
|
||||
# Discover entry — image + meta come from cauldron_discovered_recipes
|
||||
try:
|
||||
raw = _json_loads(raw)
|
||||
except Exception:
|
||||
raw = None
|
||||
if isinstance(raw, dict):
|
||||
img = raw.get("image") or raw.get("imageUrl")
|
||||
s["image_url"] = img
|
||||
s["meta_summary"] = {k: m.get(k) for k in meta_summary_keys if m.get(k)}
|
||||
s["hecate_quip"] = m.get("hecate_quip") or ""
|
||||
h = history_by_slug.get(slug) or {}
|
||||
last_p = h.get("last_planned")
|
||||
s["last_planned"] = last_p.isoformat() if last_p is not None else None
|
||||
d_id = int(prefixed[5:])
|
||||
except ValueError:
|
||||
continue
|
||||
d = discover_by_id.get(d_id)
|
||||
if not d:
|
||||
continue
|
||||
d_meta = d.get("meta_json") or {}
|
||||
source_url = d.get("source_url") or ""
|
||||
source_host = ""
|
||||
if source_url:
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
source_host = urlparse(source_url).hostname or ""
|
||||
except Exception:
|
||||
source_host = ""
|
||||
decorated.append({
|
||||
"kind": "discover",
|
||||
"discover_id": d_id,
|
||||
"recipe_name": d.get("name") or s.get("recipe_name"),
|
||||
"fit_score": s.get("fit_score"),
|
||||
"reason": s.get("reason"),
|
||||
"image_url": d.get("image_url"),
|
||||
"source_url": source_url,
|
||||
"source_host": source_host,
|
||||
"meta_summary": {k: d_meta.get(k) for k in meta_summary_keys if d_meta.get(k)},
|
||||
"hecate_quip": d_meta.get("hecate_quip") or "",
|
||||
"last_planned": None,
|
||||
})
|
||||
else:
|
||||
# Library entry — prefixed as "lib:<slug>"; strip and look up.
|
||||
slug = prefixed[4:] if prefixed.startswith("lib:") else prefixed
|
||||
m = meta_by_slug.get(slug) or {}
|
||||
r = rows_by_slug.get(slug) or {}
|
||||
img = None
|
||||
raw = r.get("raw_json")
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
raw = _json_loads(raw)
|
||||
except Exception:
|
||||
raw = None
|
||||
if isinstance(raw, dict):
|
||||
img = raw.get("image") or raw.get("imageUrl")
|
||||
h = history_by_slug.get(slug) or {}
|
||||
last_p = h.get("last_planned")
|
||||
decorated.append({
|
||||
"kind": "library",
|
||||
"recipe_slug": slug,
|
||||
"recipe_name": s.get("recipe_name") or r.get("name") or slug,
|
||||
"fit_score": s.get("fit_score"),
|
||||
"reason": s.get("reason"),
|
||||
"image_url": img,
|
||||
"meta_summary": {k: m.get(k) for k in meta_summary_keys if m.get(k)},
|
||||
"hecate_quip": m.get("hecate_quip") or "",
|
||||
"last_planned": last_p.isoformat() if last_p is not None else None,
|
||||
})
|
||||
|
||||
return jsonify({"suggestions": suggestions})
|
||||
return jsonify({"suggestions": decorated})
|
||||
|
||||
@app.post("/api/plan/suggest/pin")
|
||||
@require_session
|
||||
def plan_suggest_pin():
|
||||
"""Pin a suggested recipe → adds to cauldron_meal_picks for the
|
||||
session user. The next /api/plan/generate will naturally pull it
|
||||
in as one of that user's picks. Body: {recipe_slug, recipe_name?}."""
|
||||
session user. Two kinds of suggestions:
|
||||
|
||||
- kind=library (default): {recipe_slug} — recipe must already be
|
||||
in the household's Mealie library (indexed). Direct pin.
|
||||
- kind=discover: {discover_id} — recipe lives in the discover
|
||||
corpus only. Imports via Mealie's create-from-url first, records
|
||||
the import to cauldron_discover_imports, then pins the resulting
|
||||
Mealie slug.
|
||||
|
||||
Back-compat: a body with only {recipe_slug} (no kind) is treated
|
||||
as kind=library so existing Flavor A callers keep working."""
|
||||
u = session["user"]
|
||||
hid = current_household_id()
|
||||
if not hid:
|
||||
return jsonify({"error": "no household"}), 409
|
||||
body = request.get_json(silent=True) or {}
|
||||
kind = (body.get("kind") or "library").strip().lower()
|
||||
|
||||
if kind == "discover":
|
||||
try:
|
||||
discover_id = int(body.get("discover_id"))
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "missing discover_id"}), 400
|
||||
row = db.get_discovered_recipe(discover_id)
|
||||
if not row:
|
||||
return jsonify({"error": "discover_not_found"}), 404
|
||||
if row.get("status") == "rejected":
|
||||
return jsonify({"error": "row_rejected"}), 409
|
||||
|
||||
# If THIS household has already imported this discover row we
|
||||
# short-circuit to its existing Mealie slug — no double-import,
|
||||
# no Mealie 'Cookies (1)' duplicate.
|
||||
existing = db.discover_imported_by_household(
|
||||
discover_id=discover_id, household_id=hid
|
||||
)
|
||||
if existing:
|
||||
new_slug = existing["mealie_slug"]
|
||||
else:
|
||||
client = current_user_mealie()
|
||||
if client is None:
|
||||
return jsonify({"error": "mealie_not_connected"}), 409
|
||||
try:
|
||||
new_slug = client.import_from_url(row["source_url"])
|
||||
except MealieError as e:
|
||||
return jsonify({"error": "mealie_import_failed", "detail": str(e)[:300]}), 502
|
||||
db.record_discover_import(
|
||||
discover_id=discover_id,
|
||||
household_id=hid,
|
||||
mealie_slug=new_slug,
|
||||
imported_by_sub=u["sub"],
|
||||
)
|
||||
|
||||
display_name = row.get("name") or new_slug
|
||||
added = db.add_meal_pick(u["sub"], new_slug, display_name)
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"kind": "discover",
|
||||
"mealie_slug": new_slug,
|
||||
"imported": existing is None,
|
||||
"added_to_picks": added,
|
||||
})
|
||||
|
||||
# kind = library (default)
|
||||
slug = (body.get("recipe_slug") or "").strip()
|
||||
if not slug:
|
||||
return jsonify({"error": "missing recipe_slug"}), 400
|
||||
|
|
@ -1233,7 +1383,12 @@ def create_app() -> Flask:
|
|||
if not idx:
|
||||
return jsonify({"error": "recipe_not_indexed"}), 404
|
||||
added = db.add_meal_pick(u["sub"], slug, idx.get("name") or slug)
|
||||
return jsonify({"ok": True, "added": added})
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"kind": "library",
|
||||
"recipe_slug": slug,
|
||||
"added_to_picks": added,
|
||||
})
|
||||
|
||||
@app.get("/list")
|
||||
@require_session
|
||||
|
|
|
|||
|
|
@ -468,11 +468,13 @@
|
|||
<section class="panel" id="suggest-panel">
|
||||
<div class="panel-head">
|
||||
<h2>forgotten gems</h2>
|
||||
<span class="ctx">recipes you haven't seen in 90+ days</span>
|
||||
<span class="ctx">your library + new finds from discover</span>
|
||||
</div>
|
||||
<p style="margin: 4px 0 12px 0; opacity: 0.8;">
|
||||
let hecate look through your library for recipes that fit who you are
|
||||
but haven't crossed your table recently.
|
||||
but haven't crossed your table recently — and pull in fresh enriched
|
||||
finds from /discover the family hasn't imported yet. pinning a discover
|
||||
pick auto-imports it to your mealie household.
|
||||
</p>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-purple" type="button" id="suggest-btn" onclick="askHecate(this, 3)">🔮 suggest 3 gems</button>
|
||||
|
|
@ -691,22 +693,43 @@ function _renderSuggestion(s) {
|
|||
if (meta.primary_protein) metaBits.push('protein: ' + _escapeHtml(meta.primary_protein));
|
||||
if (meta.comfort_tier) metaBits.push('comfort: ' + _escapeHtml(meta.comfort_tier));
|
||||
const metaLine = metaBits.length ? `<div class="ctx" style="margin: 4px 0;">${metaBits.join(' · ')}</div>` : '';
|
||||
const lastSeen = s.last_planned ? `last planned ${s.last_planned}` : 'never planned in this household';
|
||||
const isDiscover = s.kind === 'discover';
|
||||
const img = s.image_url ? `<img src="${_escapeHtml(s.image_url)}" alt="" style="width: 100%; max-height: 140px; object-fit: cover; border-radius: 6px; margin-bottom: 8px;">` : '';
|
||||
const quip = s.hecate_quip ? `<div style="font-style: italic; opacity: 0.85; margin: 6px 0;">"${_escapeHtml(s.hecate_quip)}"</div>` : '';
|
||||
// Source-aware footer line: for library entries, show last-planned recall;
|
||||
// for discover entries, show source host so the user knows where it came from.
|
||||
let footer;
|
||||
if (isDiscover) {
|
||||
const host = s.source_host || 'web';
|
||||
footer = `<div class="ctx" style="margin: 4px 0 8px 0; color: var(--purple-bright);">📬 from Discover · ${_escapeHtml(host)}</div>`;
|
||||
} else {
|
||||
const lastSeen = s.last_planned ? `last planned ${s.last_planned}` : 'never planned in this household';
|
||||
footer = `<div class="ctx" style="margin: 4px 0 8px 0;">${_escapeHtml(lastSeen)}</div>`;
|
||||
}
|
||||
// Identifier used for the pin click + DOM data attr. Library uses recipe_slug;
|
||||
// discover uses discover_id (passed back to the pin endpoint as kind='discover').
|
||||
const ident = isDiscover ? `disc-${s.discover_id}` : (s.recipe_slug || '');
|
||||
const pinAttr = isDiscover
|
||||
? `data-kind="discover" data-discover-id="${s.discover_id}"`
|
||||
: `data-kind="library" data-slug="${_escapeHtml(s.recipe_slug)}"`;
|
||||
// Discover entries don't have a Mealie page yet → no clickable name link.
|
||||
const nameHtml = isDiscover
|
||||
? `<span class="rname" style="font-weight: 600;">${_escapeHtml(s.recipe_name)}</span>`
|
||||
: `<a class="rname" href="/recipes/${_escapeHtml(s.recipe_slug)}" style="font-weight: 600;">${_escapeHtml(s.recipe_name)}</a>`;
|
||||
const pinLabel = isDiscover ? '📌 import + pin' : '📌 pin to picks';
|
||||
return `
|
||||
<div class="day-card" data-slug="${_escapeHtml(s.recipe_slug)}" style="margin-bottom: 12px;">
|
||||
<div class="day-card" data-ident="${_escapeHtml(ident)}" style="margin-bottom: 12px;">
|
||||
${img}
|
||||
<div style="display: flex; justify-content: space-between; align-items: baseline; gap: 8px;">
|
||||
<a class="rname" href="/recipes/${_escapeHtml(s.recipe_slug)}" style="font-weight: 600;">${_escapeHtml(s.recipe_name)}</a>
|
||||
${nameHtml}
|
||||
<span title="hecate's fit score (1-5)" style="font-size: 0.85em; opacity: 0.75; white-space: nowrap;">${fitDots}</span>
|
||||
</div>
|
||||
${metaLine}
|
||||
${quip}
|
||||
<div style="margin: 6px 0; opacity: 0.9;">${_escapeHtml(s.reason || '')}</div>
|
||||
<div class="ctx" style="margin: 4px 0 8px 0;">${_escapeHtml(lastSeen)}</div>
|
||||
${footer}
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-purple" type="button" onclick="pinSuggestion(this, '${_escapeHtml(s.recipe_slug)}')">📌 pin to picks</button>
|
||||
<button class="btn btn-purple" type="button" ${pinAttr} onclick="pinSuggestion(this)">${pinLabel}</button>
|
||||
<button class="btn" type="button" onclick="this.closest('.day-card').style.display='none'">✗ skip</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -744,19 +767,31 @@ async function askHecate(btn, count) {
|
|||
}
|
||||
}
|
||||
|
||||
async function pinSuggestion(btn, slug) {
|
||||
async function pinSuggestion(btn) {
|
||||
btn.disabled = true;
|
||||
const original = btn.textContent;
|
||||
btn.textContent = '…';
|
||||
const kind = btn.dataset.kind || 'library';
|
||||
let body;
|
||||
if (kind === 'discover') {
|
||||
btn.textContent = 'importing…';
|
||||
body = { kind: 'discover', discover_id: parseInt(btn.dataset.discoverId, 10) };
|
||||
} else {
|
||||
btn.textContent = '…';
|
||||
body = { kind: 'library', recipe_slug: btn.dataset.slug };
|
||||
}
|
||||
try {
|
||||
const r = await fetch('/api/plan/suggest/pin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ recipe_slug: slug }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(data.detail || data.error || r.status);
|
||||
btn.textContent = data.added ? '✓ pinned' : '✓ already pinned';
|
||||
if (data.kind === 'discover') {
|
||||
btn.textContent = data.imported ? '✓ imported & pinned' : '✓ already in library — pinned';
|
||||
} else {
|
||||
btn.textContent = data.added_to_picks ? '✓ pinned' : '✓ already pinned';
|
||||
}
|
||||
btn.classList.remove('btn-purple');
|
||||
// Leave the card visible so user can see it pinned, but disable further pinning
|
||||
} catch (e) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue