feat: community feed Vue frontend -- Pinia store + feed panel + RecipesView tab
This commit is contained in:
parent
86dd9adbcb
commit
8731cad854
4 changed files with 541 additions and 5 deletions
251
frontend/src/components/CommunityFeedPanel.vue
Normal file
251
frontend/src/components/CommunityFeedPanel.vue
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
<template>
|
||||||
|
<div class="community-feed-panel">
|
||||||
|
|
||||||
|
<!-- Filter tabs: All / Plans / Successes / Bloopers -->
|
||||||
|
<div role="tablist" aria-label="Community post filters" class="filter-bar flex gap-xs mb-md">
|
||||||
|
<button
|
||||||
|
v-for="f in filters"
|
||||||
|
:key="f.id"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="activeFilter === f.id"
|
||||||
|
:tabindex="activeFilter === f.id ? 0 : -1"
|
||||||
|
:class="['btn', 'tab-btn', activeFilter === f.id ? 'btn-primary' : 'btn-secondary']"
|
||||||
|
@click="setFilter(f.id)"
|
||||||
|
@keydown="onFilterKeydown"
|
||||||
|
>{{ f.label }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading skeletons -->
|
||||||
|
<div
|
||||||
|
v-if="store.loading"
|
||||||
|
class="skeleton-list flex-col gap-sm"
|
||||||
|
aria-busy="true"
|
||||||
|
aria-label="Loading posts"
|
||||||
|
>
|
||||||
|
<div v-for="n in 3" :key="n" class="skeleton-card">
|
||||||
|
<div class="skeleton-line skeleton-line-short"></div>
|
||||||
|
<div class="skeleton-line skeleton-line-long mt-xs"></div>
|
||||||
|
<div class="skeleton-line skeleton-line-med mt-xs"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div
|
||||||
|
v-else-if="store.error"
|
||||||
|
class="error-state card"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-secondary mb-sm">{{ store.error }}</p>
|
||||||
|
<button class="btn btn-secondary btn-sm" @click="retry">
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div
|
||||||
|
v-else-if="store.posts.length === 0"
|
||||||
|
class="empty-state card text-center"
|
||||||
|
>
|
||||||
|
<p class="text-secondary mb-xs">No posts yet</p>
|
||||||
|
<p class="text-sm text-muted">Be the first to share a meal plan or recipe story.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Post list -->
|
||||||
|
<div v-else class="post-list flex-col gap-sm">
|
||||||
|
<CommunityPostCard
|
||||||
|
v-for="post in store.posts"
|
||||||
|
:key="post.slug"
|
||||||
|
:post="post"
|
||||||
|
@fork="handleFork"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fork success toast -->
|
||||||
|
<Transition name="toast-fade">
|
||||||
|
<div
|
||||||
|
v-if="forkFeedback"
|
||||||
|
class="fork-toast status-badge status-success"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{{ forkFeedback }}
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<!-- Fork error toast -->
|
||||||
|
<Transition name="toast-fade">
|
||||||
|
<div
|
||||||
|
v-if="forkError"
|
||||||
|
class="fork-toast status-badge status-error"
|
||||||
|
role="alert"
|
||||||
|
aria-live="assertive"
|
||||||
|
>
|
||||||
|
{{ forkError }}
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useCommunityStore } from '../stores/community'
|
||||||
|
import CommunityPostCard from './CommunityPostCard.vue'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'plan-forked': [payload: { plan_id: number; week_start: string }]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const store = useCommunityStore()
|
||||||
|
|
||||||
|
const activeFilter = ref('all')
|
||||||
|
|
||||||
|
const filters = [
|
||||||
|
{ id: 'all', label: 'All' },
|
||||||
|
{ id: 'plan', label: 'Plans' },
|
||||||
|
{ id: 'recipe_success', label: 'Successes' },
|
||||||
|
{ id: 'recipe_blooper', label: 'Bloopers' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const filterIds = filters.map((f) => f.id)
|
||||||
|
|
||||||
|
function onFilterKeydown(e: KeyboardEvent) {
|
||||||
|
const current = filterIds.indexOf(activeFilter.value)
|
||||||
|
if (e.key === 'ArrowRight') {
|
||||||
|
e.preventDefault()
|
||||||
|
setFilter(filterIds[(current + 1) % filterIds.length]!)
|
||||||
|
} else if (e.key === 'ArrowLeft') {
|
||||||
|
e.preventDefault()
|
||||||
|
setFilter(filterIds[(current - 1 + filterIds.length) % filterIds.length]!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setFilter(filterId: string) {
|
||||||
|
activeFilter.value = filterId
|
||||||
|
await store.fetchPosts(filterId === 'all' ? undefined : filterId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retry() {
|
||||||
|
await store.fetchPosts(activeFilter.value === 'all' ? undefined : activeFilter.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const forkFeedback = ref<string | null>(null)
|
||||||
|
const forkError = ref<string | null>(null)
|
||||||
|
|
||||||
|
function showToast(msg: string, type: 'success' | 'error') {
|
||||||
|
if (type === 'success') {
|
||||||
|
forkFeedback.value = msg
|
||||||
|
setTimeout(() => { forkFeedback.value = null }, 3000)
|
||||||
|
} else {
|
||||||
|
forkError.value = msg
|
||||||
|
setTimeout(() => { forkError.value = null }, 4000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFork(slug: string) {
|
||||||
|
try {
|
||||||
|
const result = await store.forkPost(slug)
|
||||||
|
showToast('Plan added to your week.', 'success')
|
||||||
|
emit('plan-forked', { plan_id: result.plan_id, week_start: result.week_start })
|
||||||
|
} catch (err: unknown) {
|
||||||
|
showToast(err instanceof Error ? err.message : 'Could not fork this plan.', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (store.posts.length === 0) {
|
||||||
|
await store.fetchPosts()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.community-feed-panel {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
padding-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading skeletons */
|
||||||
|
.skeleton-card {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line {
|
||||||
|
height: 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
animation: shimmer 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line-short { width: 35%; }
|
||||||
|
.skeleton-line-med { width: 60%; }
|
||||||
|
.skeleton-line-long { width: 90%; }
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { opacity: 0.6; }
|
||||||
|
50% { opacity: 1.0; }
|
||||||
|
100% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty / error states */
|
||||||
|
.empty-state {
|
||||||
|
padding: var(--spacing-xl) var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Post list */
|
||||||
|
.post-list {
|
||||||
|
padding-top: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast */
|
||||||
|
.fork-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: calc(72px + var(--spacing-md));
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 300;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
.fork-toast {
|
||||||
|
bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-fade-enter-active,
|
||||||
|
.toast-fade-leave-active {
|
||||||
|
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-fade-enter-from,
|
||||||
|
.toast-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50%) translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.skeleton-line {
|
||||||
|
animation: none;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
172
frontend/src/components/CommunityPostCard.vue
Normal file
172
frontend/src/components/CommunityPostCard.vue
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
<template>
|
||||||
|
<article class="community-post-card" :class="`post-type-${post.post_type}`">
|
||||||
|
<!-- Header row: type badge + date -->
|
||||||
|
<div class="card-header flex-between gap-sm mb-xs">
|
||||||
|
<span
|
||||||
|
class="post-type-badge status-badge"
|
||||||
|
:class="typeBadgeClass"
|
||||||
|
:aria-label="`Post type: ${typeLabel}`"
|
||||||
|
>{{ typeLabel }}</span>
|
||||||
|
<time
|
||||||
|
class="post-date text-xs text-muted"
|
||||||
|
:datetime="post.published"
|
||||||
|
:title="fullDate"
|
||||||
|
>{{ shortDate }}</time>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<h3 class="post-title text-base font-semibold mb-xs">{{ post.title }}</h3>
|
||||||
|
|
||||||
|
<!-- Author -->
|
||||||
|
<p class="post-author text-xs text-muted mb-xs">
|
||||||
|
by {{ post.pseudonym }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Description (if present) -->
|
||||||
|
<p v-if="post.description" class="post-description text-sm text-secondary mb-sm">
|
||||||
|
{{ post.description }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Dietary tag pills -->
|
||||||
|
<div
|
||||||
|
v-if="post.dietary_tags.length > 0"
|
||||||
|
class="tag-row flex flex-wrap gap-xs mb-sm"
|
||||||
|
role="list"
|
||||||
|
aria-label="Dietary tags"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="tag in post.dietary_tags"
|
||||||
|
:key="tag"
|
||||||
|
class="status-badge status-success tag-pill"
|
||||||
|
role="listitem"
|
||||||
|
>{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fork button (plan posts only) -->
|
||||||
|
<div v-if="post.post_type === 'plan'" class="card-actions mt-sm">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-sm btn-fork"
|
||||||
|
:aria-label="`Fork ${post.title} to my meal plan`"
|
||||||
|
@click="$emit('fork', post.slug)"
|
||||||
|
>
|
||||||
|
Fork to my plan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { CommunityPost } from '../stores/community'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
post: CommunityPost
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
fork: [slug: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const typeLabel = computed(() => {
|
||||||
|
switch (props.post.post_type) {
|
||||||
|
case 'plan': return 'Meal Plan'
|
||||||
|
case 'recipe_success': return 'Success'
|
||||||
|
case 'recipe_blooper': return 'Blooper'
|
||||||
|
default: return props.post.post_type
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const typeBadgeClass = computed(() => {
|
||||||
|
switch (props.post.post_type) {
|
||||||
|
case 'plan': return 'status-info'
|
||||||
|
case 'recipe_success': return 'status-success'
|
||||||
|
case 'recipe_blooper': return 'status-warning'
|
||||||
|
default: return 'status-info'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const shortDate = computed(() => {
|
||||||
|
try {
|
||||||
|
return new Date(props.post.published).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const fullDate = computed(() => {
|
||||||
|
try {
|
||||||
|
return new Date(props.post.published).toLocaleString()
|
||||||
|
} catch {
|
||||||
|
return props.post.published
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.community-post-card {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
transition: box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.community-post-card:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-type-plan { border-left: 3px solid var(--color-info); }
|
||||||
|
.post-type-recipe_success { border-left: 3px solid var(--color-success); }
|
||||||
|
.post-type-recipe_blooper { border-left: 3px solid var(--color-warning); }
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-type-badge,
|
||||||
|
.post-date {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-title {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-author,
|
||||||
|
.post-description {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-description {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-pill {
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-fork {
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.community-post-card {
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-fork {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -32,6 +32,14 @@
|
||||||
@open-recipe="openRecipeById"
|
@open-recipe="openRecipeById"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Community tab -->
|
||||||
|
<CommunityFeedPanel
|
||||||
|
v-else-if="activeTab === 'community'"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="tab-community"
|
||||||
|
@plan-forked="onPlanForked"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Find tab (existing search UI) -->
|
<!-- Find tab (existing search UI) -->
|
||||||
<div v-else role="tabpanel" aria-labelledby="tab-find">
|
<div v-else role="tabpanel" aria-labelledby="tab-find">
|
||||||
<!-- Controls Panel -->
|
<!-- Controls Panel -->
|
||||||
|
|
@ -554,6 +562,7 @@ import { useInventoryStore } from '../stores/inventory'
|
||||||
import RecipeDetailPanel from './RecipeDetailPanel.vue'
|
import RecipeDetailPanel from './RecipeDetailPanel.vue'
|
||||||
import RecipeBrowserPanel from './RecipeBrowserPanel.vue'
|
import RecipeBrowserPanel from './RecipeBrowserPanel.vue'
|
||||||
import SavedRecipesPanel from './SavedRecipesPanel.vue'
|
import SavedRecipesPanel from './SavedRecipesPanel.vue'
|
||||||
|
import CommunityFeedPanel from './CommunityFeedPanel.vue'
|
||||||
import type { RecipeSuggestion, GroceryLink } from '../services/api'
|
import type { RecipeSuggestion, GroceryLink } from '../services/api'
|
||||||
import { recipesAPI } from '../services/api'
|
import { recipesAPI } from '../services/api'
|
||||||
|
|
||||||
|
|
@ -561,16 +570,17 @@ const recipesStore = useRecipesStore()
|
||||||
const inventoryStore = useInventoryStore()
|
const inventoryStore = useInventoryStore()
|
||||||
|
|
||||||
// Tab state
|
// Tab state
|
||||||
type TabId = 'find' | 'browse' | 'saved'
|
type TabId = 'find' | 'browse' | 'saved' | 'community'
|
||||||
const tabs: Array<{ id: TabId; label: string }> = [
|
const tabs: Array<{ id: TabId; label: string }> = [
|
||||||
{ id: 'find', label: 'Find' },
|
{ id: 'find', label: 'Find' },
|
||||||
{ id: 'browse', label: 'Browse' },
|
{ id: 'browse', label: 'Browse' },
|
||||||
{ id: 'saved', label: 'Saved' },
|
{ id: 'saved', label: 'Saved' },
|
||||||
|
{ id: 'community', label: 'Community' },
|
||||||
]
|
]
|
||||||
const activeTab = ref<TabId>('find')
|
const activeTab = ref<TabId>('find')
|
||||||
|
|
||||||
function onTabKeydown(e: KeyboardEvent) {
|
function onTabKeydown(e: KeyboardEvent) {
|
||||||
const tabIds: TabId[] = ['find', 'browse', 'saved']
|
const tabIds: TabId[] = ['find', 'browse', 'saved', 'community']
|
||||||
const current = tabIds.indexOf(activeTab.value)
|
const current = tabIds.indexOf(activeTab.value)
|
||||||
if (e.key === 'ArrowRight') {
|
if (e.key === 'ArrowRight') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
@ -581,6 +591,11 @@ function onTabKeydown(e: KeyboardEvent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Community tab: navigate to Find tab after a plan fork (full plan view deferred to Task 9)
|
||||||
|
function onPlanForked(_payload: { plan_id: number; week_start: string }) {
|
||||||
|
activeTab.value = 'find'
|
||||||
|
}
|
||||||
|
|
||||||
// Browser/saved tab recipe detail panel (fetches full recipe from API)
|
// Browser/saved tab recipe detail panel (fetches full recipe from API)
|
||||||
const browserSelectedRecipe = ref<RecipeSuggestion | null>(null)
|
const browserSelectedRecipe = ref<RecipeSuggestion | null>(null)
|
||||||
|
|
||||||
|
|
|
||||||
98
frontend/src/stores/community.ts
Normal file
98
frontend/src/stores/community.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
/**
|
||||||
|
* Community Store
|
||||||
|
*
|
||||||
|
* Manages community post feed state and fork actions using Pinia.
|
||||||
|
* Follows the composition store pattern established in recipes.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import api from '../services/api'
|
||||||
|
|
||||||
|
// ========== Types ==========
|
||||||
|
|
||||||
|
export interface CommunityPostSlot {
|
||||||
|
day: string
|
||||||
|
meal_type: string
|
||||||
|
recipe_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElementProfiles {
|
||||||
|
seasoning_score: number | null
|
||||||
|
richness_score: number | null
|
||||||
|
brightness_score: number | null
|
||||||
|
depth_score: number | null
|
||||||
|
aroma_score: number | null
|
||||||
|
structure_score: number | null
|
||||||
|
texture_profile: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommunityPost {
|
||||||
|
slug: string
|
||||||
|
pseudonym: string
|
||||||
|
post_type: 'plan' | 'recipe_success' | 'recipe_blooper'
|
||||||
|
published: string
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
photo_url: string | null
|
||||||
|
slots: CommunityPostSlot[]
|
||||||
|
recipe_id: number | null
|
||||||
|
recipe_name: string | null
|
||||||
|
level: number | null
|
||||||
|
outcome_notes: string | null
|
||||||
|
element_profiles: ElementProfiles
|
||||||
|
dietary_tags: string[]
|
||||||
|
allergen_flags: string[]
|
||||||
|
flavor_molecules: string[]
|
||||||
|
fat_pct: number | null
|
||||||
|
protein_pct: number | null
|
||||||
|
moisture_pct: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForkResult {
|
||||||
|
plan_id: number
|
||||||
|
week_start: string
|
||||||
|
forked_from: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Store ==========
|
||||||
|
|
||||||
|
export const useCommunityStore = defineStore('community', () => {
|
||||||
|
const posts = ref<CommunityPost[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const currentFilter = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function fetchPosts(postType?: string) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
currentFilter.value = postType ?? null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params: Record<string, string | number> = { page: 1, page_size: 40 }
|
||||||
|
if (postType) {
|
||||||
|
params.post_type = postType
|
||||||
|
}
|
||||||
|
const response = await api.get<{ posts: CommunityPost[] }>('/community/posts', { params })
|
||||||
|
posts.value = response.data.posts
|
||||||
|
} catch (err: unknown) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Could not load community posts.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forkPost(slug: string): Promise<ForkResult> {
|
||||||
|
const response = await api.post<ForkResult>(`/community/posts/${slug}/fork`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
posts,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
currentFilter,
|
||||||
|
fetchPosts,
|
||||||
|
forkPost,
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue