feat(front+back): pwa added, register parkour update with it, and jeux added in coming soon
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 20s

This commit is contained in:
EvanChal
2026-01-25 19:28:21 +01:00
parent 0020c13bfd
commit 5bbe05000e
27 changed files with 14084 additions and 222 deletions

View File

@@ -8,6 +8,7 @@ from config.database import get_db
from config.settings import settings from config.settings import settings
from models.vlog import Vlog, VlogLike, VlogComment, VlogView from models.vlog import Vlog, VlogLike, VlogComment, VlogView
from models.user import User from models.user import User
from models.notification import Notification, NotificationType
from schemas.vlog import VlogCreate, VlogUpdate, VlogResponse, VlogCommentCreate from schemas.vlog import VlogCreate, VlogUpdate, VlogResponse, VlogCommentCreate
from utils.security import get_current_active_user from utils.security import get_current_active_user
from utils.video_utils import generate_video_thumbnail, get_video_duration from utils.video_utils import generate_video_thumbnail, get_video_duration
@@ -332,6 +333,22 @@ async def upload_vlog_video(
db.commit() db.commit()
db.refresh(vlog) db.refresh(vlog)
# Create notifications for all active users (except the creator)
users = db.query(User).filter(User.is_active == True).all()
for user in users:
if user.id != current_user.id:
notification = Notification(
user_id=user.id,
type=NotificationType.NEW_VLOG,
title="Nouveau vlog",
message=f"{current_user.full_name} a publié un nouveau vlog : {vlog.title}",
link=f"/vlogs/{vlog.id}",
is_read=False
)
db.add(notification)
db.commit()
return format_vlog_response(vlog, db, current_user.id) return format_vlog_response(vlog, db, current_user.id)
def format_vlog_response(vlog: Vlog, db: Session, current_user_id: int) -> dict: def format_vlog_response(vlog: Vlog, db: Session, current_user_id: int) -> dict:

51
frontend/PWA_SETUP.md Normal file
View File

@@ -0,0 +1,51 @@
# Configuration PWA - LeDiscord
## Installation
1. **Installer les dépendances PWA :**
```bash
npm install --save-dev vite-plugin-pwa sharp
```
2. **Générer les icônes PWA :**
```bash
npm run generate-icons
```
Cette commande génère automatiquement toutes les icônes nécessaires (72x72, 96x96, 128x128, 144x144, 152x152, 192x192, 384x384, 512x512) à partir du logo `public/logo_lediscord.png`.
## Fonctionnalités PWA
### ✅ Fonctionnalités implémentées
- **Service Worker** : Cache automatique des assets statiques
- **Manifest.json** : Configuration complète de l'application
- **Icônes** : Support multi-tailles pour tous les appareils
- **Offline** : Cache des ressources pour fonctionner hors ligne
- **Installation** : L'application peut être installée sur mobile et desktop
### 📱 Cache Strategy
- **Assets statiques** : Cache First (JS, CSS, images, vidéos)
- **API** : Network First avec cache de 5 minutes
- **Uploads** : Cache First avec expiration de 7 jours
- **Fonts Google** : Cache First avec expiration de 1 an
### 🔧 Configuration
La configuration PWA se trouve dans `vite.config.js` dans le plugin `VitePWA`.
### 🚀 Build Production
Lors du build en production, le service worker sera automatiquement généré :
```bash
npm run build
```
### 📝 Notes
- Le service worker est activé en développement (`devOptions.enabled: true`)
- Les mises à jour sont automatiques (`registerType: 'autoUpdate'`)
- Les icônes doivent être générées avant le premier build

View File

@@ -0,0 +1 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })

128
frontend/dev-dist/sw.js Normal file
View File

@@ -0,0 +1,128 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-47da91e0'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "registerSW.js",
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.abqp38bc5fg"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/]
}));
workbox.registerRoute(/^https:\/\/fonts\.googleapis\.com\/.*/i, new workbox.CacheFirst({
"cacheName": "google-fonts-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 10,
maxAgeSeconds: 31536000
}), new workbox.CacheableResponsePlugin({
statuses: [0, 200]
})]
}), 'GET');
workbox.registerRoute(/^https:\/\/fonts\.gstatic\.com\/.*/i, new workbox.CacheFirst({
"cacheName": "gstatic-fonts-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 10,
maxAgeSeconds: 31536000
}), new workbox.CacheableResponsePlugin({
statuses: [0, 200]
})]
}), 'GET');
workbox.registerRoute(/^https?:\/\/.*\/api\/.*/i, new workbox.NetworkFirst({
"cacheName": "api-cache",
"networkTimeoutSeconds": 10,
plugins: [new workbox.ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 300
})]
}), 'GET');
workbox.registerRoute(/^https?:\/\/.*\/uploads\/.*/i, new workbox.CacheFirst({
"cacheName": "uploads-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 604800
}), new workbox.CacheableResponsePlugin({
statuses: [0, 200]
})]
}), 'GET');
}));
//# sourceMappingURL=sw.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -4,6 +4,15 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Plateforme communautaire LeDiscord - Notre espace">
<meta name="theme-color" content="#6366f1">
<!-- PWA Meta Tags -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="LeDiscord">
<link rel="apple-touch-icon" href="/icon-192x192.png">
<title>LeDiscord - Notre espace</title> <title>LeDiscord - Notre espace</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,8 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"generate-icons": "node scripts/generate-icons.js"
}, },
"dependencies": { "dependencies": {
"@vueuse/core": "^10.6.1", "@vueuse/core": "^10.6.1",
@@ -23,8 +24,10 @@
"@vitejs/plugin-vue": "^4.5.0", "@vitejs/plugin-vue": "^4.5.0",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"postcss": "^8.4.31", "postcss": "^8.4.31",
"sharp": "^0.33.5",
"tailwindcss": "^3.3.5", "tailwindcss": "^3.3.5",
"terser": "^5.43.1", "terser": "^5.43.1",
"vite": "^5.0.0" "vite": "^5.0.0",
"vite-plugin-pwa": "^0.20.5"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1,80 @@
{
"name": "LeDiscord - Notre espace",
"short_name": "LeDiscord",
"description": "Plateforme communautaire LeDiscord",
"theme_color": "#6366f1",
"background_color": "#ffffff",
"display": "standalone",
"orientation": "portrait-primary",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"screenshots": [],
"categories": ["social", "entertainment"],
"shortcuts": [
{
"name": "Vlogs",
"short_name": "Vlogs",
"description": "Voir les vlogs",
"url": "/vlogs",
"icons": [{ "src": "/icon-96x96.png", "sizes": "96x96" }]
},
{
"name": "Albums",
"short_name": "Albums",
"description": "Voir les albums",
"url": "/albums",
"icons": [{ "src": "/icon-96x96.png", "sizes": "96x96" }]
}
]
}

View File

@@ -0,0 +1,79 @@
/**
* Script pour générer les icônes PWA à partir du logo
*
* Usage: node scripts/generate-icons.js
*
* Nécessite: npm install --save-dev sharp
*/
const fs = require('fs')
const path = require('path')
// Tailles d'icônes requises pour PWA
const iconSizes = [72, 96, 128, 144, 152, 192, 384, 512]
async function generateIcons() {
try {
// Vérifier si sharp est installé
let sharp
try {
sharp = require('sharp')
} catch (e) {
console.error('❌ Le package "sharp" n\'est pas installé.')
console.log('📦 Installez-le avec: npm install --save-dev sharp')
process.exit(1)
}
const logoPath = path.join(__dirname, '../public/logo_lediscord.png')
const publicDir = path.join(__dirname, '../public')
// Vérifier que le logo existe
if (!fs.existsSync(logoPath)) {
console.error(`❌ Logo introuvable: ${logoPath}`)
process.exit(1)
}
console.log('🎨 Génération des icônes PWA avec fond transparent...')
// Générer chaque taille d'icône
for (const size of iconSizes) {
const outputPath = path.join(publicDir, `icon-${size}x${size}.png`)
// Calculer le padding (10% de la taille) pour éviter que le logo touche les bords
const padding = Math.floor(size * 0.1)
const contentSize = size - (padding * 2)
await sharp(logoPath)
.resize(contentSize, contentSize, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 } // Fond transparent
})
.extend({
top: padding,
bottom: padding,
left: padding,
right: padding,
background: { r: 0, g: 0, b: 0, alpha: 0 } // Fond transparent
})
.resize(size, size, {
kernel: sharp.kernel.lanczos3 // Meilleure qualité de redimensionnement
})
.png({
quality: 100,
compressionLevel: 9
})
.toFile(outputPath)
console.log(`✅ Généré: icon-${size}x${size}.png`)
}
console.log('✨ Toutes les icônes ont été générées avec succès!')
console.log('💡 Les icônes utilisent un fond transparent avec un padding intelligent.')
} catch (error) {
console.error('❌ Erreur lors de la génération des icônes:', error)
process.exit(1)
}
}
generateIcons()

