diff --git a/frontend/src/components/RecipeBrowserPanel.vue b/frontend/src/components/RecipeBrowserPanel.vue index 363b7e3..af6ba01 100644 --- a/frontend/src/components/RecipeBrowserPanel.vue +++ b/frontend/src/components/RecipeBrowserPanel.vue @@ -69,6 +69,12 @@ > {{ sub.subcategory }} {{ sub.recipe_count }} + @@ -186,6 +192,79 @@ @saved="savingRecipe = null" @unsave="savingRecipe && doUnsave(savingRecipe.id)" /> + + + @@ -220,6 +299,24 @@ const savingRecipe = ref(null) const searchQuery = ref('') const sortOrder = ref<'default' | 'alpha' | 'alpha_desc' | 'match'>('default') let searchDebounce: ReturnType | null = null +let tagSearchDebounce: ReturnType | null = null + +// ── Tag modal state ──────────────────────────────────────────────────────── +const tagModal = ref({ + open: false, + subcategory: '', // display label (pre-filled from CTA) + domain: '', // editable, pre-filled + category: '', // editable, pre-filled + subcategoryEdit: '', // editable, pre-filled + searchQuery: '', + searching: false, + results: [] as Array<{ id: number; title: string }>, + selectedRecipe: null as { id: number; title: string } | null, + submitting: false, + error: '', + success: '', +}) + const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize))) const allCountsZero = computed(() => @@ -375,6 +472,73 @@ async function doUnsave(recipeId: number) { savingRecipe.value = null await savedStore.unsave(recipeId) } + +// ── Tag modal ────────────────────────────────────────────────────────────── + +function openTagModal(subcategoryName: string) { + Object.assign(tagModal.value, { + open: true, + subcategory: subcategoryName, + domain: activeDomain.value ?? '', + category: activeCategory.value ?? '', + subcategoryEdit: subcategoryName, + searchQuery: '', + searching: false, + results: [], + selectedRecipe: null, + submitting: false, + error: '', + success: '', + }) +} + +function onTagSearchInput() { + if (tagSearchDebounce) clearTimeout(tagSearchDebounce) + const q = tagModal.value.searchQuery.trim() + if (q.length < 3) { + tagModal.value.results = [] + return + } + 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 }) + tagModal.value.results = (res.recipes ?? []).slice(0, 8).map( + (r: { id: number; title: string }) => ({ id: r.id, title: r.title }) + ) + } catch { + tagModal.value.results = [] + } finally { + tagModal.value.searching = false + } + }, 350) +} + +async function submitTag() { + const m = tagModal.value + if (!m.selectedRecipe) return + m.submitting = true + m.error = '' + m.success = '' + try { + await browserAPI.submitRecipeTag({ + recipe_id: m.selectedRecipe.id, + domain: m.domain, + category: m.category, + subcategory: m.subcategoryEdit || null, + pseudonym: 'anon', // TODO: wire real pseudonym from community store + }) + m.success = `Tagged! It will appear here once a second user confirms.` + setTimeout(() => { m.open = false }, 2500) + } catch (err: any) { + m.error = err?.message === '409' + ? 'You have already tagged this recipe here.' + : 'Failed to submit — please try again.' + } finally { + m.submitting = false + } +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index e6389c1..a7e263d 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -952,6 +952,28 @@ export const browserAPI = { const response = await api.get(`/recipes/browse/${domain}/${encodeURIComponent(category)}`, { params }) return response.data }, + + async submitRecipeTag(body: { + recipe_id: number + domain: string + category: string + subcategory: string | null + pseudonym: string + }): Promise { + await api.post('/recipes/community-tags', body) + }, + + async upvoteRecipeTag(tagId: number, pseudonym: string): Promise { + await api.post(`/recipes/community-tags/${tagId}/upvote`, null, { params: { pseudonym } }) + }, + + async listRecipeTags(recipeId: number): Promise> { + const response = await api.get(`/recipes/community-tags/${recipeId}`) + return response.data + }, } // ── Shopping List ─────────────────────────────────────────────────────────────