From 95e76edaeaad8650413eacc40fdcf5b6af935b2e Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sat, 25 Apr 2026 23:31:30 -0700 Subject: [PATCH] feat(community): complete Layer A subcategory tagging (#118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RecipeBrowserPanel: fix onTagSearchInput using '_all' domain slug (backend validates domain — was silently returning empty results) - RecipeDetailPanel: fetch and display accepted community category tags on recipe open; accepted tags shown with accent chip + checkmark, pending tags shown in muted style - browserAPI.listRecipeTags() was already in api.ts but not consumed — now wired into RecipeDetailPanel onMounted as a background fetch --- .../src/components/RecipeBrowserPanel.vue | 6 +- frontend/src/components/RecipeDetailPanel.vue | 64 ++++++++++++++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/RecipeBrowserPanel.vue b/frontend/src/components/RecipeBrowserPanel.vue index e785d5b..288310f 100644 --- a/frontend/src/components/RecipeBrowserPanel.vue +++ b/frontend/src/components/RecipeBrowserPanel.vue @@ -527,8 +527,10 @@ function onTagSearchInput() { tagSearchDebounce = setTimeout(async () => { tagModal.value.searching = true try { - // Re-use the browser API: browse all recipes filtered by title substring - const res = await browserAPI.browse('_all', '_all', { page: 1, q }) + // Use the first available domain with category=_all to search all recipes by title. + // Domain must be a real domain slug — '_all' is not valid at the browse endpoint. + const searchDomain = domains.value[0]?.id ?? 'cuisine' + const res = await browserAPI.browse(searchDomain, '_all', { page: 1, q }) tagModal.value.results = (res.recipes ?? []).slice(0, 8).map( (r: { id: number; title: string }) => ({ id: r.id, title: r.title }) ) diff --git a/frontend/src/components/RecipeDetailPanel.vue b/frontend/src/components/RecipeDetailPanel.vue index 1de54a9..efb80eb 100644 --- a/frontend/src/components/RecipeDetailPanel.vue +++ b/frontend/src/components/RecipeDetailPanel.vue @@ -225,6 +225,23 @@ + +
+ +
+ + {{ tag.domain }} › {{ tag.category }} + + +
+
+
@@ -354,7 +371,7 @@ import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue' import { useRecipesStore } from '../stores/recipes' import { useSavedRecipesStore } from '../stores/savedRecipes' -import { inventoryAPI, recipesAPI } from '../services/api' +import { inventoryAPI, recipesAPI, browserAPI } from '../services/api' import type { RecipeSuggestion, GroceryLink, StepAnalysis } from '../services/api' import SaveRecipeModal from './SaveRecipeModal.vue' @@ -386,6 +403,12 @@ onMounted(() => { ) ;(focusable ?? dialogRef.value)?.focus() }) + // Load community tags in the background — non-critical, silently skip on error + browserAPI.listRecipeTags(props.recipe.id).then((tags) => { + communityTags.value = tags + }).catch(() => { + // Community tags are supplemental; silently skip on error + }) }) onUnmounted(() => { @@ -411,6 +434,10 @@ const isSaved = computed(() => savedStore.isSaved(props.recipe.id)) const cookDone = ref(false) +// ── Community tags ──────────────────────────────────────── +type CommunityTag = { id: number; domain: string; category: string; subcategory: string | null; pseudonym: string; upvotes: number; accepted: boolean } +const communityTags = ref([]) + // ── Leftover shelf-life ──────────────────────────────────── type LeftoversData = { fridge_days: number; freeze_days: number | null; freeze_by_day: number | null; storage_advice: string } const leftovers = ref(null) @@ -1628,4 +1655,39 @@ details[open].steps-collapsible .steps-collapsible-summary::before { font-size: 1rem; line-height: 1; } + +/* ── Community tags section ──────────────────────────────── */ +.community-tags-section { + padding-top: var(--spacing-sm); +} + +.community-tags-list { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-xs); +} + +.community-tag-chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 2px var(--spacing-sm); + border-radius: var(--radius-pill, 999px); + font-size: var(--font-size-xs, 0.72rem); + background: var(--color-bg-secondary); + color: var(--color-text-secondary); + border: 1px solid var(--color-border); + white-space: nowrap; +} + +.community-tag-chip--accepted { + background: rgba(124, 111, 205, 0.12); + color: var(--color-accent, #7c6fcd); + border-color: rgba(124, 111, 205, 0.3); +} + +.community-tag-check { + font-size: 0.65rem; + opacity: 0.8; +}