View File

@@ -0,0 +1,152 @@
<template>
<div
v-if="showInstallPrompt"
class="fixed bottom-4 right-4 z-50 max-w-sm bg-white rounded-lg shadow-lg border border-gray-200 p-4 animate-slide-up"
>
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
<div class="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center">
<svg
class="w-6 h-6 text-indigo-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>
<div class="flex-1 min-w-0">
<h3 class="text-sm font-semibold text-gray-900">
Installer LeDiscord
</h3>
<p class="mt-1 text-sm text-gray-500">
Installez l'application pour un accès rapide et une meilleure expérience.
</p>
<div class="mt-3 flex space-x-2">
<button
@click="installApp"
class="flex-1 bg-indigo-600 text-white text-sm font-medium py-2 px-4 rounded-md hover:bg-indigo-700 transition-colors"
>
Installer
</button>
<button
@click="dismissPrompt"
class="flex-1 bg-gray-100 text-gray-700 text-sm font-medium py-2 px-4 rounded-md hover:bg-gray-200 transition-colors"
>
Plus tard
</button>
</div>
</div>
<button
@click="dismissPrompt"
class="flex-shrink-0 text-gray-400 hover:text-gray-500"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
const showInstallPrompt = ref(false)
const deferredPrompt = ref(null)
onMounted(() => {
// Vérifier si l'app est déjà installée
if (window.matchMedia('(display-mode: standalone)').matches) {
return
}
// Vérifier si le prompt a été rejeté récemment
const dismissed = localStorage.getItem('pwa-install-dismissed')
if (dismissed) {
const dismissedTime = parseInt(dismissed, 10)
const daysSinceDismissed = (Date.now() - dismissedTime) / (1000 * 60 * 60 * 24)
// Réafficher après 7 jours
if (daysSinceDismissed < 7) {
return
}
}
// Écouter l'événement beforeinstallprompt
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
})
onBeforeUnmount(() => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
})
function handleBeforeInstallPrompt(e) {
// Empêcher le prompt par défaut
e.preventDefault()
// Stocker l'événement pour l'utiliser plus tard
deferredPrompt.value = e
// Afficher notre prompt personnalisé
showInstallPrompt.value = true
}
async function installApp() {
if (!deferredPrompt.value) {
return
}
try {
// Afficher le prompt d'installation
deferredPrompt.value.prompt()
// Attendre la réponse de l'utilisateur
const { outcome } = await deferredPrompt.value.userChoice
if (outcome === 'accepted') {
console.log('✅ PWA installée avec succès')
} else {
console.log('❌ Installation PWA annulée')
}
// Réinitialiser
deferredPrompt.value = null
showInstallPrompt.value = false
} catch (error) {
console.error('Erreur lors de l\'installation:', error)
}
}
function dismissPrompt() {
showInstallPrompt.value = false
// Enregistrer le rejet avec timestamp
localStorage.setItem('pwa-install-dismissed', Date.now().toString())
}
</script>
<style scoped>
@keyframes slide-up {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
</style>

View File

@@ -1,20 +1,20 @@
<template> <template>
<div class="min-h-screen bg-gradient-discord flex items-center justify-center p-4"> <div class="min-h-screen bg-gradient-discord flex items-center justify-center p-3 sm:p-4 md:p-6">
<div class="w-full max-w-md"> <div class="w-full max-w-md lg:max-w-lg flex flex-col">
<div class="text-center mb-4"> <div class="text-center mb-3 sm:mb-4">
<h1 class="text-4xl font-bold bg-gradient-primary bg-clip-text text-transparent mb-0">LeDiscord</h1> <h1 class="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-primary bg-clip-text text-transparent mb-0">LeDiscord</h1>
<p class="text-secondary-600">Notre espace privé</p> <p class="text-xs sm:text-sm md:text-base text-secondary-600 mt-1">Notre espace privé</p>
</div> </div>
<div class="bg-white rounded-2xl shadow-xl p-8"> <div class="bg-white rounded-xl sm:rounded-2xl shadow-xl p-4 sm:p-6 md:p-8 flex-1 flex flex-col">
<slot /> <slot />
</div> </div>
<div class="text-center mt-4"> <div class="text-center mt-3 sm:mt-4">
<img <img
src="/logo_lediscord.png" src="/logo_lediscord.png"
alt="LeDiscord Logo" alt="LeDiscord Logo"
class="mx-auto h-48 w-auto mb-0 drop-shadow-lg" class="mx-auto h-24 sm:h-32 md:h-40 w-auto drop-shadow-lg"
> >
</div> </div>
</div> </div>

View File

@@ -21,11 +21,21 @@
v-for="item in navigation" v-for="item in navigation"
:key="item.name" :key="item.name"
:to="item.to" :to="item.to"
class="inline-flex items-center px-1 pt-1 text-sm font-medium text-secondary-600 hover:text-primary-600 border-b-2 border-transparent hover:border-primary-600 transition-colors" class="inline-flex items-center px-1 pt-1 text-sm font-medium border-b-2 border-transparent transition-colors relative"
active-class="!text-primary-600 !border-primary-600" :class="item.comingSoon
? 'text-gray-400 cursor-not-allowed opacity-60 !border-transparent'
: 'text-secondary-600 hover:text-primary-600 hover:border-primary-600'"
:active-class="item.comingSoon ? '' : '!text-primary-600 !border-primary-600'"
@click.prevent="item.comingSoon ? null : null"
> >
<component :is="item.icon" class="w-4 h-4 mr-2" /> <component :is="item.icon" class="w-4 h-4 mr-2" />
{{ item.name }} {{ item.name }}
<span
v-if="item.comingSoon"
class="ml-1.5 px-1 py-0.5 text-[9px] font-medium text-white bg-purple-600 rounded"
>
Soon
</span>
</router-link> </router-link>
</div> </div>
</div> </div>
@@ -39,8 +49,10 @@
<Bell class="w-5 h-5" /> <Bell class="w-5 h-5" />
<span <span
v-if="unreadNotifications > 0" v-if="unreadNotifications > 0"
class="absolute top-0 right-0 block h-2 w-2 rounded-full bg-red-500" class="absolute -top-1 -right-1 flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-bold text-white bg-red-500 rounded-full"
/> >
{{ unreadNotifications > 99 ? '99+' : unreadNotifications }}
</span>
</button> </button>
<!-- User Menu --> <!-- User Menu -->
@@ -112,6 +124,25 @@
Administration Administration
</router-link> </router-link>
<hr class="my-1">
<!-- Installer l'app -->
<button
@click="handleInstallApp"
:disabled="isPWAInstalled || !canInstall"
class="block w-full text-left px-4 py-2 text-sm transition-colors"
:class="isPWAInstalled || !canInstall
? 'text-gray-400 cursor-not-allowed'
: 'text-gray-700 hover:bg-gray-100'"
>
<div class="flex items-center">
<svg class="w-4 h-4 mr-2" 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>
{{ isPWAInstalled ? 'Application installée' : 'Installer l\'app' }}
</div>
</button>
<hr class="my-1"> <hr class="my-1">
<button <button
@click="logout" @click="logout"
@@ -145,11 +176,21 @@
v-for="item in navigation" v-for="item in navigation"
:key="item.name" :key="item.name"
:to="item.to" :to="item.to"
class="flex items-center px-4 py-2 text-base font-medium text-gray-600 hover:text-primary-600 hover:bg-gray-50" class="flex items-center px-4 py-2 text-base font-medium hover:bg-gray-50 relative"
:class="item.comingSoon
? 'text-gray-400 cursor-not-allowed opacity-60'
: 'text-gray-600 hover:text-primary-600'"
active-class="!text-primary-600 bg-primary-50" active-class="!text-primary-600 bg-primary-50"
@click.prevent="item.comingSoon ? null : null"
> >
<component :is="item.icon" class="w-5 h-5 mr-3" /> <component :is="item.icon" class="w-5 h-5 mr-3" />
{{ item.name }} <span>{{ item.name }}</span>
<span
v-if="item.comingSoon"
class="ml-auto px-1 py-0.5 text-[9px] font-medium text-white bg-purple-600 rounded"
>
Soon
</span>
</router-link> </router-link>
</div> </div>
</div> </div>
@@ -226,7 +267,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } 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 { format } from 'date-fns'
@@ -242,7 +283,8 @@ import {
User, User,
ChevronDown, ChevronDown,
X, X,
Menu Menu,
Dice6
} 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'
@@ -255,12 +297,16 @@ const navigation = [
{ name: 'Événements', to: '/events', icon: Calendar }, { name: 'Événements', to: '/events', icon: Calendar },
{ name: 'Albums', to: '/albums', icon: Image }, { name: 'Albums', to: '/albums', icon: Image },
{ name: 'Vlogs', to: '/vlogs', icon: Film }, { name: 'Vlogs', to: '/vlogs', icon: Film },
{ name: 'Publications', to: '/posts', icon: MessageSquare } { name: 'Publications', to: '/posts', icon: MessageSquare },
{ name: 'Jeux', to: '#', icon: Dice6, comingSoon: true }
] ]
const showUserMenu = ref(false) const showUserMenu = ref(false)
const showNotifications = ref(false) const showNotifications = ref(false)
const isMobileMenuOpen = ref(false) const isMobileMenuOpen = ref(false)
const deferredPrompt = ref(null)
const isPWAInstalled = ref(false)
const canInstall = ref(false)
const user = computed(() => authStore.user) const user = computed(() => authStore.user)
const notifications = computed(() => authStore.notifications) const notifications = computed(() => authStore.notifications)
@@ -276,7 +322,8 @@ async function logout() {
} }
async function fetchNotifications() { async function fetchNotifications() {
await authStore.fetchNotifications() const result = await authStore.fetchNotifications()
// Les notifications sont maintenant mises à jour automatiquement via le polling
} }
async function markAllRead() { async function markAllRead() {
@@ -295,9 +342,82 @@ async function handleNotificationClick(notification) {
showNotifications.value = false showNotifications.value = false
} }
// PWA Installation logic
function checkPWAInstalled() {
// Vérifier si l'app est déjà installée (mode standalone)
isPWAInstalled.value = window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true ||
document.referrer.includes('android-app://')
}
function handleBeforeInstallPrompt(e) {
// Empêcher le prompt par défaut
e.preventDefault()
// Stocker l'événement pour l'utiliser plus tard
deferredPrompt.value = e
canInstall.value = true
}
async function handleInstallApp() {
if (!deferredPrompt.value || isPWAInstalled.value) {
return
}
try {
showUserMenu.value = false
// Afficher le prompt d'installation
deferredPrompt.value.prompt()
// Attendre la réponse de l'utilisateur
const { outcome } = await deferredPrompt.value.userChoice
if (outcome === 'accepted') {
console.log('✅ PWA installée avec succès')
isPWAInstalled.value = true
} else {
console.log('❌ Installation PWA annulée')
}
// Réinitialiser
deferredPrompt.value = null
canInstall.value = false
} catch (error) {
console.error('Erreur lors de l\'installation:', error)
}
}
onMounted(async () => { onMounted(async () => {
await authStore.fetchCurrentUser() await authStore.fetchCurrentUser()
await fetchNotifications()
await authStore.fetchUnreadCount() if (authStore.isAuthenticated) {
await fetchNotifications()
await authStore.fetchUnreadCount()
// Démarrer le polling des notifications
const notificationService = (await import('@/services/notificationService')).default
notificationService.startPolling()
notificationService.setupServiceWorkerListener()
// Demander la permission pour les notifications push
await notificationService.requestNotificationPermission()
}
// Vérifier si PWA est installée
checkPWAInstalled()
// Écouter l'événement beforeinstallprompt
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
// Écouter les changements de display mode
window.matchMedia('(display-mode: standalone)').addEventListener('change', checkPWAInstalled)
})
onBeforeUnmount(async () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
// Arrêter le polling des notifications
const notificationService = (await import('@/services/notificationService')).default
notificationService.stopPolling()
}) })
</script> </script>

