fix(pwa+timezone)
This commit is contained in:
97
frontend/public/sw-custom.js
Normal file
97
frontend/public/sw-custom.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
// Service Worker personnalisé pour gérer les notifications push
|
||||||
|
// Ce fichier sera fusionné avec le service worker généré par vite-plugin-pwa
|
||||||
|
|
||||||
|
// Écouter les événements de notification
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
console.log('Notification clicked:', event.notification)
|
||||||
|
|
||||||
|
event.notification.close()
|
||||||
|
|
||||||
|
// Récupérer le lien depuis les données de la notification
|
||||||
|
const link = event.notification.data?.link || event.notification.data?.url || '/'
|
||||||
|
|
||||||
|
// Ouvrir ou focus la fenêtre/clients
|
||||||
|
event.waitUntil(
|
||||||
|
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||||
|
// Si une fenêtre est déjà ouverte, la focus
|
||||||
|
for (let i = 0; i < clientList.length; i++) {
|
||||||
|
const client = clientList[i]
|
||||||
|
if (client.url && 'focus' in client) {
|
||||||
|
// Naviguer vers le lien si nécessaire
|
||||||
|
if (link && !client.url.includes(link.split('/')[1])) {
|
||||||
|
return client.navigate(link).then(() => client.focus())
|
||||||
|
}
|
||||||
|
return client.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sinon, ouvrir une nouvelle fenêtre
|
||||||
|
if (clients.openWindow) {
|
||||||
|
return clients.openWindow(link || '/')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Écouter les messages du client pour afficher des notifications
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
console.log('Service Worker received message:', event.data)
|
||||||
|
|
||||||
|
if (event.data && event.data.type === 'SHOW_NOTIFICATION') {
|
||||||
|
const { title, options } = event.data
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(title, {
|
||||||
|
icon: '/icon-192x192.png',
|
||||||
|
badge: '/icon-96x96.png',
|
||||||
|
tag: 'lediscord-notification',
|
||||||
|
requireInteraction: false,
|
||||||
|
vibrate: [200, 100, 200],
|
||||||
|
data: {
|
||||||
|
link: options.link || '/',
|
||||||
|
notificationId: options.data?.notificationId
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Écouter les push events (pour les vraies push notifications depuis le serveur)
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
console.log('Push event received:', event)
|
||||||
|
|
||||||
|
let notificationData = {
|
||||||
|
title: 'LeDiscord',
|
||||||
|
body: 'Vous avez une nouvelle notification',
|
||||||
|
icon: '/icon-192x192.png',
|
||||||
|
badge: '/icon-96x96.png',
|
||||||
|
tag: 'lediscord-notification',
|
||||||
|
data: {
|
||||||
|
link: '/'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si des données sont envoyées avec le push
|
||||||
|
if (event.data) {
|
||||||
|
try {
|
||||||
|
const data = event.data.json()
|
||||||
|
notificationData = {
|
||||||
|
...notificationData,
|
||||||
|
title: data.title || notificationData.title,
|
||||||
|
body: data.body || data.message || notificationData.body,
|
||||||
|
data: {
|
||||||
|
link: data.link || '/',
|
||||||
|
notificationId: data.notificationId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing push data:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(notificationData.title, notificationData)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
@@ -8,15 +8,24 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import DefaultLayout from '@/layouts/DefaultLayout.vue'
|
import DefaultLayout from '@/layouts/DefaultLayout.vue'
|
||||||
import AuthLayout from '@/layouts/AuthLayout.vue'
|
import AuthLayout from '@/layouts/AuthLayout.vue'
|
||||||
import EnvironmentDebug from '@/components/EnvironmentDebug.vue'
|
import EnvironmentDebug from '@/components/EnvironmentDebug.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const layout = computed(() => {
|
const layout = computed(() => {
|
||||||
return route.meta.layout === 'auth' ? AuthLayout : DefaultLayout
|
return route.meta.layout === 'auth' ? AuthLayout : DefaultLayout
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Restaurer la session au démarrage de l'app
|
||||||
|
onMounted(async () => {
|
||||||
|
if (authStore.token && !authStore.user) {
|
||||||
|
await authStore.fetchCurrentUser()
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
243
frontend/src/components/PWAInstallTutorial.vue
Normal file
243
frontend/src/components/PWAInstallTutorial.vue
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
<template>
|
||||||
|
<transition
|
||||||
|
enter-active-class="transition ease-out duration-300"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
enter-to-class="opacity-100"
|
||||||
|
leave-active-class="transition ease-in duration-200"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="show"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60 p-4"
|
||||||
|
@click.self="close"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-white rounded-2xl shadow-2xl max-w-md w-full max-h-[90vh] overflow-y-auto"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between rounded-t-2xl">
|
||||||
|
<h3 class="text-xl font-bold text-gray-900">Installer l'application</h3>
|
||||||
|
<button
|
||||||
|
@click="close"
|
||||||
|
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
<X class="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="px-6 py-6">
|
||||||
|
<div v-if="isIOS" class="space-y-6">
|
||||||
|
<!-- Step 1 -->
|
||||||
|
<div class="flex items-start space-x-4">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-lg shadow-lg">
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 pt-1">
|
||||||
|
<h4 class="font-semibold text-gray-900 mb-2">Appuyez sur le bouton de partage</h4>
|
||||||
|
<p class="text-sm text-gray-600 mb-3">
|
||||||
|
En bas de l'écran, dans la barre d'outils Safari
|
||||||
|
</p>
|
||||||
|
<div class="bg-gray-50 rounded-lg p-4 border-2 border-dashed border-gray-200">
|
||||||
|
<div class="flex items-center justify-center space-x-2">
|
||||||
|
<div class="w-12 h-12 bg-gray-200 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<ArrowDown class="w-5 h-5 text-gray-400" />
|
||||||
|
<p class="text-xs text-gray-500">Bouton de partage</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2 -->
|
||||||
|
<div class="flex items-start space-x-4">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-lg shadow-lg">
|
||||||
|
2
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 pt-1">
|
||||||
|
<h4 class="font-semibold text-gray-900 mb-2">Sélectionnez "Sur l'écran d'accueil"</h4>
|
||||||
|
<p class="text-sm text-gray-600 mb-3">
|
||||||
|
Faites défiler le menu de partage vers le haut
|
||||||
|
</p>
|
||||||
|
<div class="bg-gradient-to-br from-primary-50 to-purple-50 rounded-lg p-4 border-2 border-primary-200">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="w-10 h-10 bg-primary-100 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-semibold text-primary-700 text-sm">Sur l'écran d'accueil</p>
|
||||||
|
<p class="text-xs text-primary-600">Ajouter à l'écran d'accueil</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3 -->
|
||||||
|
<div class="flex items-start space-x-4">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-lg shadow-lg">
|
||||||
|
3
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 pt-1">
|
||||||
|
<h4 class="font-semibold text-gray-900 mb-2">Confirmez l'installation</h4>
|
||||||
|
<p class="text-sm text-gray-600 mb-3">
|
||||||
|
Appuyez sur "Ajouter" dans la popup de confirmation
|
||||||
|
</p>
|
||||||
|
<div class="bg-green-50 rounded-lg p-4 border-2 border-green-200">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<CheckCircle class="w-6 h-6 text-green-600" />
|
||||||
|
<p class="text-sm text-green-700 font-medium">L'application apparaîtra sur votre écran d'accueil !</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Android -->
|
||||||
|
<div v-else class="space-y-6">
|
||||||
|
<!-- Step 1 -->
|
||||||
|
<div class="flex items-start space-x-4">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-lg shadow-lg">
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 pt-1">
|
||||||
|
<h4 class="font-semibold text-gray-900 mb-2">Appuyez sur le menu</h4>
|
||||||
|
<p class="text-sm text-gray-600 mb-3">
|
||||||
|
En haut à droite de votre navigateur (⋮)
|
||||||
|
</p>
|
||||||
|
<div class="bg-gray-50 rounded-lg p-4 border-2 border-dashed border-gray-200">
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<div class="w-12 h-12 bg-gray-200 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2 -->
|
||||||
|
<div class="flex items-start space-x-4">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-lg shadow-lg">
|
||||||
|
2
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 pt-1">
|
||||||
|
<h4 class="font-semibold text-gray-900 mb-2">Sélectionnez "Ajouter à l'écran d'accueil"</h4>
|
||||||
|
<p class="text-sm text-gray-600 mb-3">
|
||||||
|
Ou "Installer l'application" selon votre navigateur
|
||||||
|
</p>
|
||||||
|
<div class="bg-gradient-to-br from-primary-50 to-purple-50 rounded-lg p-4 border-2 border-primary-200">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="w-10 h-10 bg-primary-100 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-semibold text-primary-700 text-sm">Ajouter à l'écran d'accueil</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3 -->
|
||||||
|
<div class="flex items-start space-x-4">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-lg shadow-lg">
|
||||||
|
3
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 pt-1">
|
||||||
|
<h4 class="font-semibold text-gray-900 mb-2">Confirmez l'installation</h4>
|
||||||
|
<p class="text-sm text-gray-600 mb-3">
|
||||||
|
Appuyez sur "Ajouter" ou "Installer" dans la popup
|
||||||
|
</p>
|
||||||
|
<div class="bg-green-50 rounded-lg p-4 border-2 border-green-200">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<CheckCircle class="w-6 h-6 text-green-600" />
|
||||||
|
<p class="text-sm text-green-700 font-medium">L'application sera installée !</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Benefits -->
|
||||||
|
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<p class="text-sm font-semibold text-gray-900 mb-3">Une fois installée, vous pourrez :</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center space-x-2 text-sm text-gray-600">
|
||||||
|
<CheckCircle class="w-5 h-5 text-green-500 flex-shrink-0" />
|
||||||
|
<span>Accéder rapidement à l'app depuis l'écran d'accueil</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2 text-sm text-gray-600">
|
||||||
|
<CheckCircle class="w-5 h-5 text-green-500 flex-shrink-0" />
|
||||||
|
<span>Recevoir des notifications push</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2 text-sm text-gray-600">
|
||||||
|
<CheckCircle class="w-5 h-5 text-green-500 flex-shrink-0" />
|
||||||
|
<span>Utiliser l'app hors ligne (contenu mis en cache)</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2 text-sm text-gray-600">
|
||||||
|
<CheckCircle class="w-5 h-5 text-green-500 flex-shrink-0" />
|
||||||
|
<span>Rester connecté automatiquement</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="sticky bottom-0 bg-gray-50 border-t border-gray-200 px-6 py-4 rounded-b-2xl">
|
||||||
|
<button
|
||||||
|
@click="close"
|
||||||
|
class="w-full bg-primary-600 hover:bg-primary-700 text-white font-medium py-3 px-4 rounded-lg transition-colors shadow-lg hover:shadow-xl"
|
||||||
|
>
|
||||||
|
J'ai compris
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { X, ArrowDown, CheckCircle } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
isIOS: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close'])
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
@@ -97,8 +97,7 @@ import { useToast } from 'vue-toastification'
|
|||||||
import { MessageSquare, User } from 'lucide-vue-next'
|
import { MessageSquare, User } from 'lucide-vue-next'
|
||||||
import Mentions from '@/components/Mentions.vue'
|
import Mentions from '@/components/Mentions.vue'
|
||||||
import MentionInput from '@/components/MentionInput.vue'
|
import MentionInput from '@/components/MentionInput.vue'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
|
||||||
import { fr } from 'date-fns/locale'
|
|
||||||
import { getMediaUrl } from '@/utils/axios'
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
import axios from '@/utils/axios'
|
import axios from '@/utils/axios'
|
||||||
|
|
||||||
@@ -130,7 +129,7 @@ const commentMentions = ref([])
|
|||||||
const currentUser = computed(() => authStore.user)
|
const currentUser = computed(() => authStore.user)
|
||||||
|
|
||||||
function formatDate(date) {
|
function formatDate(date) {
|
||||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
return formatRelativeDateInFrenchTimezone(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAvatarUrl(avatarUrl) {
|
function getAvatarUrl(avatarUrl) {
|
||||||
|
|||||||
@@ -204,6 +204,13 @@
|
|||||||
<!-- Ticket Floating Button -->
|
<!-- Ticket Floating Button -->
|
||||||
<TicketFloatingButton />
|
<TicketFloatingButton />
|
||||||
|
|
||||||
|
<!-- PWA Install Tutorial -->
|
||||||
|
<PWAInstallTutorial
|
||||||
|
:show="showPWAInstructions"
|
||||||
|
:is-ios="isIOS"
|
||||||
|
@close="showPWAInstructions = false"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Notifications Panel -->
|
<!-- Notifications Panel -->
|
||||||
<transition
|
<transition
|
||||||
enter-active-class="transition ease-out duration-200"
|
enter-active-class="transition ease-out duration-200"
|
||||||
@@ -270,8 +277,7 @@
|
|||||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { format } from 'date-fns'
|
import { formatDateInFrenchTimezone } from '@/utils/dateUtils'
|
||||||
import { fr } from 'date-fns/locale'
|
|
||||||
import { getMediaUrl } from '@/utils/axios'
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
import {
|
import {
|
||||||
Home,
|
Home,
|
||||||
@@ -288,6 +294,7 @@ import {
|
|||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import TicketFloatingButton from '@/components/TicketFloatingButton.vue'
|
import TicketFloatingButton from '@/components/TicketFloatingButton.vue'
|
||||||
|
import PWAInstallTutorial from '@/components/PWAInstallTutorial.vue'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -308,13 +315,16 @@ const deferredPrompt = ref(null)
|
|||||||
const isPWAInstalled = ref(false)
|
const isPWAInstalled = ref(false)
|
||||||
const canInstall = ref(false)
|
const canInstall = ref(false)
|
||||||
const isMobile = ref(false)
|
const isMobile = ref(false)
|
||||||
|
const showPWAInstructions = ref(false)
|
||||||
|
|
||||||
|
const isIOS = computed(() => /iPhone|iPad|iPod/i.test(navigator.userAgent))
|
||||||
|
|
||||||
const user = computed(() => authStore.user)
|
const user = computed(() => authStore.user)
|
||||||
const notifications = computed(() => authStore.notifications)
|
const notifications = computed(() => authStore.notifications)
|
||||||
const unreadNotifications = computed(() => authStore.unreadCount)
|
const unreadNotifications = computed(() => authStore.unreadCount)
|
||||||
|
|
||||||
function formatDate(date) {
|
function formatDate(date) {
|
||||||
return format(new Date(date), 'dd MMM à HH:mm', { locale: fr })
|
return formatDateInFrenchTimezone(date, 'dd MMM à HH:mm')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
@@ -336,11 +346,18 @@ async function handleNotificationClick(notification) {
|
|||||||
await authStore.markNotificationRead(notification.id)
|
await authStore.markNotificationRead(notification.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notification.link) {
|
|
||||||
router.push(notification.link)
|
|
||||||
}
|
|
||||||
|
|
||||||
showNotifications.value = false
|
showNotifications.value = false
|
||||||
|
|
||||||
|
if (notification.link) {
|
||||||
|
// Gérer les liens de posts différemment car il n'y a pas de route /posts/:id
|
||||||
|
if (notification.link.startsWith('/posts/')) {
|
||||||
|
const postId = notification.link.split('/posts/')[1]
|
||||||
|
// Naviguer vers /posts et passer l'ID en query pour scroll
|
||||||
|
router.push({ path: '/posts', query: { highlight: postId } })
|
||||||
|
} else {
|
||||||
|
router.push(notification.link)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PWA Installation logic
|
// PWA Installation logic
|
||||||
@@ -398,20 +415,19 @@ async function handleInstallApp() {
|
|||||||
console.error('Erreur lors de l\'installation:', error)
|
console.error('Erreur lors de l\'installation:', error)
|
||||||
}
|
}
|
||||||
} else if (isMobile.value) {
|
} else if (isMobile.value) {
|
||||||
// Sur mobile sans beforeinstallprompt, afficher les instructions
|
// Sur mobile sans beforeinstallprompt, afficher le tutoriel
|
||||||
showUserMenu.value = false
|
showUserMenu.value = false
|
||||||
const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent)
|
showPWAInstructions.value = true
|
||||||
if (isIOS) {
|
|
||||||
alert('Sur iOS :\n1. Appuyez sur le bouton de partage (□↑) en bas de l\'écran\n2. Faites défiler et sélectionnez "Sur l\'écran d\'accueil"\n3. Appuyez sur "Ajouter"')
|
|
||||||
} else {
|
|
||||||
alert('Sur Android :\n1. Appuyez sur le menu (⋮) en haut à droite\n2. Sélectionnez "Ajouter à l\'écran d\'accueil" ou "Installer l\'application"\n3. Confirmez l\'installation')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
checkIfMobile()
|
checkIfMobile()
|
||||||
await authStore.fetchCurrentUser()
|
|
||||||
|
// Restaurer la session si un token existe
|
||||||
|
if (authStore.token) {
|
||||||
|
await authStore.fetchCurrentUser()
|
||||||
|
}
|
||||||
|
|
||||||
if (authStore.isAuthenticated) {
|
if (authStore.isAuthenticated) {
|
||||||
await fetchNotifications()
|
await fetchNotifications()
|
||||||
|
|||||||
@@ -51,11 +51,25 @@ class NotificationService {
|
|||||||
.slice(0, result.newCount - result.previousCount)
|
.slice(0, result.newCount - result.previousCount)
|
||||||
|
|
||||||
if (newUnreadNotifications.length > 0) {
|
if (newUnreadNotifications.length > 0) {
|
||||||
|
// Ne pas afficher de notification si l'app est en focus
|
||||||
|
// (pour éviter les doublons avec les notifications dans l'app)
|
||||||
|
if (document.hasFocus()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Afficher une notification push pour la plus récente
|
// Afficher une notification push pour la plus récente
|
||||||
const latestNotification = newUnreadNotifications[0]
|
const latestNotification = newUnreadNotifications[0]
|
||||||
|
|
||||||
|
// Gérer les liens de posts différemment
|
||||||
|
let link = latestNotification.link || '/'
|
||||||
|
if (link.startsWith('/posts/')) {
|
||||||
|
const postId = link.split('/posts/')[1]
|
||||||
|
link = `/posts?highlight=${postId}`
|
||||||
|
}
|
||||||
|
|
||||||
await this.showPushNotification(latestNotification.title, {
|
await this.showPushNotification(latestNotification.title, {
|
||||||
body: latestNotification.message,
|
body: latestNotification.message,
|
||||||
link: latestNotification.link || '/',
|
link: link,
|
||||||
data: { notificationId: latestNotification.id }
|
data: { notificationId: latestNotification.id }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -98,16 +112,44 @@ class NotificationService {
|
|||||||
const hasPermission = await this.requestNotificationPermission()
|
const hasPermission = await this.requestNotificationPermission()
|
||||||
if (!hasPermission) return
|
if (!hasPermission) return
|
||||||
|
|
||||||
// Si on est dans un service worker, utiliser la notification API du SW
|
// Toujours utiliser le service worker pour les notifications push
|
||||||
|
// Cela permet aux notifications de fonctionner même quand l'app est fermée
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
try {
|
try {
|
||||||
const registration = await navigator.serviceWorker.ready
|
const registration = await navigator.serviceWorker.ready
|
||||||
|
|
||||||
|
// Envoyer un message au service worker pour afficher la notification
|
||||||
|
// Cela permet de gérer les clics correctement
|
||||||
|
registration.active?.postMessage({
|
||||||
|
type: 'SHOW_NOTIFICATION',
|
||||||
|
title,
|
||||||
|
options: {
|
||||||
|
body: options.body || options.message || '',
|
||||||
|
icon: '/icon-192x192.png',
|
||||||
|
badge: '/icon-96x96.png',
|
||||||
|
tag: 'lediscord-notification',
|
||||||
|
requireInteraction: false,
|
||||||
|
vibrate: [200, 100, 200],
|
||||||
|
data: {
|
||||||
|
link: options.link || '/',
|
||||||
|
notificationId: options.data?.notificationId
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Aussi utiliser l'API directe comme fallback
|
||||||
await registration.showNotification(title, {
|
await registration.showNotification(title, {
|
||||||
icon: '/icon-192x192.png',
|
icon: '/icon-192x192.png',
|
||||||
badge: '/icon-96x96.png',
|
badge: '/icon-96x96.png',
|
||||||
tag: 'lediscord-notification',
|
tag: 'lediscord-notification',
|
||||||
requireInteraction: false,
|
requireInteraction: false,
|
||||||
vibrate: [200, 100, 200],
|
vibrate: [200, 100, 200],
|
||||||
|
data: {
|
||||||
|
link: options.link || '/',
|
||||||
|
notificationId: options.data?.notificationId
|
||||||
|
},
|
||||||
|
body: options.body || options.message || '',
|
||||||
...options
|
...options
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -116,12 +158,13 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: notification native du navigateur
|
// Fallback: notification native du navigateur (seulement si le SW n'est pas disponible)
|
||||||
const notification = new Notification(title, {
|
const notification = new Notification(title, {
|
||||||
icon: '/icon-192x192.png',
|
icon: '/icon-192x192.png',
|
||||||
badge: '/icon-96x96.png',
|
badge: '/icon-96x96.png',
|
||||||
tag: 'lediscord-notification',
|
tag: 'lediscord-notification',
|
||||||
requireInteraction: false,
|
requireInteraction: false,
|
||||||
|
body: options.body || options.message || '',
|
||||||
...options
|
...options
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -130,7 +173,10 @@ class NotificationService {
|
|||||||
notification.close()
|
notification.close()
|
||||||
|
|
||||||
if (options.link) {
|
if (options.link) {
|
||||||
window.location.href = options.link
|
// Utiliser le router si disponible
|
||||||
|
if (window.location.pathname !== options.link) {
|
||||||
|
window.location.href = options.link
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const token = ref(localStorage.getItem('token'))
|
const token = ref(localStorage.getItem('token'))
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
const isAuthenticated = computed(() => !!token.value)
|
const isAuthenticated = computed(() => !!token.value && !!user.value)
|
||||||
const isAdmin = computed(() => user.value?.is_admin || false)
|
const isAdmin = computed(() => user.value?.is_admin || false)
|
||||||
|
|
||||||
if (token.value) {
|
if (token.value) {
|
||||||
|
|||||||
66
frontend/src/utils/dateUtils.js
Normal file
66
frontend/src/utils/dateUtils.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { format, formatDistanceToNow } from 'date-fns'
|
||||||
|
import { fr } from 'date-fns/locale'
|
||||||
|
import { zonedTimeToUtc, utcToZonedTime } from 'date-fns-tz'
|
||||||
|
|
||||||
|
// Fuseau horaire français
|
||||||
|
const FRENCH_TIMEZONE = 'Europe/Paris'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit une date UTC en date du fuseau horaire français
|
||||||
|
*/
|
||||||
|
function toFrenchTimezone(date) {
|
||||||
|
if (!date) return null
|
||||||
|
const dateObj = date instanceof Date ? date : new Date(date)
|
||||||
|
return utcToZonedTime(dateObj, FRENCH_TIMEZONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formate une date dans le fuseau horaire français
|
||||||
|
*/
|
||||||
|
export function formatDateInFrenchTimezone(date, formatStr = 'dd MMM à HH:mm') {
|
||||||
|
if (!date) return ''
|
||||||
|
const frenchDate = toFrenchTimezone(date)
|
||||||
|
return format(frenchDate, formatStr, { locale: fr })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formate une date relative dans le fuseau horaire français
|
||||||
|
*/
|
||||||
|
export function formatRelativeDateInFrenchTimezone(date) {
|
||||||
|
if (!date) return ''
|
||||||
|
const frenchDate = toFrenchTimezone(date)
|
||||||
|
return formatDistanceToNow(frenchDate, { addSuffix: true, locale: fr })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formate une date complète dans le fuseau horaire français
|
||||||
|
*/
|
||||||
|
export function formatFullDateInFrenchTimezone(date) {
|
||||||
|
return formatDateInFrenchTimezone(date, 'EEEE d MMMM yyyy à HH:mm')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formate une date courte dans le fuseau horaire français
|
||||||
|
*/
|
||||||
|
export function formatShortDateInFrenchTimezone(date) {
|
||||||
|
return formatDateInFrenchTimezone(date, 'dd/MM/yyyy')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formate une date pour un input datetime-local dans le fuseau horaire français
|
||||||
|
*/
|
||||||
|
export function formatDateForInputInFrenchTimezone(date) {
|
||||||
|
if (!date) return ''
|
||||||
|
const frenchDate = toFrenchTimezone(date)
|
||||||
|
return format(frenchDate, "yyyy-MM-dd'T'HH:mm", { locale: fr })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit une date du fuseau horaire français vers UTC pour l'envoyer au backend
|
||||||
|
*/
|
||||||
|
export function convertFrenchTimezoneToUTC(date) {
|
||||||
|
if (!date) return null
|
||||||
|
const dateObj = date instanceof Date ? date : new Date(date)
|
||||||
|
return zonedTimeToUtc(dateObj, FRENCH_TIMEZONE)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1239,8 +1239,7 @@ import {
|
|||||||
TestTube,
|
TestTube,
|
||||||
Plus
|
Plus
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
|
||||||
import { fr } from 'date-fns/locale'
|
|
||||||
import LoadingLogo from '@/components/LoadingLogo.vue'
|
import LoadingLogo from '@/components/LoadingLogo.vue'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
@@ -1710,7 +1709,7 @@ function getPriorityBadgeClass(priority) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(date) {
|
function formatDate(date) {
|
||||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
return formatRelativeDateInFrenchTimezone(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gestion des tickets
|
// Gestion des tickets
|
||||||
|
|||||||
@@ -567,8 +567,7 @@ import { useAuthStore } from '@/stores/auth'
|
|||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import axios from '@/utils/axios'
|
import axios from '@/utils/axios'
|
||||||
import { getMediaUrl } from '@/utils/axios'
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
import { format, formatDistanceToNow } from 'date-fns'
|
import { formatDateInFrenchTimezone, formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
|
||||||
import { fr } from 'date-fns/locale'
|
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Image,
|
Image,
|
||||||
@@ -618,7 +617,7 @@ const totalSize = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
function formatDate(date) {
|
function formatDate(date) {
|
||||||
return format(new Date(date), 'dd MMMM yyyy', { locale: fr })
|
return formatDateInFrenchTimezone(date, 'dd MMMM yyyy')
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(bytes) {
|
function formatBytes(bytes) {
|
||||||
|
|||||||
@@ -335,8 +335,7 @@ import { useToast } from 'vue-toastification'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import axios from '@/utils/axios'
|
import axios from '@/utils/axios'
|
||||||
import { getMediaUrl } from '@/utils/axios'
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
import { formatDistanceToNow, format } from 'date-fns'
|
import { formatRelativeDateInFrenchTimezone, formatShortDateInFrenchTimezone } from '@/utils/dateUtils'
|
||||||
import { fr } from 'date-fns/locale'
|
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Image,
|
Image,
|
||||||
@@ -384,11 +383,11 @@ const uploadSuccess = ref([])
|
|||||||
const isDragOver = ref(false)
|
const isDragOver = ref(false)
|
||||||
|
|
||||||
function formatRelativeDate(date) {
|
function formatRelativeDate(date) {
|
||||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
return formatRelativeDateInFrenchTimezone(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(date) {
|
function formatDate(date) {
|
||||||
return format(new Date(date), 'dd/MM/yyyy', { locale: fr })
|
return formatShortDateInFrenchTimezone(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFileSize(bytes) {
|
function formatFileSize(bytes) {
|
||||||
|
|||||||
@@ -342,8 +342,7 @@ import { useAuthStore } from '@/stores/auth'
|
|||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import axios from '@/utils/axios'
|
import axios from '@/utils/axios'
|
||||||
import { getMediaUrl } from '@/utils/axios'
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
import { format, formatDistanceToNow } from 'date-fns'
|
import { formatFullDateInFrenchTimezone, formatRelativeDateInFrenchTimezone, formatDateForInputInFrenchTimezone, convertFrenchTimezoneToUTC } from '@/utils/dateUtils'
|
||||||
import { fr } from 'date-fns/locale'
|
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Calendar,
|
Calendar,
|
||||||
@@ -379,11 +378,11 @@ const canEdit = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
function formatDate(date) {
|
function formatDate(date) {
|
||||||
return format(new Date(date), 'EEEE d MMMM yyyy à HH:mm', { locale: fr })
|
return formatFullDateInFrenchTimezone(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRelativeDate(date) {
|
function formatRelativeDate(date) {
|
||||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
return formatRelativeDateInFrenchTimezone(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getParticipationClass(status) {
|
function getParticipationClass(status) {
|
||||||
@@ -444,7 +443,7 @@ async function fetchEvent() {
|
|||||||
editForm.value = {
|
editForm.value = {
|
||||||
title: event.value.title,
|
title: event.value.title,
|
||||||
description: event.value.description || '',
|
description: event.value.description || '',
|
||||||
date: format(new Date(event.value.date), "yyyy-MM-dd'T'HH:mm", { locale: fr }),
|
date: formatDateForInputInFrenchTimezone(event.value.date),
|
||||||
location: event.value.location || ''
|
location: event.value.location || ''
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -470,7 +469,7 @@ async function updateEvent() {
|
|||||||
try {
|
try {
|
||||||
const response = await axios.put(`/api/events/${event.value.id}`, {
|
const response = await axios.put(`/api/events/${event.value.id}`, {
|
||||||
...editForm.value,
|
...editForm.value,
|
||||||
date: new Date(editForm.value.date).toISOString()
|
date: convertFrenchTimezoneToUTC(new Date(editForm.value.date)).toISOString()
|
||||||
})
|
})
|
||||||
event.value = response.data
|
event.value = response.data
|
||||||
showEditModal.value = false
|
showEditModal.value = false
|
||||||
|
|||||||
@@ -333,8 +333,7 @@ import { useToast } from 'vue-toastification'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import axios from '@/utils/axios'
|
import axios from '@/utils/axios'
|
||||||
import { getMediaUrl } from '@/utils/axios'
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
import { formatDistanceToNow, format } from 'date-fns'
|
import { formatRelativeDateInFrenchTimezone, formatShortDateInFrenchTimezone } from '@/utils/dateUtils'
|
||||||
import { fr } from 'date-fns/locale'
|
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Calendar,
|
Calendar,
|
||||||
@@ -380,11 +379,11 @@ const filteredEvents = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function formatRelativeDate(date) {
|
function formatRelativeDate(date) {
|
||||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
return formatRelativeDateInFrenchTimezone(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(date) {
|
function formatDate(date) {
|
||||||
return format(new Date(date), 'dd/MM/yyyy', { locale: fr })
|
return formatShortDateInFrenchTimezone(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEvent(event) {
|
function openEvent(event) {
|
||||||
@@ -481,9 +480,9 @@ async function createEvent() {
|
|||||||
const eventData = {
|
const eventData = {
|
||||||
title: newEvent.value.title,
|
title: newEvent.value.title,
|
||||||
description: newEvent.value.description,
|
description: newEvent.value.description,
|
||||||
date: new Date(newEvent.value.date).toISOString(),
|
date: convertFrenchTimezoneToUTC(new Date(newEvent.value.date)).toISOString(),
|
||||||
location: newEvent.value.location,
|
location: newEvent.value.location,
|
||||||
end_date: newEvent.value.end_date ? new Date(newEvent.value.end_date).toISOString() : null,
|
end_date: newEvent.value.end_date ? convertFrenchTimezoneToUTC(new Date(newEvent.value.end_date)).toISOString() : null,
|
||||||
is_private: newEvent.value.is_private,
|
is_private: newEvent.value.is_private,
|
||||||
invited_user_ids: newEvent.value.is_private ? newEvent.value.invited_user_ids : null
|
invited_user_ids: newEvent.value.is_private ? newEvent.value.invited_user_ids : null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,8 +220,7 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import axios from '@/utils/axios'
|
import axios from '@/utils/axios'
|
||||||
import { getMediaUrl } from '@/utils/axios'
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
import { format, formatDistanceToNow } from 'date-fns'
|
import { formatFullDateInFrenchTimezone, formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
|
||||||
import { fr } from 'date-fns/locale'
|
|
||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
@@ -248,11 +247,11 @@ const recentPosts = computed(() => stats.value.recent_posts || 0)
|
|||||||
const activeMembers = computed(() => stats.value.total_users || 0)
|
const activeMembers = computed(() => stats.value.total_users || 0)
|
||||||
|
|
||||||
function formatDate(date) {
|
function formatDate(date) {
|
||||||
return format(new Date(date), 'EEEE d MMMM à HH:mm', { locale: fr })
|
return formatFullDateInFrenchTimezone(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRelativeDate(date) {
|
function formatRelativeDate(date) {
|
||||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
return formatRelativeDateInFrenchTimezone(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchDashboardData() {
|
async function fetchDashboardData() {
|
||||||
|
|||||||
@@ -103,8 +103,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
|
||||||
import { fr } from 'date-fns/locale'
|
|
||||||
import axios from '@/utils/axios'
|
import axios from '@/utils/axios'
|
||||||
import LoadingLogo from '@/components/LoadingLogo.vue'
|
import LoadingLogo from '@/components/LoadingLogo.vue'
|
||||||
|
|
||||||
@@ -154,7 +153,7 @@ function getCategoryBadgeClass(category) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(date) {
|
function formatDate(date) {
|
||||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
return formatRelativeDateInFrenchTimezone(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchInformations() {
|
async function fetchInformations() {
|
||||||
|
|||||||
@@ -306,8 +306,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
|
||||||
import { fr } from 'date-fns/locale'
|
|
||||||
import { Save } from 'lucide-vue-next'
|
import { Save } from 'lucide-vue-next'
|
||||||
import axios from '@/utils/axios'
|
import axios from '@/utils/axios'
|
||||||
import { getMediaUrl } from '@/utils/axios'
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
@@ -419,7 +418,7 @@ function getPriorityBadgeClass(priority) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(date) {
|
function formatDate(date) {
|
||||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
return formatRelativeDateInFrenchTimezone(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetTicketForm() {
|
function resetTicketForm() {
|
||||||
|
|||||||
@@ -99,7 +99,9 @@
|
|||||||
<div
|
<div
|
||||||
v-for="post in posts"
|
v-for="post in posts"
|
||||||
:key="post.id"
|
:key="post.id"
|
||||||
class="card p-4 sm:p-6"
|
: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 -->
|
<!-- Post Header -->
|
||||||
<div class="flex items-start space-x-3 mb-4">
|
<div class="flex items-start space-x-3 mb-4">
|
||||||
@@ -282,13 +284,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import axios from '@/utils/axios'
|
import axios from '@/utils/axios'
|
||||||
import { getMediaUrl } from '@/utils/axios'
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
|
||||||
import { fr } from 'date-fns/locale'
|
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
User,
|
User,
|
||||||
@@ -305,6 +307,8 @@ import MentionInput from '@/components/MentionInput.vue'
|
|||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const user = computed(() => authStore.user)
|
const user = computed(() => authStore.user)
|
||||||
const posts = ref([])
|
const posts = ref([])
|
||||||
@@ -324,7 +328,7 @@ const newPost = ref({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function formatRelativeDate(date) {
|
function formatRelativeDate(date) {
|
||||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
return formatRelativeDateInFrenchTimezone(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMentionsChanged(mentions) {
|
function handleMentionsChanged(mentions) {
|
||||||
@@ -514,8 +518,36 @@ function canDeleteComment(comment) {
|
|||||||
return user.value && (comment.author_id === user.value.id || user.value.is_admin)
|
return user.value && (comment.author_id === user.value.id || user.value.is_admin)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
// Fonction pour scroller vers un post spécifique
|
||||||
fetchPosts()
|
async function scrollToPost(postId) {
|
||||||
fetchUsers()
|
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))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -159,8 +159,7 @@ import { useAuthStore } from '@/stores/auth'
|
|||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import axios from '@/utils/axios'
|
import axios from '@/utils/axios'
|
||||||
import { getMediaUrl } from '@/utils/axios'
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
import { format } from 'date-fns'
|
import { formatDateInFrenchTimezone } from '@/utils/dateUtils'
|
||||||
import { fr } from 'date-fns/locale'
|
|
||||||
import { User, Camera } from 'lucide-vue-next'
|
import { User, Camera } from 'lucide-vue-next'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
@@ -179,7 +178,7 @@ const form = ref({
|
|||||||
|
|
||||||
function formatDate(date) {
|
function formatDate(date) {
|
||||||
if (!date) return ''
|
if (!date) return ''
|
||||||
return format(new Date(date), 'MMMM yyyy', { locale: fr })
|
return formatDateInFrenchTimezone(date, 'MMMM yyyy')
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
|
|||||||
@@ -466,13 +466,9 @@ async function handleInstallApp() {
|
|||||||
console.error('Erreur lors de l\'installation:', error)
|
console.error('Erreur lors de l\'installation:', error)
|
||||||
}
|
}
|
||||||
} else if (isMobile.value) {
|
} else if (isMobile.value) {
|
||||||
// Sur mobile sans beforeinstallprompt, afficher les instructions
|
// Sur mobile sans beforeinstallprompt, on ne peut pas installer directement
|
||||||
const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent)
|
// L'utilisateur devra utiliser le menu une fois connecté
|
||||||
if (isIOS) {
|
console.log('Installation PWA non disponible sur ce navigateur mobile')
|
||||||
alert('Sur iOS :\n1. Appuyez sur le bouton de partage (□↑) en bas de l\'écran\n2. Faites défiler et sélectionnez "Sur l\'écran d\'accueil"\n3. Appuyez sur "Ajouter"')
|
|
||||||
} else {
|
|
||||||
alert('Sur Android :\n1. Appuyez sur le menu (⋮) en haut à droite\n2. Sélectionnez "Ajouter à l\'écran d\'accueil" ou "Installer l\'application"\n3. Confirmez l\'installation')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,8 +123,7 @@ import { useAuthStore } from '@/stores/auth'
|
|||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import axios from '@/utils/axios'
|
import axios from '@/utils/axios'
|
||||||
import { getMediaUrl } from '@/utils/axios'
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
import { format, formatDistanceToNow } from 'date-fns'
|
import { formatDateInFrenchTimezone, formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
|
||||||
import { fr } from 'date-fns/locale'
|
|
||||||
import { User, ArrowLeft, ArrowRight, MessageSquare, Video, Image, Calendar, Activity } from 'lucide-vue-next'
|
import { User, ArrowLeft, ArrowRight, MessageSquare, Video, Image, Calendar, Activity } from 'lucide-vue-next'
|
||||||
import LoadingLogo from '@/components/LoadingLogo.vue'
|
import LoadingLogo from '@/components/LoadingLogo.vue'
|
||||||
|
|
||||||
@@ -140,12 +139,12 @@ const recentActivity = ref([])
|
|||||||
|
|
||||||
function formatDate(date) {
|
function formatDate(date) {
|
||||||
if (!date) return ''
|
if (!date) return ''
|
||||||
return format(new Date(date), 'MMMM yyyy', { locale: fr })
|
return formatDateInFrenchTimezone(date, 'MMMM yyyy')
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRelativeDate(date) {
|
function formatRelativeDate(date) {
|
||||||
if (!date) return ''
|
if (!date) return ''
|
||||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
return formatRelativeDateInFrenchTimezone(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getActivityIcon(type) {
|
function getActivityIcon(type) {
|
||||||
|
|||||||
@@ -228,8 +228,7 @@ import { useAuthStore } from '@/stores/auth'
|
|||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import axios from '@/utils/axios'
|
import axios from '@/utils/axios'
|
||||||
import { getMediaUrl } from '@/utils/axios'
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
import { format, formatDistanceToNow } from 'date-fns'
|
import { formatDateInFrenchTimezone, formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
|
||||||
import { fr } from 'date-fns/locale'
|
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
User,
|
User,
|
||||||
@@ -269,11 +268,11 @@ const canEdit = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
function formatDate(date) {
|
function formatDate(date) {
|
||||||
return format(new Date(date), 'dd MMMM yyyy', { locale: fr })
|
return formatDateInFrenchTimezone(date, 'dd MMMM yyyy')
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRelativeDate(date) {
|
function formatRelativeDate(date) {
|
||||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
return formatRelativeDateInFrenchTimezone(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(seconds) {
|
function formatDuration(seconds) {
|
||||||
|
|||||||
@@ -268,8 +268,7 @@ import { useToast } from 'vue-toastification'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import axios from '@/utils/axios'
|
import axios from '@/utils/axios'
|
||||||
import { getMediaUrl } from '@/utils/axios'
|
import { getMediaUrl } from '@/utils/axios'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
|
||||||
import { fr } from 'date-fns/locale'
|
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Film,
|
Film,
|
||||||
@@ -312,7 +311,7 @@ function formatDuration(seconds) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatRelativeDate(date) {
|
function formatRelativeDate(date) {
|
||||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
return formatRelativeDateInFrenchTimezone(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openVlog(vlog) {
|
function openVlog(vlog) {
|
||||||
|
|||||||
@@ -179,6 +179,8 @@ module.exports = defineConfig(({ command, mode }) => {
|
|||||||
navigateFallback: null,
|
navigateFallback: null,
|
||||||
skipWaiting: true,
|
skipWaiting: true,
|
||||||
clientsClaim: true,
|
clientsClaim: true,
|
||||||
|
// Intégrer le service worker personnalisé
|
||||||
|
importScripts: ['/sw-custom.js'],
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
{
|
{
|
||||||
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
|
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
|
||||||
|
|||||||
Reference in New Issue
Block a user