Files
LeDiscord/frontend/src/views/Posts.vue
EvanChal 7ca168c34c
All checks were successful
Deploy to Development / build-and-deploy (push) Successful in 22s
Deploy to Production / build-and-deploy (push) Successful in 4s
fix
2026-01-28 22:31:17 +01:00

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>