View File

@@ -0,0 +1,159 @@
import { useAuthStore } from '@/stores/auth'
class NotificationService {
constructor() {
this.pollingInterval = null
this.pollInterval = 30000 // 30 secondes
this.isPolling = false
}
startPolling() {
if (this.isPolling) return
const authStore = useAuthStore()
if (!authStore.isAuthenticated) return
this.isPolling = true
// Récupérer immédiatement
this.fetchNotifications()
// Puis toutes les 30 secondes
this.pollingInterval = setInterval(() => {
this.fetchNotifications()
}, this.pollInterval)
}
stopPolling() {
if (this.pollingInterval) {
clearInterval(this.pollingInterval)
this.pollingInterval = null
}
this.isPolling = false
}
async fetchNotifications() {
const authStore = useAuthStore()
if (!authStore.isAuthenticated) {
this.stopPolling()
return
}
try {
const result = await authStore.fetchNotifications()
// Si de nouvelles notifications non lues ont été détectées
if (result && result.hasNewNotifications && result.newCount > result.previousCount) {
// Trouver les nouvelles notifications non lues (les plus récentes en premier)
const newUnreadNotifications = authStore.notifications
.filter(n => !n.is_read)
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
.slice(0, result.newCount - result.previousCount)
if (newUnreadNotifications.length > 0) {
// Afficher une notification push pour la plus récente
const latestNotification = newUnreadNotifications[0]
await this.showPushNotification(latestNotification.title, {
body: latestNotification.message,
link: latestNotification.link || '/',
data: { notificationId: latestNotification.id }
})
}
}
} catch (error) {
console.error('Error polling notifications:', error)
}
}
showNotificationBadge() {
// Mettre à jour le badge du titre de la page
if ('Notification' in window && Notification.permission === 'granted') {
// La notification push sera gérée par le service worker
return
}
}
// Gestion des notifications push PWA
async requestNotificationPermission() {
if (!('Notification' in window)) {
console.log('Ce navigateur ne supporte pas les notifications')
return false
}
if (Notification.permission === 'granted') {
return true
}
if (Notification.permission !== 'denied') {
const permission = await Notification.requestPermission()
return permission === 'granted'
}
return false
}
async showPushNotification(title, options = {}) {
if (!('Notification' in window)) return
const hasPermission = await this.requestNotificationPermission()
if (!hasPermission) return
// Si on est dans un service worker, utiliser la notification API du SW
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.ready
await registration.showNotification(title, {
icon: '/icon-192x192.png',
badge: '/icon-96x96.png',
tag: 'lediscord-notification',
requireInteraction: false,
vibrate: [200, 100, 200],
...options
})
return
} catch (error) {
console.error('Error showing notification via service worker:', error)
}
}
// Fallback: notification native du navigateur
const notification = new Notification(title, {
icon: '/icon-192x192.png',
badge: '/icon-96x96.png',
tag: 'lediscord-notification',
requireInteraction: false,
...options
})
notification.onclick = () => {
window.focus()
notification.close()
if (options.link) {
window.location.href = options.link
}
}
// Fermer automatiquement après 5 secondes
setTimeout(() => {
notification.close()
}, 5000)
return notification
}
// Écouter les messages du service worker pour les notifications push
setupServiceWorkerListener() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data && event.data.type === 'NOTIFICATION') {
const { title, options } = event.data
this.showPushNotification(title, options)
}
})
}
}
}
export default new NotificationService()

