598 lines
19 KiB
Vue
598 lines
19 KiB
Vue
<template>
|
|
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
<!-- Header -->
|
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 sm:mb-8 space-y-4 sm:space-y-0">
|
|
<div>
|
|
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900">Publications</h1>
|
|
<p class="text-sm sm:text-base text-gray-600 mt-1">Partagez vos moments avec le groupe</p>
|
|
</div>
|
|
<button
|
|
@click="showCreateModal = true"
|
|
class="w-full sm:w-auto btn-primary justify-center"
|
|
>
|
|
<Plus class="w-4 h-4 mr-2" />
|
|
Nouvelle publication
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Create Post Form -->
|
|
<div class="card p-4 sm: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"
|
|
:id="`post-${post.id}`"
|
|
class="card p-4 sm:p-6 transition-all duration-300"
|
|
:class="{ 'ring-2 ring-primary-500 bg-primary-50': route.query.highlight == post.id }"
|
|
>
|
|
<!-- 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, onBeforeUnmount, watch, nextTick } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import { useToast } from 'vue-toastification'
|
|
import axios, { postJson } from '@/utils/axios'
|
|
import { getMediaUrl, uploadFormData } from '@/utils/axios'
|
|
import { formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
|
|
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 route = useRoute()
|
|
const router = useRouter()
|
|
|
|
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 dateRefreshInterval = ref(null)
|
|
|
|
const newPost = ref({
|
|
content: '',
|
|
image_url: '',
|
|
mentioned_user_ids: []
|
|
})
|
|
|
|
// Force refresh pour les dates relatives
|
|
const dateRefreshKey = ref(0)
|
|
|
|
function formatRelativeDate(date) {
|
|
// Utiliser dateRefreshKey pour forcer le recalcul
|
|
dateRefreshKey.value
|
|
return formatRelativeDateInFrenchTimezone(date)
|
|
}
|
|
|
|
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 data = await postJson('/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(data)
|
|
|
|
// Forcer le rafraîchissement de la date immédiatement
|
|
dateRefreshKey.value++
|
|
|
|
// Rafraîchir à nouveau après 1 seconde pour s'assurer que ça s'affiche correctement
|
|
setTimeout(() => {
|
|
dateRefreshKey.value++
|
|
}, 1000)
|
|
|
|
// 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 data = await uploadFormData('/api/posts/upload-image', formData)
|
|
newPost.value.image_url = 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}`)
|
|
|
|
// S'assurer que les dates sont correctement parsées comme UTC
|
|
const postsData = response.data.map(post => {
|
|
// Si la date created_at est une string sans timezone, l'interpréter comme UTC
|
|
if (post.created_at && typeof post.created_at === 'string' && !post.created_at.endsWith('Z') && !post.created_at.includes('+') && !post.created_at.includes('-', 10)) {
|
|
post.created_at = post.created_at + 'Z'
|
|
}
|
|
// Même chose pour les commentaires
|
|
if (post.comments) {
|
|
post.comments = post.comments.map(comment => {
|
|
if (comment.created_at && typeof comment.created_at === 'string' && !comment.created_at.endsWith('Z') && !comment.created_at.includes('+') && !comment.created_at.includes('-', 10)) {
|
|
comment.created_at = comment.created_at + 'Z'
|
|
}
|
|
return comment
|
|
})
|
|
}
|
|
return post
|
|
})
|
|
|
|
if (offset.value === 0) {
|
|
posts.value = postsData
|
|
} else {
|
|
posts.value.push(...postsData)
|
|
}
|
|
|
|
hasMorePosts.value = response.data.length === 10
|
|
|
|
// Forcer le rafraîchissement des dates après le chargement
|
|
dateRefreshKey.value++
|
|
} 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)
|
|
}
|
|
|
|
// Fonction pour scroller vers un post spécifique
|
|
async function scrollToPost(postId) {
|
|
await nextTick()
|
|
const postElement = document.getElementById(`post-${postId}`)
|
|
if (postElement) {
|
|
postElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
// Retirer le highlight après 3 secondes
|
|
setTimeout(() => {
|
|
if (route.query.highlight) {
|
|
router.replace({ query: {} })
|
|
}
|
|
}, 3000)
|
|
}
|
|
}
|
|
|
|
// Watcher pour le highlight dans la query
|
|
watch(() => route.query.highlight, async (postId) => {
|
|
if (postId && posts.value.length > 0) {
|
|
await scrollToPost(parseInt(postId))
|
|
}
|
|
}, { immediate: true })
|
|
|
|
onMounted(async () => {
|
|
await fetchPosts()
|
|
await fetchUsers()
|
|
|
|
// Si on a un highlight dans la query, scroller vers le post
|
|
if (route.query.highlight) {
|
|
await nextTick()
|
|
await scrollToPost(parseInt(route.query.highlight))
|
|
}
|
|
|
|
// Rafraîchir les dates relatives toutes les 30 secondes
|
|
dateRefreshInterval.value = setInterval(() => {
|
|
dateRefreshKey.value++
|
|
}, 30000)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (dateRefreshInterval.value) {
|
|
clearInterval(dateRefreshInterval.value)
|
|
}
|
|
})
|
|
</script>
|