kiwi/frontend/src/components/CommunityFeedPanel.vue

298 lines
7.2 KiB
Vue

<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>
<!-- Share a plan action row -->
<div class="action-row flex-between mb-sm">
<button
class="btn btn-secondary btn-sm share-plan-btn"
aria-haspopup="dialog"
@click="showPublishPlan = true"
>
Share a plan
</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>
<!-- Publish plan modal -->
<PublishPlanModal
v-if="showPublishPlan"
:plan="null"
@close="showPublishPlan = false"
@published="onPlanPublished"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useCommunityStore } from '../stores/community'
import CommunityPostCard from './CommunityPostCard.vue'
import PublishPlanModal from './PublishPlanModal.vue'
const emit = defineEmits<{
'plan-forked': [payload: { plan_id: number; week_start: string }]
}>()
const store = useCommunityStore()
const activeFilter = ref('all')
const showPublishPlan = ref(false)
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)
let next = current
if (e.key === 'ArrowRight') {
e.preventDefault()
next = (current + 1) % filterIds.length
} else if (e.key === 'ArrowLeft') {
e.preventDefault()
next = (current - 1 + filterIds.length) % filterIds.length
} else {
return
}
setFilter(filterIds[next]!)
// Move DOM focus to the newly active tab per ARIA tablist pattern
const bar = (e.currentTarget as HTMLElement).closest('[role="tablist"]')
const buttons = bar?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
buttons?.[next]?.focus()
}
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')
}
}
function onPlanPublished(_payload: { slug: string }) {
showPublishPlan.value = false
store.fetchPosts(activeFilter.value === 'all' ? undefined : activeFilter.value)
}
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;
}
.action-row {
padding: var(--spacing-xs) 0;
}
.share-plan-btn {
font-size: var(--font-size-xs);
}
/* 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;
}
.toast-fade-enter-active,
.toast-fade-leave-active {
transition: none;
}
}
</style>