View File

@@ -68,8 +68,15 @@ export const useAuthStore = defineStore('auth', () => {
async function logout() { async function logout() {
token.value = null token.value = null
user.value = null user.value = null
notifications.value = []
unreadCount.value = 0
localStorage.removeItem('token') localStorage.removeItem('token')
delete axios.defaults.headers.common['Authorization'] delete axios.defaults.headers.common['Authorization']
// Arrêter le polling des notifications
const notificationService = (await import('@/services/notificationService')).default
notificationService.stopPolling()
router.push('/login') router.push('/login')
toast.info('Déconnexion réussie') toast.info('Déconnexion réussie')
} }
@@ -130,10 +137,36 @@ export const useAuthStore = defineStore('auth', () => {
try { try {
const response = await axios.get('/api/notifications?limit=50') const response = await axios.get('/api/notifications?limit=50')
notifications.value = response.data const newNotifications = response.data
unreadCount.value = notifications.value.filter(n => !n.is_read).length
// Détecter les nouvelles notifications non lues
const previousIds = new Set(notifications.value.map(n => n.id))
const previousUnreadIds = new Set(
notifications.value.filter(n => !n.is_read).map(n => n.id)
)
// Nouvelles notifications = celles qui n'existaient pas avant
const hasNewNotifications = newNotifications.some(n => !previousIds.has(n.id))
// Nouvelles notifications non lues = nouvelles ET non lues
const newUnreadNotifications = newNotifications.filter(
n => !previousIds.has(n.id) && !n.is_read
)
notifications.value = newNotifications
const newUnreadCount = notifications.value.filter(n => !n.is_read).length
const previousUnreadCount = unreadCount.value
unreadCount.value = newUnreadCount
// Retourner si de nouvelles notifications non lues ont été détectées
return {
hasNewNotifications: newUnreadNotifications.length > 0,
newCount: newUnreadCount,
previousCount: previousUnreadCount
}
} catch (error) { } catch (error) {
console.error('Error fetching notifications:', error) console.error('Error fetching notifications:', error)
return { hasNewNotifications: false, newCount: unreadCount.value, previousCount: unreadCount.value }
} }
} }

View File

@@ -1,144 +1,333 @@
<template> <template>
<div class="space-y-6"> <div class="w-full max-w-2xl mx-auto space-y-4 sm:space-y-6 px-4 sm:px-6 pb-4 sm:pb-6">
<!-- Header --> <!-- Header -->
<div class="text-center"> <div class="text-center pt-2 sm:pt-0">
<h2 class="text-2xl font-bold text-gray-900">Créer un compte</h2> <h2 class="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900">Créer un compte</h2>
<p class="mt-2 text-sm text-gray-500">Rejoignez notre communauté en quelques étapes</p> <p class="mt-2 text-xs sm:text-sm text-gray-500">Remplissez ça et après promis je vous embête plus</p>
</div> </div>
<!-- Progress Bar --> <!-- Progress Bar -->
<div class="pt-4"> <div class="pt-2 sm:pt-4">
<div class="flex items-center justify-between mb-1"> <div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700">Étape {{ currentStep }} sur {{ totalSteps }}</span> <span class="text-xs sm:text-sm font-medium text-gray-700">Étape {{ currentStep }} sur {{ totalSteps }}</span>
<span class="text-sm font-medium text-gray-500">{{ Math.round((currentStep / totalSteps) * 100) }}%</span> <span class="text-xs sm:text-sm font-medium text-gray-500">{{ Math.round((currentStep / totalSteps) * 100) }}%</span>
</div> </div>
<div class="w-full bg-gray-200 rounded-full h-1.5"> <div class="w-full bg-gray-200 rounded-full h-2 sm:h-2.5">
<div <div
class="bg-primary-600 h-1.5 rounded-full transition-all duration-500 ease-in-out" class="bg-primary-600 h-2 sm:h-2.5 rounded-full transition-all duration-500 ease-in-out"
:style="{ width: `${(currentStep / totalSteps) * 100}%` }" :style="{ width: `${(currentStep / totalSteps) * 100}%` }"
></div> ></div>
</div> </div>
</div> </div>
<!-- Step Content --> <!-- Step Content -->
<div class="min-h-[350px] flex flex-col justify-center"> <div class="flex flex-col justify-center py-4 sm:py-6">
<StepTransition :step="currentStep"> <StepTransition :step="currentStep">
<!-- Step 1: Welcome --> <!-- Step 1: Welcome -->
<div v-if="currentStep === 1" class="text-center"> <div v-if="currentStep === 1" class="text-center px-2 sm:px-0">
<h3 class="text-2xl font-bold text-gray-900 mb-4">Bienvenue sur LeDiscord !</h3> <br />
<p class="text-gray-600 max-w-sm mx-auto"> <br />
Nous sommes ravis de vous accueillir. Préparez-vous à rejoindre une communauté passionnante. <br />
<br />
<h3 class="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 mb-3 sm:mb-4">Yo les ptits potes ! 🎉</h3>
<p class="text-sm sm:text-base text-gray-600 max-w-md mx-auto leading-relaxed mb-6 sm:mb-8">
Bienvenue sur LeDiscord ! Ici on partage nos vlogs, nos photos de soirées et on organise nos prochaines beuveries. 🍻
</p> </p>
<!-- Minimalist features preview -->
<div class="grid grid-cols-3 gap-2 sm:gap-4 max-w-md mx-auto mt-6 sm:mt-8">
<div class="text-center">
<div class="text-2xl sm:text-3xl mb-1">📹</div>
<div class="text-xs sm:text-sm font-medium text-gray-700">Vlogs</div>
</div>
<div class="text-center">
<div class="text-2xl sm:text-3xl mb-1">🍺</div>
<div class="text-xs sm:text-sm font-medium text-gray-700">Événements</div>
</div>
<div class="text-center">
<div class="text-2xl sm:text-3xl mb-1">📸</div>
<div class="text-xs sm:text-sm font-medium text-gray-700">Albums</div>
</div>
</div>
</div> </div>
<!-- Step 2: Registration Form --> <!-- Step 2: Registration Form -->
<div v-if="currentStep === 2"> <div v-if="currentStep === 2" class="w-full">
<form @submit.prevent="nextStep" class="space-y-6"> <form @submit.prevent="nextStep" class="space-y-4 sm:space-y-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6">
<div> <div>
<label for="email" class="label">Email</label> <label for="email" class="label text-sm sm:text-base">Email</label>
<input id="email" v-model="form.email" type="email" required class="input" @blur="touchedFields.email = true"> <input
id="email"
v-model="form.email"
type="email"
required
class="input text-sm sm:text-base"
placeholder="exemple@email.com"
@blur="touchedFields.email = true"
>
</div> </div>
<div> <div>
<label for="username" class="label">Nom d'utilisateur</label> <label for="username" class="label text-sm sm:text-base">Nom d'utilisateur</label>
<input id="username" v-model="form.username" type="text" required minlength="3" class="input" @blur="touchedFields.username = true"> <input
id="username"
v-model="form.username"
type="text"
required
minlength="3"
class="input text-sm sm:text-base"
placeholder="nom_utilisateur"
@blur="touchedFields.username = true"
>
</div> </div>
</div> </div>
<div> <div>
<label for="full_name" class="label">Nom complet</label> <label for="full_name" class="label text-sm sm:text-base">Nom complet</label>
<input id="full_name" v-model="form.full_name" type="text" required class="input" @blur="touchedFields.full_name = true"> <input
id="full_name"
v-model="form.full_name"
type="text"
required
class="input text-sm sm:text-base"
placeholder="Prénom Nom"
@blur="touchedFields.full_name = true"
>
</div> </div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6">
<div> <div>
<label for="password" class="label">Mot de passe</label> <label for="password" class="label text-sm sm:text-base">Mot de passe</label>
<input id="password" v-model="form.password" type="password" required minlength="6" class="input" @blur="touchedFields.password = true"> <input
id="password"
v-model="form.password"
type="password"
required
minlength="6"
class="input text-sm sm:text-base"
placeholder="••••••••"
@blur="touchedFields.password = true"
>
</div> </div>
<div> <div>
<label for="password_confirm" class="label">Confirmer</label> <label for="password_confirm" class="label text-sm sm:text-base">Confirmer</label>
<input id="password_confirm" v-model="form.password_confirm" type="password" required class="input" @blur="touchedFields.password_confirm = true"> <input
id="password_confirm"
v-model="form.password_confirm"
type="password"
required
class="input text-sm sm:text-base"
placeholder="••••••••"
@blur="touchedFields.password_confirm = true"
>
</div> </div>
</div> </div>
<PasswordStrength :password="form.password" /> <PasswordStrength :password="form.password" />
<div v-if="touchedFields.password_confirm && form.password_confirm && form.password !== form.password_confirm" class="flex items-center text-sm text-red-600"> <div v-if="touchedFields.password_confirm && form.password_confirm && form.password !== form.password_confirm" class="flex items-center text-xs sm:text-sm text-red-600 mt-2">
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" /></svg> <svg class="w-4 h-4 mr-1 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
Les mots de passe ne correspondent pas <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</div> </svg>
<span>Les mots de passe ne correspondent pas</span>
</div>
</form> </form>
</div> </div>
<!-- Step 3: Warning --> <!-- Step 3: Warning -->
<div v-if="currentStep === 3" class="text-center"> <div v-if="currentStep === 3" class="w-full px-2 sm:px-0 text-center">
<div class="bg-yellow-50 border-l-4 border-yellow-400 p-4"> <h3 class="text-xl sm:text-2xl font-bold text-gray-900 mb-3 sm:mb-4">
<div class="flex"> Version Bêta en cours ! 🚧
<div class="flex-shrink-0"> </h3>
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" /> <div class="bg-gradient-to-br from-yellow-50 to-orange-50 border-2 border-yellow-200 rounded-xl p-4 sm:p-6 max-w-lg mx-auto mb-4 sm:mb-6">
</svg> <p class="text-sm sm:text-base text-gray-700 leading-relaxed mb-4">
</div> Je suis encore en train de peaufiner tout ça, donc si tu vois un bug ou que quelque chose te fait chier, <strong>dis-moi !</strong> 💬
<div class="ml-3 text-left"> </p>
<p class="text-sm text-yellow-700"> <div class="flex items-center justify-center space-x-2 text-xs sm:text-sm text-gray-600">
LeDiscord est actuellement en version bêta. Votre retour est précieux pour nous aider à améliorer la plateforme. <span>🔧</span>
</p> <span>J'améliore au fur et à mesure</span>
</div> </div>
</div> </div>
</div>
<div class="mt-4 sm:mt-6">
<p class="text-xs sm:text-sm text-gray-700 mb-3 font-medium">Pour me signaler un problème, utilise le bouton ticket :</p>
<div class="inline-flex items-center justify-center bg-primary-600 text-white rounded-full p-3 sm:p-4 shadow-lg">
<svg class="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z" />
</svg>
</div>
<p class="text-xs sm:text-sm text-gray-500 mt-3">
Il se trouve en bas à droite de l'écran une fois connecté
</p>
</div>
</div> </div>
<!-- Step 4: Features Tour --> <!-- Step 4: Interactive Tour -->
<div v-if="currentStep === 4" class="text-center"> <div v-if="currentStep === 4" class="w-full px-2 sm:px-0">
<h3 class="text-2xl font-bold text-gray-900 mb-4">Découvrez les fonctionnalités</h3> <h3 class="text-xl sm:text-2xl font-bold text-gray-900 mb-4 sm:mb-6 text-center">Petite visite guidée 🗺️</h3>
<div class="space-y-4">
<div v-for="feature in features" :key="feature.title" class="border rounded-lg p-4 text-left flex items-center space-x-4"> <div class="max-w-lg mx-auto">
<div class="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center"> <!-- Tour Content -->
<span v-html="feature.icon"></span> <div class="bg-white border-2 border-gray-200 rounded-xl p-4 sm:p-6 mb-4 sm:mb-6 min-h-[280px] flex flex-col justify-center">
<div v-if="tourStep === 0" class="text-center">
<div class="text-4xl sm:text-5xl mb-4">🏠</div>
<h4 class="text-lg sm:text-xl font-bold text-gray-900 mb-3">Accueil</h4>
<p class="text-sm sm:text-base text-gray-600 mb-3">C'est ici que tu verras toutes les dernières activités de la communauté</p>
<div class="bg-gray-50 rounded-lg p-3 text-left text-xs sm:text-sm text-gray-600">
<p class="mb-1"> <strong>Astuce :</strong> Tu peux liker, commenter et partager tout ce qui t'intéresse</p>
<p>🔔 Les notifications te tiendront au courant des nouvelles interactions</p>
</div>
</div> </div>
<div>
<h4 class="font-semibold">{{ feature.title }}</h4> <div v-if="tourStep === 1" class="text-center">
<p class="text-sm text-gray-600">{{ feature.description }}</p> <div class="text-4xl sm:text-5xl mb-4">📅</div>
<h4 class="text-lg sm:text-xl font-bold text-gray-900 mb-3">Événements</h4>
<p class="text-sm sm:text-base text-gray-600 mb-3">Organise et participe aux prochaines soirées et beuveries</p>
<div class="bg-gray-50 rounded-lg p-3 text-left text-xs sm:text-sm text-gray-600">
<p class="mb-1">📝 <strong>Crée un événement :</strong> Date, lieu, description, le tout en quelques clics</p>
<p>✅ <strong>Participe :</strong> Indique ta présence et vois qui vient</p>
</div>
</div>
<div v-if="tourStep === 2" class="text-center">
<div class="text-4xl sm:text-5xl mb-4">📸</div>
<h4 class="text-lg sm:text-xl font-bold text-gray-900 mb-3">Albums</h4>
<p class="text-sm sm:text-base text-gray-600 mb-3">Partage tes meilleures photos de soirées et de moments entre potes</p>
<div class="bg-gray-50 rounded-lg p-3 text-left text-xs sm:text-sm text-gray-600">
<p class="mb-1">📁 <strong>Crée un album :</strong> Regroupe tes photos par événement ou thème</p>
<p>💾 <strong>Upload multiple :</strong> Ajoute plusieurs photos d'un coup</p>
</div>
</div>
<div v-if="tourStep === 3" class="text-center">
<div class="text-4xl sm:text-5xl mb-4">📹</div>
<h4 class="text-lg sm:text-xl font-bold text-gray-900 mb-3">Vlogs</h4>
<p class="text-sm sm:text-base text-gray-600 mb-3">Regarde et partage tes vlogs avec la communauté</p>
<div class="bg-gray-50 rounded-lg p-3 text-left text-xs sm:text-sm text-gray-600">
<p class="mb-1">🎬 <strong>Upload un vlog :</strong> Vidéos jusqu'à 500MB, avec thumbnail personnalisé</p>
<p>👀 <strong>Statistiques :</strong> Vues, replays, likes, tout est tracké</p>
</div>
</div>
<div v-if="tourStep === 4" class="text-center">
<div class="text-4xl sm:text-5xl mb-4">💬</div>
<h4 class="text-lg sm:text-xl font-bold text-gray-900 mb-3">Publications</h4>
<p class="text-sm sm:text-base text-gray-600 mb-3">Discute, partage et échange avec tout le monde</p>
<div class="bg-gray-50 rounded-lg p-3 text-left text-xs sm:text-sm text-gray-600">
<p class="mb-1">@<strong>Mentions :</strong> Tag tes potes avec @nom_utilisateur</p>
<p>📎 <strong>Médias :</strong> Ajoute des images directement dans tes posts</p>
</div>
</div> </div>
</div> </div>
<!-- Tour Navigation -->
<div class="flex items-center justify-between">
<button
@click="tourStep = Math.max(0, tourStep - 1)"
:disabled="tourStep === 0"
class="px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
← Précédent
</button>
<div class="flex space-x-2">
<div
v-for="i in 5"
:key="i"
class="w-2 h-2 rounded-full transition-all duration-300"
:class="tourStep === i - 1 ? 'bg-primary-600 w-6' : 'bg-gray-300'"
></div>
</div>
<button
@click="tourStep = Math.min(4, tourStep + 1)"
:disabled="tourStep === 4"
class="px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Suivant →
</button>
</div>
</div>
</div>
<!-- Step 5: Install App -->
<div v-if="currentStep === 5" class="w-full px-2 sm:px-0 text-center">
<h3 class="text-xl sm:text-2xl font-bold text-gray-900 mb-3 sm:mb-4">
Ah et tiens... 📱
</h3>
<p class="text-sm sm:text-base text-gray-600 max-w-md mx-auto leading-relaxed mb-6 sm:mb-8">
Prends l'app, ce sera plus sympa sur mobile ! Accès rapide, notifications, et tout ça directement depuis ton téléphone.
</p>
<div class="bg-gradient-to-br from-primary-50 to-purple-50 border-2 border-primary-200 rounded-xl p-4 sm:p-6 max-w-md mx-auto mb-6">
<div class="flex items-center justify-center space-x-3 mb-4">
<div class="text-3xl">📱</div>
<div class="text-3xl"></div>
<div class="text-3xl"></div>
</div>
<p class="text-xs sm:text-sm text-gray-700 mb-4">
Une fois connecté, tu pourras installer l'app depuis le menu de ton profil
</p>
<button
v-if="deferredPrompt"
@click="handleInstallApp"
class="w-full sm:w-auto bg-primary-600 hover:bg-primary-700 text-white font-medium py-3 px-6 rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105 text-sm sm:text-base"
>
<div class="flex items-center justify-center space-x-2">
<svg class="w-5 h-5" 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>
<span>Installer l'app maintenant</span>
</div>
</button>
<p v-else class="text-xs sm:text-sm text-gray-500">
Voilà, c'est quand même mieux comme ça !
</p>
</div> </div>
</div> </div>
</StepTransition> </StepTransition>
</div> </div>
<!-- Navigation Buttons --> <!-- Navigation Buttons -->
<div class="flex items-center pt-6" :class="currentStep > 1 ? 'justify-between' : 'justify-end'"> <div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 sm:gap-4 pt-4 sm:pt-6" :class="currentStep > 1 ? 'sm:justify-between' : 'sm:justify-end'">
<button <button
v-if="currentStep > 1" v-if="currentStep > 1"
@click="previousStep" @click="previousStep"
class="btn-secondary" class="btn-secondary w-full sm:w-auto order-2 sm:order-1 text-sm sm:text-base py-2.5 sm:py-2"
:disabled="loading" :disabled="loading"
>Précédent</button> >
Précédent
</button>
<button <button
v-if="currentStep < totalSteps" v-if="currentStep < totalSteps"
@click="nextStep" @click="nextStep"
:disabled="!canProceed || loading" :disabled="!canProceed || loading"
class="btn-primary bg-gradient-to-r from-primary-500 to-purple-600" class="btn-primary bg-gradient-to-r from-primary-500 to-purple-600 w-full sm:w-auto order-1 sm:order-2 text-sm sm:text-base py-2.5 sm:py-2"
:class="{ 'opacity-50 cursor-not-allowed': !canProceed }" :class="{ 'opacity-50 cursor-not-allowed': !canProceed || loading }"
>Suivant</button> >
Suivant
</button>
<button <button
v-if="currentStep === totalSteps" v-if="currentStep === totalSteps"
@click="handleRegister" @click="handleRegister"
:disabled="loading" :disabled="loading || !canProceed"
class="btn-primary bg-gradient-to-r from-primary-500 to-purple-600" class="btn-primary bg-gradient-to-r from-primary-500 to-purple-600 w-full sm:w-auto order-1 sm:order-2 text-sm sm:text-base py-2.5 sm:py-2"
:class="{ 'opacity-50 cursor-not-allowed': loading || !canProceed }"
> >
<span v-if="loading">Création en cours...</span> <span v-if="loading">Création en cours...</span>
<span v-else>Créer mon compte</span> <span v-else>Créer mon compte</span>
</button> </button>
</div> </div>
<div v-if="error" class="mt-4 text-center text-red-600"> <div v-if="error" class="mt-4 text-center text-xs sm:text-sm text-red-600 px-2">
{{ error }} {{ error }}
</div> </div>
<!-- Login Link --> <!-- Login Link -->
<div class="mt-8 text-center"> <div class="mt-6 sm:mt-8 text-center pb-4 sm:pb-0">
<p class="text-sm text-gray-600"> <p class="text-xs sm:text-sm text-gray-600">
Déjà un compte ? Déjà un compte ?
<router-link to="/login" class="font-medium text-primary-600 hover:text-primary-500"> <router-link to="/login" class="font-medium text-primary-600 hover:text-primary-500 transition-colors">
Se connecter Se connecter
</router-link> </router-link>
</p> </p>
@@ -147,7 +336,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import StepTransition from '@/components/StepTransition.vue' import StepTransition from '@/components/StepTransition.vue'
@@ -159,7 +348,9 @@ const router = useRouter()
// Step management // Step management
const currentStep = ref(1) const currentStep = ref(1)
const totalSteps = 4 const totalSteps = 5
const tourStep = ref(0)
const deferredPrompt = ref(null)
// Form data // Form data
const form = ref({ const form = ref({
@@ -214,6 +405,10 @@ const canProceed = computed(() => {
function nextStep() { function nextStep() {
if (currentStep.value < totalSteps && canProceed.value) { if (currentStep.value < totalSteps && canProceed.value) {
currentStep.value++ currentStep.value++
// Reset tour step when entering step 4
if (currentStep.value === 4) {
tourStep.value = 0
}
} }
} }
@@ -242,4 +437,35 @@ async function handleRegister() {
} }
loading.value = false loading.value = false
} }
// PWA Installation
function handleBeforeInstallPrompt(e) {
e.preventDefault()
deferredPrompt.value = e
}
async function handleInstallApp() {
if (!deferredPrompt.value) return
try {
deferredPrompt.value.prompt()
const { outcome } = await deferredPrompt.value.userChoice
if (outcome === 'accepted') {
console.log('✅ PWA installée avec succès')
}
deferredPrompt.value = null
} catch (error) {
console.error('Erreur lors de l\'installation:', error)
}
}
onMounted(() => {
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
})
onBeforeUnmount(() => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
})
</script> </script>

View File

@@ -2,6 +2,15 @@ const { defineConfig } = require('vite')
const vue = require('@vitejs/plugin-vue') const vue = require('@vitejs/plugin-vue')
const path = require('path') const path = require('path')
// Import conditionnel du plugin PWA
let VitePWA = null
try {
VitePWA = require('vite-plugin-pwa').VitePWA
} catch (e) {
console.warn('⚠️ vite-plugin-pwa n\'est pas installé. La fonctionnalité PWA sera désactivée.')
console.warn(' Installez-le avec: npm install --save-dev vite-plugin-pwa')
}
// Configuration par environnement // Configuration par environnement
const getEnvironmentConfig = (mode) => { const getEnvironmentConfig = (mode) => {
const configs = { const configs = {
@@ -79,8 +88,163 @@ module.exports = defineConfig(({ command, mode }) => {
VITE_APP_URL: process.env.VITE_APP_URL VITE_APP_URL: process.env.VITE_APP_URL
}) })
const plugins = [vue()]
// Ajouter le plugin PWA seulement s'il est installé
if (VitePWA) {
plugins.push(VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'logo_lediscord.png'],
manifest: {
name: 'LeDiscord - Notre espace',
short_name: 'LeDiscord',
description: 'Plateforme communautaire LeDiscord',
theme_color: '#6366f1',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait-primary',
scope: '/',
start_url: '/',
icons: [
{
src: '/icon-72x72.png',
sizes: '72x72',
type: 'image/png',
purpose: 'any maskable'
},
{
src: '/icon-96x96.png',
sizes: '96x96',
type: 'image/png',
purpose: 'any maskable'
},
{
src: '/icon-128x128.png',
sizes: '128x128',
type: 'image/png',
purpose: 'any maskable'
},
{
src: '/icon-144x144.png',
sizes: '144x144',
type: 'image/png',
purpose: 'any maskable'
},
{
src: '/icon-152x152.png',
sizes: '152x152',
type: 'image/png',
purpose: 'any maskable'
},
{
src: '/icon-192x192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'any maskable'
},
{
src: '/icon-384x384.png',
sizes: '384x384',
type: 'image/png',
purpose: 'any maskable'
},
{
src: '/icon-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable'
}
],
shortcuts: [
{
name: 'Vlogs',
short_name: 'Vlogs',
description: 'Voir les vlogs',
url: '/vlogs',
icons: [{ src: '/icon-96x96.png', sizes: '96x96' }]
},
{
name: 'Albums',
short_name: 'Albums',
description: 'Voir les albums',
url: '/albums',
icons: [{ src: '/icon-96x96.png', sizes: '96x96' }]
}
],
categories: ['social', 'entertainment']
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,webp,mp4}'],
// Notifications push
navigateFallback: null,
skipWaiting: true,
clientsClaim: true,
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365 // 1 year
},
cacheableResponse: {
statuses: [0, 200]
}
}
},
{
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'gstatic-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365 // 1 year
},
cacheableResponse: {
statuses: [0, 200]
}
}
},
{
urlPattern: /^https?:\/\/.*\/api\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 5 // 5 minutes
},
networkTimeoutSeconds: 10
}
},
{
urlPattern: /^https?:\/\/.*\/uploads\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'uploads-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 * 7 // 7 days
},
cacheableResponse: {
statuses: [0, 200]
}
}
}
]
},
devOptions: {
enabled: true,
type: 'module'
}
}))
}
return { return {
plugins: [vue()], plugins,
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src') '@': path.resolve(__dirname, './src')