522 lines
16 KiB
Vue
522 lines
16 KiB
Vue
<template>
|
|
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
<!-- Header -->
|
|
<div class="flex justify-between items-center mb-8">
|
|
<div>
|
|
<h1 class="text-3xl font-bold text-gray-900">Publications</h1>
|
|
<p class="text-gray-600 mt-1">Partagez vos moments avec le groupe</p>
|
|
</div>
|
|
<button
|
|
@click="showCreateModal = true"
|
|
class="btn-primary"
|
|
>
|
|
<Plus class="w-4 h-4 mr-2" />
|
|
Nouvelle publication
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Create Post Form -->
|
|
<div class="card p-6 mb-8">
|
|
<div class="flex items-start space-x-3">
|
|
<img
|
|
v-if="user?.avatar_url"
|
|
:src="getMediaUrl(user.avatar_url)"
|
|
:alt="user?.full_name"
|
|
class="w-10 h-10 rounded-full object-cover"
|
|
>
|
|
<div v-else class="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
|
|
<User class="w-5 h-5 text-primary-600" />
|
|
</div>
|
|
|
|
<div class="flex-1">
|
|
<MentionInput
|
|
v-model="newPost.content"
|
|
:users="users"
|
|
:rows="3"
|
|
placeholder="Quoi de neuf ? Mentionnez des amis avec @..."
|
|
@mentions-changed="handleMentionsChanged"
|
|
/>
|
|
|
|
|
|
|
|
<div class="flex items-center justify-between mt-3">
|
|
<div class="flex items-center space-x-2">
|
|
<button
|
|
@click="showImageUpload = !showImageUpload"
|
|
class="text-gray-500 hover:text-primary-600 transition-colors"
|
|
title="Ajouter une image"
|
|
>
|
|
<Image class="w-5 h-5" />
|
|
</button>
|
|
<span class="text-xs text-gray-500">{{ newPost.content.length }}/5000</span>
|
|
</div>
|
|
|
|
<button
|
|
@click="createPost"
|
|
:disabled="!newPost.content.trim() || creating"
|
|
class="btn-primary"
|
|
>
|
|
{{ creating ? 'Publication...' : 'Publier' }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Image upload -->
|
|
<div v-if="showImageUpload" class="mt-3">
|
|
<input
|
|
ref="imageInput"
|
|
type="file"
|
|
accept="image/*"
|
|
class="hidden"
|
|
@change="handleImageChange"
|
|
>
|
|
<button
|
|
@click="$refs.imageInput.click()"
|
|
class="btn-secondary text-sm"
|
|
>
|
|
<Upload class="w-4 h-4 mr-2" />
|
|
Sélectionner une image
|
|
</button>
|
|
<div v-if="newPost.image_url" class="mt-2 relative inline-block">
|
|
<img
|
|
:src="getMediaUrl(newPost.image_url)"
|
|
:alt="newPost.content"
|
|
class="max-w-xs max-h-32 rounded-lg object-cover"
|
|
>
|
|
<button
|
|
@click="removeImage"
|
|
class="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-600"
|
|
>
|
|
<X class="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Posts List -->
|
|
<div class="space-y-6">
|
|
<div
|
|
v-for="post in posts"
|
|
:key="post.id"
|
|
class="card p-6"
|
|
>
|
|
<!-- Post Header -->
|
|
<div class="flex items-start space-x-3 mb-4">
|
|
<img
|
|
v-if="post.author_avatar"
|
|
:src="getMediaUrl(post.author_avatar)"
|
|
:alt="post.author_name"
|
|
class="w-10 h-10 rounded-full object-cover"
|
|
>
|
|
<div v-else class="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
|
|
<User class="w-5 h-5 text-primary-600" />
|
|
</div>
|
|
|
|
<div class="flex-1">
|
|
<div class="flex items-center space-x-2">
|
|
<router-link
|
|
:to="`/profile/${post.author_id}`"
|
|
class="font-medium text-gray-900 hover:text-primary-600 transition-colors"
|
|
>
|
|
{{ post.author_name }}
|
|
</router-link>
|
|
<span class="text-sm text-gray-500">{{ formatRelativeDate(post.created_at) }}</span>
|
|
</div>
|
|
|
|
<!-- Mentions -->
|
|
<div v-if="post.mentioned_users && post.mentioned_users.length > 0" class="mt-1">
|
|
<span class="text-xs text-gray-500">Mentionne : </span>
|
|
<span
|
|
v-for="mentionedUser in post.mentioned_users"
|
|
:key="mentionedUser.id"
|
|
class="text-xs text-primary-600 hover:underline cursor-pointer"
|
|
>
|
|
@{{ mentionedUser.username }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Delete button for author or admin -->
|
|
<button
|
|
v-if="canDeletePost(post)"
|
|
@click="deletePost(post.id)"
|
|
class="text-gray-400 hover:text-red-600 transition-colors"
|
|
title="Supprimer"
|
|
>
|
|
<Trash2 class="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Post Content -->
|
|
<div class="mb-4">
|
|
<Mentions :content="post.content" :mentions="post.mentioned_users" />
|
|
|
|
<!-- Post Image -->
|
|
<img
|
|
v-if="post.image_url"
|
|
:src="getMediaUrl(post.image_url)"
|
|
:alt="post.content"
|
|
class="mt-3 rounded-lg max-w-full max-h-96 object-cover"
|
|
>
|
|
</div>
|
|
|
|
<!-- Post Actions -->
|
|
<div class="flex items-center space-x-6 text-sm text-gray-500">
|
|
<button
|
|
@click="togglePostLike(post)"
|
|
class="flex items-center space-x-2 hover:text-primary-600 transition-colors"
|
|
:class="{ 'text-primary-600': post.is_liked }"
|
|
>
|
|
<Heart :class="post.is_liked ? 'fill-current' : ''" class="w-4 h-4" />
|
|
<span>{{ post.likes_count || 0 }}</span>
|
|
</button>
|
|
|
|
<button
|
|
@click="toggleComments(post.id)"
|
|
class="flex items-center space-x-2 hover:text-primary-600 transition-colors"
|
|
>
|
|
<MessageCircle class="w-4 h-4" />
|
|
<span>{{ post.comments_count || 0 }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Comments Section -->
|
|
<div v-if="post.showComments" class="mt-4 pt-4 border-t border-gray-100">
|
|
<!-- Add Comment -->
|
|
<div class="flex items-start space-x-3 mb-4">
|
|
<img
|
|
v-if="user?.avatar_url"
|
|
:src="getMediaUrl(user.avatar_url)"
|
|
:alt="user?.full_name"
|
|
class="w-8 h-8 rounded-full object-cover"
|
|
>
|
|
<div v-else class="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center">
|
|
<User class="w-4 h-4 text-primary-600" />
|
|
</div>
|
|
|
|
<div class="flex-1">
|
|
<MentionInput
|
|
v-model="post.newComment"
|
|
:users="users"
|
|
:rows="2"
|
|
placeholder="Ajouter un commentaire... (utilisez @username pour mentionner)"
|
|
@mentions-changed="(mentions) => handleCommentMentionsChanged(post.id, mentions)"
|
|
/>
|
|
<div class="flex items-center justify-between mt-2">
|
|
<span class="text-xs text-gray-500">{{ (post.newComment || '').length }}/500</span>
|
|
<button
|
|
@click="addComment(post)"
|
|
:disabled="!post.newComment?.trim()"
|
|
class="btn-primary text-sm px-3 py-1"
|
|
>
|
|
Commenter
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Comments List -->
|
|
<div v-if="post.comments && post.comments.length > 0" class="space-y-3">
|
|
<div
|
|
v-for="comment in post.comments"
|
|
:key="comment.id"
|
|
class="flex items-start space-x-3 p-3 bg-gray-50 rounded-lg"
|
|
>
|
|
<img
|
|
v-if="comment.author_avatar"
|
|
:src="getMediaUrl(comment.author_avatar)"
|
|
:alt="comment.author_name"
|
|
class="w-6 h-6 rounded-full object-cover"
|
|
>
|
|
<div v-else class="w-6 h-6 rounded-full bg-gray-200 flex items-center justify-center">
|
|
<User class="w-3 h-3 text-gray-500" />
|
|
</div>
|
|
|
|
<div class="flex-1">
|
|
<div class="flex items-center space-x-2">
|
|
<router-link
|
|
:to="`/profile/${comment.author_id}`"
|
|
class="font-medium text-sm text-gray-900 hover:text-primary-600 transition-colors"
|
|
>
|
|
{{ comment.author_name }}
|
|
</router-link>
|
|
<span class="text-xs text-gray-500">{{ formatRelativeDate(comment.created_at) }}</span>
|
|
</div>
|
|
<Mentions :content="comment.content" :mentions="comment.mentioned_users || []" class="text-sm text-gray-700 mt-1" />
|
|
</div>
|
|
|
|
<!-- Delete comment button -->
|
|
<button
|
|
v-if="canDeleteComment(comment)"
|
|
@click="deleteComment(post.id, comment.id)"
|
|
class="text-gray-400 hover:text-red-600 transition-colors"
|
|
title="Supprimer"
|
|
>
|
|
<X class="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Load more -->
|
|
<div v-if="hasMorePosts" class="text-center mt-8">
|
|
<button
|
|
@click="loadMorePosts"
|
|
:disabled="loading"
|
|
class="btn-secondary"
|
|
>
|
|
{{ loading ? 'Chargement...' : 'Charger plus' }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Empty state -->
|
|
<div v-if="posts.length === 0 && !loading" class="text-center py-12">
|
|
<MessageSquare class="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
|
<h3 class="text-lg font-medium text-gray-900 mb-2">Aucune publication</h3>
|
|
<p class="text-gray-600">Soyez le premier à partager quelque chose !</p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import { useToast } from 'vue-toastification'
|
|
import axios from '@/utils/axios'
|
|
import { getMediaUrl } from '@/utils/axios'
|
|
import { formatDistanceToNow } from 'date-fns'
|
|
import { fr } from 'date-fns/locale'
|
|
import {
|
|
Plus,
|
|
User,
|
|
Image,
|
|
Upload,
|
|
X,
|
|
Heart,
|
|
MessageCircle,
|
|
Trash2,
|
|
MessageSquare
|
|
} from 'lucide-vue-next'
|
|
import Mentions from '@/components/Mentions.vue'
|
|
import MentionInput from '@/components/MentionInput.vue'
|
|
|
|
const authStore = useAuthStore()
|
|
const toast = useToast()
|
|
|
|
const user = computed(() => authStore.user)
|
|
const posts = ref([])
|
|
const users = ref([])
|
|
const loading = ref(false)
|
|
const creating = ref(false)
|
|
const showCreateModal = ref(false)
|
|
const showImageUpload = ref(false)
|
|
|
|
const offset = ref(0)
|
|
const hasMorePosts = ref(true)
|
|
|
|
const newPost = ref({
|
|
content: '',
|
|
image_url: '',
|
|
mentioned_user_ids: []
|
|
})
|
|
|
|
function formatRelativeDate(date) {
|
|
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
|
}
|
|
|
|
function handleMentionsChanged(mentions) {
|
|
newPost.value.mentioned_user_ids = mentions.map(m => m.id)
|
|
}
|
|
|
|
function handleCommentMentionsChanged(postId, mentions) {
|
|
const post = posts.value.find(p => p.id === postId)
|
|
if (post) {
|
|
post.commentMentions = mentions
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function createPost() {
|
|
if (!newPost.value.content.trim()) return
|
|
|
|
creating.value = true
|
|
try {
|
|
const response = await axios.post('/api/posts', {
|
|
content: newPost.value.content,
|
|
image_url: newPost.value.image_url,
|
|
mentioned_user_ids: newPost.value.mentioned_user_ids
|
|
})
|
|
|
|
// Add new post to the beginning of the list
|
|
posts.value.unshift(response.data)
|
|
|
|
// Reset form
|
|
newPost.value = {
|
|
content: '',
|
|
image_url: '',
|
|
mentioned_user_ids: []
|
|
}
|
|
showImageUpload.value = false
|
|
|
|
toast.success('Publication créée avec succès')
|
|
} catch (error) {
|
|
toast.error('Erreur lors de la création de la publication')
|
|
}
|
|
creating.value = false
|
|
}
|
|
|
|
async function togglePostLike(post) {
|
|
try {
|
|
const response = await axios.post(`/api/posts/${post.id}/like`)
|
|
post.is_liked = response.data.is_liked
|
|
post.likes_count = response.data.likes_count
|
|
toast.success(post.is_liked ? 'Post liké' : 'Like retiré')
|
|
} catch (error) {
|
|
toast.error('Erreur lors de la mise à jour du like')
|
|
}
|
|
}
|
|
|
|
function toggleComments(postId) {
|
|
const post = posts.value.find(p => p.id === postId)
|
|
if (post) {
|
|
post.showComments = !post.showComments
|
|
if (post.showComments && !post.comments) {
|
|
post.comments = []
|
|
post.newComment = ''
|
|
}
|
|
}
|
|
}
|
|
|
|
async function addComment(post) {
|
|
if (!post.newComment?.trim()) return
|
|
|
|
try {
|
|
const response = await axios.post(`/api/posts/${post.id}/comment`, {
|
|
content: post.newComment.trim()
|
|
})
|
|
|
|
// Add new comment to the post
|
|
if (!post.comments) post.comments = []
|
|
post.comments.push(response.data.comment)
|
|
post.comments_count = (post.comments_count || 0) + 1
|
|
|
|
// Reset comment input
|
|
post.newComment = ''
|
|
|
|
toast.success('Commentaire ajouté')
|
|
} catch (error) {
|
|
toast.error('Erreur lors de l\'ajout du commentaire')
|
|
}
|
|
}
|
|
|
|
async function deleteComment(postId, commentId) {
|
|
try {
|
|
await axios.delete(`/api/posts/${postId}/comment/${commentId}`)
|
|
|
|
const post = posts.value.find(p => p.id === postId)
|
|
if (post && post.comments) {
|
|
post.comments = post.comments.filter(c => c.id !== commentId)
|
|
post.comments_count = Math.max(0, (post.comments_count || 1) - 1)
|
|
}
|
|
|
|
toast.success('Commentaire supprimé')
|
|
} catch (error) {
|
|
toast.error('Erreur lors de la suppression du commentaire')
|
|
}
|
|
}
|
|
|
|
async function handleImageChange(event) {
|
|
const file = event.target.files[0]
|
|
if (!file) return
|
|
|
|
if (!file.type.startsWith('image/')) {
|
|
toast.error('Veuillez sélectionner une image')
|
|
return
|
|
}
|
|
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
toast.error('L\'image est trop volumineuse (max 5MB)')
|
|
return
|
|
}
|
|
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
|
|
const response = await axios.post('/api/posts/upload-image', formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' }
|
|
})
|
|
|
|
newPost.value.image_url = response.data.image_url
|
|
} catch (error) {
|
|
toast.error('Erreur lors de l\'upload de l\'image')
|
|
}
|
|
|
|
event.target.value = ''
|
|
}
|
|
|
|
function removeImage() {
|
|
newPost.value.image_url = ''
|
|
}
|
|
|
|
async function fetchPosts() {
|
|
loading.value = true
|
|
try {
|
|
const response = await axios.get(`/api/posts?limit=10&offset=${offset.value}`)
|
|
if (offset.value === 0) {
|
|
posts.value = response.data
|
|
} else {
|
|
posts.value.push(...response.data)
|
|
}
|
|
|
|
hasMorePosts.value = response.data.length === 10
|
|
} catch (error) {
|
|
toast.error('Erreur lors du chargement des publications')
|
|
}
|
|
loading.value = false
|
|
}
|
|
|
|
async function fetchUsers() {
|
|
try {
|
|
const response = await axios.get('/api/users')
|
|
users.value = response.data
|
|
} catch (error) {
|
|
console.error('Error fetching users:', error)
|
|
}
|
|
}
|
|
|
|
async function loadMorePosts() {
|
|
offset.value += 10
|
|
await fetchPosts()
|
|
}
|
|
|
|
async function deletePost(postId) {
|
|
if (!confirm('Êtes-vous sûr de vouloir supprimer cette publication ?')) return
|
|
|
|
try {
|
|
await axios.delete(`/api/posts/${postId}`)
|
|
posts.value = posts.value.filter(p => p.id !== postId)
|
|
toast.success('Publication supprimée')
|
|
} catch (error) {
|
|
toast.error('Erreur lors de la suppression')
|
|
}
|
|
}
|
|
|
|
function canDeletePost(post) {
|
|
return user.value && (post.author_id === user.value.id || user.value.is_admin)
|
|
}
|
|
|
|
function canDeleteComment(comment) {
|
|
return user.value && (comment.author_id === user.value.id || user.value.is_admin)
|
|
}
|
|
|
|
onMounted(() => {
|
|
fetchPosts()
|
|
fetchUsers()
|
|
})
|
|
</script>
|