feat(recipe-tags): 'Categorize this' CTA and tag submission modal
Zero-count subcategory buttons show a + badge. Clicking opens a modal: - Recipe search (debounced, 3-char min) using existing browse API - Pre-filled domain/category/subcategory from current browse context, fully correctable via selects populated from loaded domains/categories - Submit calls POST /recipes/community-tags; 409 on duplicate - Success message: 'It will appear once a second user confirms' api.ts: adds submitRecipeTag(), upvoteRecipeTag(), listRecipeTags() to browserAPI. CSS: tag-cta pill on subcat buttons, modal-backdrop + modal-box with theme vars. TODO: wire real community pseudonym (currently hardcoded 'anon'). Refs kiwi#118.
This commit is contained in:
parent
9697c7b64f
commit
70205ebb25
2 changed files with 257 additions and 0 deletions
|
|
@ -69,6 +69,12 @@
|
||||||
>
|
>
|
||||||
{{ sub.subcategory }}
|
{{ sub.subcategory }}
|
||||||
<span class="cat-count">{{ sub.recipe_count }}</span>
|
<span class="cat-count">{{ sub.recipe_count }}</span>
|
||||||
|
<span
|
||||||
|
v-if="sub.recipe_count === 0"
|
||||||
|
class="tag-cta"
|
||||||
|
title="Know a recipe in this category? Tag it!"
|
||||||
|
@click.stop="openTagModal(sub.subcategory)"
|
||||||
|
>+</span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -186,6 +192,79 @@
|
||||||
@saved="savingRecipe = null"
|
@saved="savingRecipe = null"
|
||||||
@unsave="savingRecipe && doUnsave(savingRecipe.id)"
|
@unsave="savingRecipe && doUnsave(savingRecipe.id)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Community tag modal — opened from zero-count subcategory CTA -->
|
||||||
|
<div v-if="tagModal.open" class="modal-backdrop" @click.self="tagModal.open = false">
|
||||||
|
<div class="modal-box" role="dialog" aria-modal="true" aria-label="Tag a recipe">
|
||||||
|
<h3 class="text-md font-semibold mb-sm">Tag a recipe as {{ tagModal.subcategory }}</h3>
|
||||||
|
<p class="text-sm text-secondary mb-sm">
|
||||||
|
Search for a recipe you know belongs here. Your tag helps other users discover it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Recipe search -->
|
||||||
|
<input
|
||||||
|
class="form-input mb-xs"
|
||||||
|
v-model="tagModal.searchQuery"
|
||||||
|
placeholder="Search recipe title…"
|
||||||
|
@input="onTagSearchInput"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<div v-if="tagModal.searching" class="text-sm text-secondary mb-xs">Searching…</div>
|
||||||
|
<ul v-else-if="tagModal.results.length > 0" class="tag-search-results mb-sm">
|
||||||
|
<li
|
||||||
|
v-for="r in tagModal.results"
|
||||||
|
:key="r.id"
|
||||||
|
:class="['tag-result-row', { selected: tagModal.selectedRecipe?.id === r.id }]"
|
||||||
|
@click="tagModal.selectedRecipe = r"
|
||||||
|
>
|
||||||
|
<span class="tag-result-title">{{ r.title }}</span>
|
||||||
|
<span class="tag-result-check" v-if="tagModal.selectedRecipe?.id === r.id">✓</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else-if="tagModal.searchQuery.length > 2" class="text-sm text-secondary mb-sm">
|
||||||
|
No results — try a different title.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Location correction (pre-filled from active browse context) -->
|
||||||
|
<div class="form-group mb-xs">
|
||||||
|
<label class="form-label text-xs">Domain</label>
|
||||||
|
<select class="form-input" v-model="tagModal.domain">
|
||||||
|
<option v-for="d in domains" :key="d.id" :value="d.id">{{ d.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-xs">
|
||||||
|
<label class="form-label text-xs">Category</label>
|
||||||
|
<select class="form-input" v-model="tagModal.category">
|
||||||
|
<option v-for="c in categories" :key="c.category" :value="c.category">
|
||||||
|
{{ c.category }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-sm">
|
||||||
|
<label class="form-label text-xs">Subcategory (optional)</label>
|
||||||
|
<select class="form-input" v-model="tagModal.subcategoryEdit">
|
||||||
|
<option value="">— none (category level) —</option>
|
||||||
|
<option v-for="s in subcategories" :key="s.subcategory" :value="s.subcategory">
|
||||||
|
{{ s.subcategory }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-sm">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-sm"
|
||||||
|
:disabled="!tagModal.selectedRecipe || tagModal.submitting"
|
||||||
|
@click="submitTag"
|
||||||
|
>
|
||||||
|
<span v-if="tagModal.submitting">Submitting…</span>
|
||||||
|
<span v-else>Tag this recipe</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" @click="tagModal.open = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="tagModal.error" class="text-sm status-badge status-error mt-xs">{{ tagModal.error }}</p>
|
||||||
|
<p v-if="tagModal.success" class="text-sm status-badge status-ok mt-xs">{{ tagModal.success }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -220,6 +299,24 @@ const savingRecipe = ref<BrowserRecipe | null>(null)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const sortOrder = ref<'default' | 'alpha' | 'alpha_desc' | 'match'>('default')
|
const sortOrder = ref<'default' | 'alpha' | 'alpha_desc' | 'match'>('default')
|
||||||
let searchDebounce: ReturnType<typeof setTimeout> | null = null
|
let searchDebounce: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let tagSearchDebounce: ReturnType<typeof setTimeout> | 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 totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
||||||
const allCountsZero = computed(() =>
|
const allCountsZero = computed(() =>
|
||||||
|
|
@ -375,6 +472,73 @@ async function doUnsave(recipeId: number) {
|
||||||
savingRecipe.value = null
|
savingRecipe.value = null
|
||||||
await savedStore.unsave(recipeId)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -535,4 +699,75 @@ async function doUnsave(recipeId: number) {
|
||||||
.flex-shrink-0 {
|
.flex-shrink-0 {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Community tag CTA ──────────────────────────────────────────────────── */
|
||||||
|
.tag-cta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: var(--color-accent, #7c6fcd);
|
||||||
|
color: #fff;
|
||||||
|
opacity: 0.75;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.tag-cta:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tag modal ──────────────────────────────────────────────────────────── */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
.modal-box {
|
||||||
|
background: var(--color-surface, #fff);
|
||||||
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 28rem;
|
||||||
|
width: 90vw;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.18);
|
||||||
|
}
|
||||||
|
.tag-search-results {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid var(--color-border, #e0e0e0);
|
||||||
|
border-radius: var(--radius-sm, 0.25rem);
|
||||||
|
max-height: 12rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.tag-result-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.tag-result-row:hover,
|
||||||
|
.tag-result-row.selected {
|
||||||
|
background: var(--color-hover, #f0eeff);
|
||||||
|
}
|
||||||
|
.tag-result-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.tag-result-check {
|
||||||
|
color: var(--color-accent, #7c6fcd);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -952,6 +952,28 @@ export const browserAPI = {
|
||||||
const response = await api.get(`/recipes/browse/${domain}/${encodeURIComponent(category)}`, { params })
|
const response = await api.get(`/recipes/browse/${domain}/${encodeURIComponent(category)}`, { params })
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async submitRecipeTag(body: {
|
||||||
|
recipe_id: number
|
||||||
|
domain: string
|
||||||
|
category: string
|
||||||
|
subcategory: string | null
|
||||||
|
pseudonym: string
|
||||||
|
}): Promise<void> {
|
||||||
|
await api.post('/recipes/community-tags', body)
|
||||||
|
},
|
||||||
|
|
||||||
|
async upvoteRecipeTag(tagId: number, pseudonym: string): Promise<void> {
|
||||||
|
await api.post(`/recipes/community-tags/${tagId}/upvote`, null, { params: { pseudonym } })
|
||||||
|
},
|
||||||
|
|
||||||
|
async listRecipeTags(recipeId: number): Promise<Array<{
|
||||||
|
id: number; domain: string; category: string; subcategory: string | null;
|
||||||
|
pseudonym: string; upvotes: number; accepted: boolean
|
||||||
|
}>> {
|
||||||
|
const response = await api.get(`/recipes/community-tags/${recipeId}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Shopping List ─────────────────────────────────────────────────────────────
|
// ── Shopping List ─────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue