initial commit - LeDiscord plateforme des copains
This commit is contained in:
18
frontend/Dockerfile
Normal file
18
frontend/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy application files
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5173
|
||||
|
||||
# Run the application
|
||||
CMD ["npm", "run", "dev"]
|
||||
16
frontend/index.html
Normal file
16
frontend/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LeDiscord - Notre espace</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
frontend/package.json
Normal file
28
frontend/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "lediscord-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.3.8",
|
||||
"vue-router": "^4.2.5",
|
||||
"pinia": "^2.1.7",
|
||||
"axios": "^1.6.2",
|
||||
"@vueuse/core": "^10.6.1",
|
||||
"date-fns": "^2.30.0",
|
||||
"lucide-vue-next": "^0.294.0",
|
||||
"vue-toastification": "^2.0.0-rc.5",
|
||||
"video.js": "^8.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.5.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.31",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
20
frontend/src/App.vue
Normal file
20
frontend/src/App.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<component :is="layout">
|
||||
<router-view />
|
||||
</component>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import DefaultLayout from '@/layouts/DefaultLayout.vue'
|
||||
import AuthLayout from '@/layouts/AuthLayout.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const layout = computed(() => {
|
||||
return route.meta.layout === 'auth' ? AuthLayout : DefaultLayout
|
||||
})
|
||||
</script>
|
||||
230
frontend/src/components/MentionInput.vue
Normal file
230
frontend/src/components/MentionInput.vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div class="mention-input-container">
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-model="inputValue"
|
||||
:placeholder="placeholder"
|
||||
:rows="rows"
|
||||
:class="inputClass"
|
||||
@input="handleInput"
|
||||
@keydown="handleKeydown"
|
||||
@focus="showSuggestions = true"
|
||||
@blur="handleBlur"
|
||||
/>
|
||||
|
||||
<!-- Suggestions de mentions -->
|
||||
<div
|
||||
v-if="showSuggestions && filteredUsers.length > 0"
|
||||
class="mention-suggestions"
|
||||
>
|
||||
<div
|
||||
v-for="(user, index) in filteredUsers"
|
||||
:key="user.id"
|
||||
:class="[
|
||||
'mention-suggestion-item',
|
||||
{ 'selected': index === selectedIndex }
|
||||
]"
|
||||
@click="selectUser(user)"
|
||||
@mouseenter="selectedIndex = index"
|
||||
>
|
||||
<img
|
||||
v-if="user.avatar_url"
|
||||
:src="getMediaUrl(user.avatar_url)"
|
||||
:alt="user.full_name"
|
||||
class="w-6 h-6 rounded-full mr-2"
|
||||
>
|
||||
<div v-else class="w-6 h-6 rounded-full bg-primary-100 flex items-center justify-center mr-2">
|
||||
<User class="w-3 h-3 text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-sm">{{ user.full_name }}</div>
|
||||
<div class="text-xs text-gray-500">@{{ user.username }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { User } from 'lucide-vue-next'
|
||||
import { getMediaUrl } from '@/utils/axios'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Écrivez votre message...'
|
||||
},
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 3
|
||||
},
|
||||
inputClass: {
|
||||
type: String,
|
||||
default: 'input resize-none'
|
||||
},
|
||||
users: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'mentions-changed'])
|
||||
|
||||
const textareaRef = ref(null)
|
||||
const inputValue = ref(props.modelValue)
|
||||
const showSuggestions = ref(false)
|
||||
const selectedIndex = ref(0)
|
||||
const currentMentionStart = ref(-1)
|
||||
const currentMentionQuery = ref('')
|
||||
|
||||
// Synchroniser avec la valeur externe
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
inputValue.value = newValue
|
||||
})
|
||||
|
||||
watch(inputValue, (newValue) => {
|
||||
emit('update:modelValue', newValue)
|
||||
updateMentions()
|
||||
})
|
||||
|
||||
const filteredUsers = computed(() => {
|
||||
if (!currentMentionQuery.value) return []
|
||||
|
||||
return props.users.filter(user =>
|
||||
user.username.toLowerCase().includes(currentMentionQuery.value.toLowerCase()) ||
|
||||
user.full_name.toLowerCase().includes(currentMentionQuery.value.toLowerCase())
|
||||
).slice(0, 5)
|
||||
})
|
||||
|
||||
function handleInput() {
|
||||
const text = inputValue.value
|
||||
const cursorPos = textareaRef.value.selectionStart
|
||||
|
||||
// Chercher la mention en cours
|
||||
const beforeCursor = text.substring(0, cursorPos)
|
||||
const mentionMatch = beforeCursor.match(/@(\w*)$/)
|
||||
|
||||
if (mentionMatch) {
|
||||
currentMentionStart.value = mentionMatch.index
|
||||
currentMentionQuery.value = mentionMatch[1]
|
||||
showSuggestions.value = true
|
||||
selectedIndex.value = 0
|
||||
} else {
|
||||
showSuggestions.value = false
|
||||
currentMentionStart.value = -1
|
||||
currentMentionQuery.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
if (!showSuggestions.value) return
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
selectedIndex.value = Math.min(selectedIndex.value + 1, filteredUsers.value.length - 1)
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
selectedIndex.value = Math.max(selectedIndex.value - 1, 0)
|
||||
} else if (event.key === 'Enter' && filteredUsers.value.length > 0) {
|
||||
event.preventDefault()
|
||||
selectUser(filteredUsers.value[selectedIndex.value])
|
||||
} else if (event.key === 'Escape') {
|
||||
showSuggestions.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectUser(user) {
|
||||
if (currentMentionStart.value === -1) return
|
||||
|
||||
const beforeMention = inputValue.value.substring(0, currentMentionStart.value)
|
||||
const afterMention = inputValue.value.substring(currentMentionStart.value + currentMentionQuery.value.length + 1)
|
||||
|
||||
inputValue.value = beforeMention + '@' + user.username + ' ' + afterMention
|
||||
|
||||
// Positionner le curseur après la mention
|
||||
nextTick(() => {
|
||||
const newCursorPos = currentMentionStart.value + user.username.length + 2
|
||||
textareaRef.value.setSelectionRange(newCursorPos, newCursorPos)
|
||||
textareaRef.value.focus()
|
||||
})
|
||||
|
||||
showSuggestions.value = false
|
||||
currentMentionStart.value = -1
|
||||
currentMentionQuery.value = ''
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
// Délai pour permettre le clic sur les suggestions
|
||||
setTimeout(() => {
|
||||
showSuggestions.value = false
|
||||
}, 200)
|
||||
}
|
||||
|
||||
function updateMentions() {
|
||||
const mentions = []
|
||||
const mentionRegex = /@(\w+)/g
|
||||
let match
|
||||
|
||||
while ((match = mentionRegex.exec(inputValue.value)) !== null) {
|
||||
const username = match[1]
|
||||
const user = props.users.find(u => u.username === username)
|
||||
if (user) {
|
||||
mentions.push({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
full_name: user.full_name
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
emit('mentions-changed', mentions)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mention-input-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mention-suggestions {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
z-index: 50;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mention-suggestion-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.mention-suggestion-item:hover,
|
||||
.mention-suggestion-item.selected {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.mention-suggestion-item:first-child {
|
||||
border-top-left-radius: 0.5rem;
|
||||
border-top-right-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.mention-suggestion-item:last-child {
|
||||
border-bottom-left-radius: 0.5rem;
|
||||
border-bottom-right-radius: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
79
frontend/src/components/Mentions.vue
Normal file
79
frontend/src/components/Mentions.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<span class="mentions-container">
|
||||
<template v-for="(part, index) in parsedContent" :key="index">
|
||||
<router-link
|
||||
v-if="part.type === 'mention'"
|
||||
:to="`/profile/${part.userId}`"
|
||||
class="mention-link"
|
||||
>
|
||||
@{{ part.username }}
|
||||
</router-link>
|
||||
<span v-else>{{ part.text }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
mentions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const parsedContent = computed(() => {
|
||||
if (!props.content) return []
|
||||
|
||||
const parts = []
|
||||
let currentIndex = 0
|
||||
|
||||
// Parcourir le contenu pour trouver les mentions
|
||||
props.content.split(/(@\w+)/).forEach((part, index) => {
|
||||
if (part.startsWith('@')) {
|
||||
const username = part.substring(1)
|
||||
const mention = props.mentions.find(m => m.username === username)
|
||||
|
||||
if (mention) {
|
||||
parts.push({
|
||||
type: 'mention',
|
||||
username: mention.username,
|
||||
userId: mention.id,
|
||||
text: part
|
||||
})
|
||||
} else {
|
||||
// Mention non trouvée, traiter comme du texte normal
|
||||
parts.push({ type: 'text', text: part })
|
||||
}
|
||||
} else if (part.trim()) {
|
||||
parts.push({ type: 'text', text: part })
|
||||
}
|
||||
})
|
||||
|
||||
return parts
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mentions-container {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mention-link {
|
||||
@apply font-medium transition-colors cursor-pointer;
|
||||
text-decoration: none;
|
||||
color: #8b5cf6;
|
||||
filter: drop-shadow(0 0 2px rgba(139, 92, 246, 0.3));
|
||||
}
|
||||
|
||||
.mention-link:hover {
|
||||
color: #7c3aed;
|
||||
filter: drop-shadow(0 0 4px rgba(139, 92, 246, 0.5));
|
||||
}
|
||||
</style>
|
||||
226
frontend/src/components/TicketFloatingButton.vue
Normal file
226
frontend/src/components/TicketFloatingButton.vue
Normal file
@@ -0,0 +1,226 @@
|
||||
<template>
|
||||
<!-- Floating Button -->
|
||||
<div class="fixed bottom-6 right-6 z-40">
|
||||
<button
|
||||
@click="showTicketModal = true"
|
||||
class="bg-primary-600 hover:bg-primary-700 text-white rounded-full p-4 shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-110"
|
||||
title="Signaler un problème ou une amélioration"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Ticket Modal -->
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition ease-in duration-150"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showTicketModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="bg-white rounded-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold">Nouveau ticket</h2>
|
||||
<button
|
||||
@click="closeModal"
|
||||
class="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submitTicket" class="space-y-6">
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label class="label">Titre *</label>
|
||||
<input
|
||||
v-model="ticketForm.title"
|
||||
type="text"
|
||||
class="input"
|
||||
required
|
||||
maxlength="200"
|
||||
placeholder="Titre de votre ticket"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Type -->
|
||||
<div>
|
||||
<label class="label">Type</label>
|
||||
<select v-model="ticketForm.ticket_type" class="input">
|
||||
<option value="bug">🐛 Bug</option>
|
||||
<option value="feature_request">💡 Demande de fonctionnalité</option>
|
||||
<option value="improvement">✨ Amélioration</option>
|
||||
<option value="support">❓ Support</option>
|
||||
<option value="other">📝 Autre</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Priority -->
|
||||
<div>
|
||||
<label class="label">Priorité</label>
|
||||
<select v-model="ticketForm.priority" class="input">
|
||||
<option value="low">🟢 Faible</option>
|
||||
<option value="medium">🟡 Moyenne</option>
|
||||
<option value="high">🟠 Élevée</option>
|
||||
<option value="urgent">🔴 Urgente</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label class="label">Description *</label>
|
||||
<textarea
|
||||
v-model="ticketForm.description"
|
||||
class="input resize-none"
|
||||
rows="6"
|
||||
required
|
||||
placeholder="Décrivez votre problème ou votre demande en détail..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Screenshot -->
|
||||
<div>
|
||||
<label class="label">Screenshot (optionnel)</label>
|
||||
<input
|
||||
ref="screenshotInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="input"
|
||||
@change="handleScreenshotChange"
|
||||
>
|
||||
<div class="mt-1 text-sm text-gray-600">
|
||||
Formats acceptés : JPG, PNG, GIF, WebP (max 5MB)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal"
|
||||
class="flex-1 btn-secondary"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-1 btn-primary"
|
||||
:disabled="submitting"
|
||||
>
|
||||
<svg v-if="submitting" class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ submitting ? 'Envoi...' : 'Envoyer le ticket' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useRouter } from 'vue-router'
|
||||
import axios from '@/utils/axios'
|
||||
|
||||
const toast = useToast()
|
||||
const router = useRouter()
|
||||
|
||||
// State
|
||||
const showTicketModal = ref(false)
|
||||
const submitting = ref(false)
|
||||
const screenshotInput = ref(null)
|
||||
|
||||
const ticketForm = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
ticket_type: 'other',
|
||||
priority: 'medium'
|
||||
})
|
||||
|
||||
// Methods
|
||||
function closeModal() {
|
||||
showTicketModal.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
ticketForm.value = {
|
||||
title: '',
|
||||
description: '',
|
||||
ticket_type: 'other',
|
||||
priority: 'medium'
|
||||
}
|
||||
if (screenshotInput.value) {
|
||||
screenshotInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function handleScreenshotChange(event) {
|
||||
const file = event.target.files[0]
|
||||
if (file && file.size > 5 * 1024 * 1024) {
|
||||
toast.error('Le fichier est trop volumineux (max 5MB)')
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTicket() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('title', ticketForm.value.title)
|
||||
formData.append('description', ticketForm.value.description)
|
||||
formData.append('ticket_type', ticketForm.value.ticket_type)
|
||||
formData.append('priority', ticketForm.value.priority)
|
||||
|
||||
if (screenshotInput.value && screenshotInput.value.files[0]) {
|
||||
formData.append('screenshot', screenshotInput.value.files[0])
|
||||
}
|
||||
|
||||
// Debug: afficher les données envoyées
|
||||
console.log('DEBUG - Ticket form data:')
|
||||
console.log(' title:', ticketForm.value.title)
|
||||
console.log(' description:', ticketForm.value.description)
|
||||
console.log(' ticket_type:', ticketForm.value.ticket_type)
|
||||
console.log(' priority:', ticketForm.value.priority)
|
||||
console.log(' screenshot:', screenshotInput.value?.files[0])
|
||||
|
||||
// Debug: afficher le FormData
|
||||
for (let [key, value] of formData.entries()) {
|
||||
console.log(`DEBUG - FormData entry: ${key} = ${value}`)
|
||||
}
|
||||
|
||||
await axios.post('/api/tickets/', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
}
|
||||
})
|
||||
toast.success('Ticket envoyé avec succès !')
|
||||
closeModal()
|
||||
|
||||
// Redirect to MyTickets page
|
||||
router.push('/my-tickets')
|
||||
} catch (error) {
|
||||
console.error('Error submitting ticket:', error)
|
||||
if (error.response) {
|
||||
console.error('Error response:', error.response.data)
|
||||
console.error('Error status:', error.response.status)
|
||||
}
|
||||
toast.error('Erreur lors de l\'envoi du ticket')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
78
frontend/src/components/UserAvatar.vue
Normal file
78
frontend/src/components/UserAvatar.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="flex items-center space-x-2">
|
||||
<img
|
||||
v-if="avatarUrl"
|
||||
:src="avatarUrl"
|
||||
:alt="alt"
|
||||
:class="avatarClasses"
|
||||
>
|
||||
<div v-else :class="fallbackClasses">
|
||||
<User class="w-4 h-4 text-primary-600" />
|
||||
</div>
|
||||
<div v-if="showUserInfo">
|
||||
<p class="font-medium text-gray-900">{{ userName }}</p>
|
||||
<p class="text-sm text-gray-600">@{{ username }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { User } from 'lucide-vue-next'
|
||||
import { getMediaUrl } from '@/utils/axios'
|
||||
|
||||
const props = defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md', // 'sm', 'md', 'lg'
|
||||
validator: (value) => ['sm', 'md', 'lg'].includes(value)
|
||||
},
|
||||
showUserInfo: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const avatarUrl = computed(() => {
|
||||
if (props.user?.avatar_url) {
|
||||
return getMediaUrl(props.user.avatar_url)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const alt = computed(() => {
|
||||
return props.user?.full_name || 'Avatar utilisateur'
|
||||
})
|
||||
|
||||
const userName = computed(() => {
|
||||
return props.user?.full_name || 'Utilisateur'
|
||||
})
|
||||
|
||||
const username = computed(() => {
|
||||
return props.user?.username || 'user'
|
||||
})
|
||||
|
||||
const avatarClasses = computed(() => {
|
||||
const baseClasses = 'rounded-full object-cover'
|
||||
const sizeClasses = {
|
||||
sm: 'w-6 h-6',
|
||||
md: 'w-8 h-8',
|
||||
lg: 'w-10 h-10'
|
||||
}
|
||||
return `${sizeClasses[props.size]} ${baseClasses}`
|
||||
})
|
||||
|
||||
const fallbackClasses = computed(() => {
|
||||
const baseClasses = 'rounded-full bg-primary-100 flex items-center justify-center'
|
||||
const sizeClasses = {
|
||||
sm: 'w-6 h-6',
|
||||
md: 'w-8 h-8',
|
||||
lg: 'w-10 h-10'
|
||||
}
|
||||
return `${sizeClasses[props.size]} ${baseClasses}`
|
||||
})
|
||||
</script>
|
||||
178
frontend/src/components/VideoPlayer.vue
Normal file
178
frontend/src/components/VideoPlayer.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<div class="video-player-container">
|
||||
<div class="relative">
|
||||
<!-- Video.js Player -->
|
||||
<video
|
||||
ref="videoPlayer"
|
||||
class="video-js vjs-default-skin vjs-big-play-centered w-full rounded-lg"
|
||||
controls
|
||||
preload="auto"
|
||||
:poster="posterUrl"
|
||||
data-setup="{}"
|
||||
>
|
||||
<source :src="videoUrl" type="video/mp4" />
|
||||
<p class="vjs-no-js">
|
||||
Pour voir cette vidéo, activez JavaScript et considérez passer à un navigateur web qui
|
||||
<a href="https://videojs.com/html5-video-support/" target="_blank">supporte la vidéo HTML5</a>.
|
||||
</p>
|
||||
</video>
|
||||
</div>
|
||||
|
||||
<!-- Video Stats -->
|
||||
<div class="mt-4 flex items-center justify-between text-sm text-gray-600">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="flex items-center">
|
||||
<Eye class="w-4 h-4 mr-1" />
|
||||
{{ viewsCount }} vue{{ viewsCount > 1 ? 's' : '' }}
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<Clock class="w-4 h-4 mr-1" />
|
||||
{{ formatDuration(duration) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="toggleLike"
|
||||
class="flex items-center space-x-1 px-3 py-1 rounded-full transition-colors"
|
||||
:class="isLiked ? 'bg-red-100 text-red-600' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
>
|
||||
<Heart :class="isLiked ? 'fill-current' : ''" class="w-4 h-4" />
|
||||
<span>{{ likesCount }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="toggleComments"
|
||||
class="flex items-center space-x-1 px-3 py-1 rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
<MessageSquare class="w-4 h-4" />
|
||||
<span>{{ commentsCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
|
||||
import videojs from 'video.js'
|
||||
import 'video.js/dist/video-js.css'
|
||||
import { Eye, Clock, Heart, MessageSquare } from 'lucide-vue-next'
|
||||
import { getMediaUrl } from '@/utils/axios'
|
||||
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
poster: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
duration: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
viewsCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
likesCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
commentsCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
isLiked: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['like', 'toggle-comments'])
|
||||
|
||||
const videoPlayer = ref(null)
|
||||
const player = ref(null)
|
||||
|
||||
// Computed properties pour les URLs
|
||||
const videoUrl = computed(() => getMediaUrl(props.src))
|
||||
const posterUrl = computed(() => getMediaUrl(props.poster))
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (!seconds) return '--:--'
|
||||
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = seconds % 60
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function toggleLike() {
|
||||
emit('like')
|
||||
}
|
||||
|
||||
function toggleComments() {
|
||||
emit('toggle-comments')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (videoPlayer.value) {
|
||||
player.value = videojs(videoPlayer.value, {
|
||||
controls: true,
|
||||
fluid: true,
|
||||
responsive: true,
|
||||
playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 2],
|
||||
controlBar: {
|
||||
children: [
|
||||
'playToggle',
|
||||
'volumePanel',
|
||||
'currentTimeDisplay',
|
||||
'timeDivider',
|
||||
'durationDisplay',
|
||||
'progressControl',
|
||||
'playbackRateMenuButton',
|
||||
'fullscreenToggle'
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// Error handling
|
||||
player.value.on('error', (error) => {
|
||||
console.error('Video.js error:', error)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (player.value) {
|
||||
player.value.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for src changes to reload video
|
||||
watch(() => props.src, () => {
|
||||
if (player.value && videoUrl.value) {
|
||||
player.value.src({ src: videoUrl.value, type: 'video/mp4' })
|
||||
player.value.load()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.video-js {
|
||||
aspect-ratio: 16/9;
|
||||
}
|
||||
|
||||
.video-js .vjs-big-play-button {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
203
frontend/src/components/VlogComments.vue
Normal file
203
frontend/src/components/VlogComments.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<div class="vlog-comments">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">
|
||||
Commentaires ({{ comments.length }})
|
||||
</h3>
|
||||
<button
|
||||
@click="showCommentForm = !showCommentForm"
|
||||
class="btn-primary text-sm"
|
||||
>
|
||||
<MessageSquare class="w-4 h-4 mr-2" />
|
||||
{{ showCommentForm ? 'Annuler' : 'Commenter' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Comment Form -->
|
||||
<div v-if="showCommentForm" class="mb-6">
|
||||
<form @submit.prevent="submitComment" class="space-y-3">
|
||||
<MentionInput
|
||||
v-model="newComment"
|
||||
:users="commentUsers"
|
||||
:rows="3"
|
||||
placeholder="Écrivez votre commentaire... (utilisez @username pour mentionner)"
|
||||
@mentions-changed="handleCommentMentionsChanged"
|
||||
/>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-500">
|
||||
{{ newComment.length }}/1000 caractères
|
||||
</span>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="!newComment.trim() || submitting"
|
||||
class="btn-primary text-sm"
|
||||
>
|
||||
{{ submitting ? 'Envoi...' : 'Envoyer' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Comments List -->
|
||||
<div v-if="comments.length === 0" class="text-center py-8 text-gray-500">
|
||||
<MessageSquare class="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||
<h4 class="text-lg font-medium mb-2">Aucun commentaire</h4>
|
||||
<p>Soyez le premier à commenter ce vlog !</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="comment in comments"
|
||||
:key="comment.id"
|
||||
class="flex space-x-3 p-4 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<!-- User Avatar -->
|
||||
<div class="flex-shrink-0">
|
||||
<img
|
||||
v-if="comment.avatar_url"
|
||||
:src="getAvatarUrl(comment.avatar_url)"
|
||||
:alt="comment.full_name"
|
||||
class="w-10 h-10 rounded-full object-cover"
|
||||
>
|
||||
<div v-else class="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<User class="w-5 h-5 text-primary-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comment Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center space-x-2 mb-1">
|
||||
<span class="font-medium text-gray-900">{{ comment.full_name }}</span>
|
||||
<span class="text-sm text-gray-500">@{{ comment.username }}</span>
|
||||
<span class="text-xs text-gray-400">{{ formatDate(comment.created_at) }}</span>
|
||||
</div>
|
||||
|
||||
<Mentions :content="comment.content" :mentions="comment.mentioned_users || []" class="text-gray-700 whitespace-pre-wrap" />
|
||||
|
||||
<!-- Comment Actions -->
|
||||
<div class="flex items-center space-x-4 mt-2">
|
||||
<button
|
||||
v-if="canDeleteComment(comment)"
|
||||
@click="deleteComment(comment.id)"
|
||||
class="text-xs text-red-600 hover:text-red-800 transition-colors"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { MessageSquare, User } from 'lucide-vue-next'
|
||||
import Mentions from '@/components/Mentions.vue'
|
||||
import MentionInput from '@/components/MentionInput.vue'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import { getMediaUrl } from '@/utils/axios'
|
||||
|
||||
const props = defineProps({
|
||||
vlogId: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
comments: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
commentUsers: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['comment-added', 'comment-deleted'])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const showCommentForm = ref(false)
|
||||
const newComment = ref('')
|
||||
const submitting = ref(false)
|
||||
const commentMentions = ref([])
|
||||
|
||||
const currentUser = computed(() => authStore.user)
|
||||
|
||||
function formatDate(date) {
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||
}
|
||||
|
||||
function getAvatarUrl(avatarUrl) {
|
||||
return getMediaUrl(avatarUrl)
|
||||
}
|
||||
|
||||
function canDeleteComment(comment) {
|
||||
return currentUser.value && (
|
||||
comment.user_id === currentUser.value.id ||
|
||||
currentUser.value.is_admin
|
||||
)
|
||||
}
|
||||
|
||||
function handleCommentMentionsChanged(mentions) {
|
||||
commentMentions.value = mentions
|
||||
}
|
||||
|
||||
async function submitComment() {
|
||||
if (!newComment.value.trim()) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const response = await fetch(`/api/vlogs/${props.vlogId}/comment`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authStore.token}`
|
||||
},
|
||||
body: JSON.stringify({ content: newComment.value.trim() })
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
emit('comment-added', result.comment)
|
||||
newComment.value = ''
|
||||
showCommentForm.value = false
|
||||
toast.success('Commentaire ajouté')
|
||||
} else {
|
||||
throw new Error('Erreur lors de l\'ajout du commentaire')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de l\'ajout du commentaire')
|
||||
console.error('Error adding comment:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteComment(commentId) {
|
||||
if (!confirm('Êtes-vous sûr de vouloir supprimer ce commentaire ?')) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/vlogs/${props.vlogId}/comment/${commentId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authStore.token}`
|
||||
}
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
emit('comment-deleted', commentId)
|
||||
toast.success('Commentaire supprimé')
|
||||
} else {
|
||||
throw new Error('Erreur lors de la suppression')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la suppression du commentaire')
|
||||
console.error('Error deleting comment:', error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
17
frontend/src/layouts/AuthLayout.vue
Normal file
17
frontend/src/layouts/AuthLayout.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gradient-discord flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-4xl font-bold bg-gradient-primary bg-clip-text text-transparent mb-2">LeDiscord</h1>
|
||||
<p class="text-secondary-600">Notre espace privé</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-2xl shadow-xl p-8">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
284
frontend/src/layouts/DefaultLayout.vue
Normal file
284
frontend/src/layouts/DefaultLayout.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<!-- Navigation -->
|
||||
<nav class="bg-white shadow-sm border-b border-gray-100">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex">
|
||||
<!-- Logo -->
|
||||
<router-link to="/" class="flex items-center">
|
||||
<span class="text-2xl font-bold bg-gradient-primary bg-clip-text text-transparent">LeDiscord</span>
|
||||
</router-link>
|
||||
|
||||
<!-- Navigation Links -->
|
||||
<div class="hidden sm:ml-8 sm:flex sm:space-x-6">
|
||||
<router-link
|
||||
v-for="item in navigation"
|
||||
:key="item.name"
|
||||
: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"
|
||||
active-class="!text-primary-600 !border-primary-600"
|
||||
>
|
||||
<component :is="item.icon" class="w-4 h-4 mr-2" />
|
||||
{{ item.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Notifications -->
|
||||
<button
|
||||
@click="showNotifications = !showNotifications"
|
||||
class="relative p-2 text-gray-600 hover:text-primary-600 transition-colors"
|
||||
>
|
||||
<Bell class="w-5 h-5" />
|
||||
<span
|
||||
v-if="unreadNotifications > 0"
|
||||
class="absolute top-0 right-0 block h-2 w-2 rounded-full bg-red-500"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- User Menu -->
|
||||
<div class="relative">
|
||||
<button
|
||||
@click="showUserMenu = !showUserMenu"
|
||||
class="flex items-center space-x-2 text-sm font-medium text-gray-600 hover:text-primary-600 transition-colors"
|
||||
>
|
||||
<img
|
||||
v-if="user?.avatar_url"
|
||||
:src="getMediaUrl(user.avatar_url)"
|
||||
:alt="user.full_name"
|
||||
class="w-8 h-8 rounded-full object-cover"
|
||||
>
|
||||
<div v-else class="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<User class="w-4 h-4 text-primary-600" />
|
||||
</div>
|
||||
<span class="hidden sm:block">{{ user?.full_name }}</span>
|
||||
<ChevronDown class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<!-- Dropdown -->
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-100"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="showUserMenu"
|
||||
class="absolute right-0 mt-2 w-48 rounded-lg bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5"
|
||||
>
|
||||
<router-link
|
||||
to="/profile"
|
||||
@click="showUserMenu = false"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
Mon profil
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/stats"
|
||||
@click="showUserMenu = false"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
Statistiques
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/information"
|
||||
@click="showUserMenu = false"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900"
|
||||
>
|
||||
Informations
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/my-tickets"
|
||||
@click="showUserMenu = false"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900"
|
||||
>
|
||||
Mes tickets
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="authStore.isAdmin"
|
||||
to="/admin"
|
||||
@click="showUserMenu = false"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
Administration
|
||||
</router-link>
|
||||
|
||||
<hr class="my-1">
|
||||
<button
|
||||
@click="logout"
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<div class="sm:hidden">
|
||||
<div class="pt-2 pb-3 space-y-1">
|
||||
<router-link
|
||||
v-for="item in navigation"
|
||||
:key="item.name"
|
||||
: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"
|
||||
active-class="!text-primary-600 bg-primary-50"
|
||||
>
|
||||
<component :is="item.icon" class="w-5 h-5 mr-3" />
|
||||
{{ item.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 bg-gray-50">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<!-- Ticket Floating Button -->
|
||||
<TicketFloatingButton />
|
||||
|
||||
<!-- Notifications Panel -->
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="transform translate-x-full"
|
||||
enter-to-class="transform translate-x-0"
|
||||
leave-active-class="transition ease-in duration-150"
|
||||
leave-from-class="transform translate-x-0"
|
||||
leave-to-class="transform translate-x-full"
|
||||
>
|
||||
<div
|
||||
v-if="showNotifications"
|
||||
class="fixed right-0 top-16 h-full w-80 bg-white shadow-xl z-50 overflow-y-auto"
|
||||
>
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold">Notifications</h3>
|
||||
<button
|
||||
@click="showNotifications = false"
|
||||
class="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="notifications.length === 0" class="text-center py-8 text-gray-500">
|
||||
Aucune notification
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-sm text-gray-600">{{ notifications.length }} notification(s)</span>
|
||||
<button
|
||||
@click="markAllRead"
|
||||
class="text-xs text-primary-600 hover:text-primary-800"
|
||||
>
|
||||
Tout marquer comme lu
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
@click="handleNotificationClick(notification)"
|
||||
class="p-3 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
:class="{ 'bg-blue-50 border-l-4 border-blue-500': !notification.is_read }"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium text-sm text-gray-900">{{ notification.title }}</h4>
|
||||
<p class="text-xs text-gray-600 mt-1">{{ notification.message }}</p>
|
||||
<p class="text-xs text-gray-400 mt-2">{{ formatDate(notification.created_at) }}</p>
|
||||
</div>
|
||||
<div v-if="!notification.is_read" class="w-2 h-2 bg-blue-500 rounded-full ml-2 flex-shrink-0"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { format } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import { getMediaUrl } from '@/utils/axios'
|
||||
import {
|
||||
Home,
|
||||
Calendar,
|
||||
Image,
|
||||
Film,
|
||||
MessageSquare,
|
||||
Bell,
|
||||
User,
|
||||
ChevronDown,
|
||||
X
|
||||
} from 'lucide-vue-next'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import TicketFloatingButton from '@/components/TicketFloatingButton.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Accueil', to: '/', icon: Home },
|
||||
{ name: 'Événements', to: '/events', icon: Calendar },
|
||||
{ name: 'Albums', to: '/albums', icon: Image },
|
||||
{ name: 'Vlogs', to: '/vlogs', icon: Film },
|
||||
{ name: 'Publications', to: '/posts', icon: MessageSquare }
|
||||
]
|
||||
|
||||
const showUserMenu = ref(false)
|
||||
const showNotifications = ref(false)
|
||||
|
||||
const user = computed(() => authStore.user)
|
||||
const notifications = computed(() => authStore.notifications)
|
||||
const unreadNotifications = computed(() => authStore.unreadCount)
|
||||
|
||||
function formatDate(date) {
|
||||
return format(new Date(date), 'dd MMM à HH:mm', { locale: fr })
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
showUserMenu.value = false
|
||||
await authStore.logout()
|
||||
}
|
||||
|
||||
async function fetchNotifications() {
|
||||
await authStore.fetchNotifications()
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
await authStore.markAllNotificationsRead()
|
||||
}
|
||||
|
||||
async function handleNotificationClick(notification) {
|
||||
if (!notification.is_read) {
|
||||
await authStore.markNotificationRead(notification.id)
|
||||
}
|
||||
|
||||
if (notification.link) {
|
||||
router.push(notification.link)
|
||||
}
|
||||
|
||||
showNotifications.value = false
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await authStore.fetchCurrentUser()
|
||||
await fetchNotifications()
|
||||
await authStore.fetchUnreadCount()
|
||||
})
|
||||
</script>
|
||||
33
frontend/src/main.js
Normal file
33
frontend/src/main.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import Toast from 'vue-toastification'
|
||||
import 'vue-toastification/dist/index.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
// Toast configuration
|
||||
const toastOptions = {
|
||||
position: 'top-right',
|
||||
timeout: 3000,
|
||||
closeOnClick: true,
|
||||
pauseOnFocusLoss: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
draggablePercent: 0.6,
|
||||
showCloseButtonOnHover: false,
|
||||
hideProgressBar: false,
|
||||
closeButton: 'button',
|
||||
icon: true,
|
||||
rtl: false
|
||||
}
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(Toast, toastOptions)
|
||||
|
||||
app.mount('#app')
|
||||
143
frontend/src/router/index.js
Normal file
143
frontend/src/router/index.js
Normal file
@@ -0,0 +1,143 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
// Views
|
||||
import Home from '@/views/Home.vue'
|
||||
import Login from '@/views/Login.vue'
|
||||
import Register from '@/views/Register.vue'
|
||||
import Events from '@/views/Events.vue'
|
||||
import EventDetail from '@/views/EventDetail.vue'
|
||||
import Albums from '@/views/Albums.vue'
|
||||
import AlbumDetail from '@/views/AlbumDetail.vue'
|
||||
import Vlogs from '@/views/Vlogs.vue'
|
||||
import VlogDetail from '@/views/VlogDetail.vue'
|
||||
import Posts from '@/views/Posts.vue'
|
||||
import Profile from '@/views/Profile.vue'
|
||||
import UserProfile from '@/views/UserProfile.vue'
|
||||
import Stats from '@/views/Stats.vue'
|
||||
import Admin from '@/views/Admin.vue'
|
||||
import Information from '@/views/Information.vue'
|
||||
import MyTickets from '@/views/MyTickets.vue'
|
||||
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: Home,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: Login,
|
||||
meta: { layout: 'auth' }
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: Register,
|
||||
meta: { layout: 'auth' }
|
||||
},
|
||||
{
|
||||
path: '/events',
|
||||
name: 'Events',
|
||||
component: Events,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/events/:id',
|
||||
name: 'EventDetail',
|
||||
component: EventDetail,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/albums',
|
||||
name: 'Albums',
|
||||
component: Albums,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/albums/:id',
|
||||
name: 'AlbumDetail',
|
||||
component: AlbumDetail,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/vlogs',
|
||||
name: 'Vlogs',
|
||||
component: Vlogs,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/vlogs/:id',
|
||||
name: 'VlogDetail',
|
||||
component: VlogDetail,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/posts',
|
||||
name: 'Posts',
|
||||
component: Posts,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'Profile',
|
||||
component: Profile,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/profile/:id',
|
||||
name: 'UserProfile',
|
||||
component: UserProfile,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/stats',
|
||||
name: 'Stats',
|
||||
component: Stats,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'Admin',
|
||||
component: Admin,
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: '/information',
|
||||
name: 'Information',
|
||||
component: Information,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/my-tickets',
|
||||
name: 'MyTickets',
|
||||
component: MyTickets,
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
// Navigation guard
|
||||
router.beforeEach((to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
next('/login')
|
||||
} else if (to.meta.requiresAdmin && !authStore.isAdmin) {
|
||||
next('/')
|
||||
} else if ((to.name === 'Login' || to.name === 'Register') && authStore.isAuthenticated) {
|
||||
next('/')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
197
frontend/src/stores/auth.js
Normal file
197
frontend/src/stores/auth.js
Normal file
@@ -0,0 +1,197 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import axios from '@/utils/axios'
|
||||
import router from '@/router'
|
||||
import { useToast } from 'vue-toastification'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref(null)
|
||||
const token = ref(localStorage.getItem('token'))
|
||||
const toast = useToast()
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value)
|
||||
const isAdmin = computed(() => user.value?.is_admin || false)
|
||||
|
||||
if (token.value) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token.value}`
|
||||
}
|
||||
|
||||
async function login(email, password) {
|
||||
try {
|
||||
// Pour OAuth2PasswordRequestForm, on doit envoyer en format x-www-form-urlencoded
|
||||
const formData = new URLSearchParams()
|
||||
formData.append('username', email) // OAuth2PasswordRequestForm expects username field
|
||||
formData.append('password', password)
|
||||
|
||||
const response = await axios.post('/api/auth/login', formData.toString(), {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
})
|
||||
const { access_token, user: userData } = response.data
|
||||
|
||||
token.value = access_token
|
||||
user.value = userData
|
||||
localStorage.setItem('token', access_token)
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`
|
||||
|
||||
toast.success(`Bienvenue ${userData.full_name} !`)
|
||||
router.push('/')
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Erreur de connexion')
|
||||
return { success: false, error: error.response?.data?.detail }
|
||||
}
|
||||
}
|
||||
|
||||
async function register(userData) {
|
||||
try {
|
||||
const response = await axios.post('/api/auth/register', userData)
|
||||
const { access_token, user: newUser } = response.data
|
||||
|
||||
token.value = access_token
|
||||
user.value = newUser
|
||||
localStorage.setItem('token', access_token)
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`
|
||||
|
||||
toast.success('Inscription réussie !')
|
||||
router.push('/')
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Erreur lors de l\'inscription')
|
||||
return { success: false, error: error.response?.data?.detail }
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
token.value = null
|
||||
user.value = null
|
||||
localStorage.removeItem('token')
|
||||
delete axios.defaults.headers.common['Authorization']
|
||||
router.push('/login')
|
||||
toast.info('Déconnexion réussie')
|
||||
}
|
||||
|
||||
async function fetchCurrentUser() {
|
||||
if (!token.value) return
|
||||
|
||||
try {
|
||||
const response = await axios.get('/api/users/me')
|
||||
user.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Error fetching user:', error)
|
||||
if (error.response?.status === 401) {
|
||||
logout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProfile(profileData) {
|
||||
try {
|
||||
const response = await axios.put('/api/users/me', profileData)
|
||||
user.value = response.data
|
||||
toast.success('Profil mis à jour')
|
||||
return { success: true, data: response.data }
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la mise à jour du profil')
|
||||
return { success: false, error: error.response?.data?.detail }
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadAvatar(file) {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await axios.post('/api/users/me/avatar', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
|
||||
user.value = response.data
|
||||
toast.success('Avatar mis à jour')
|
||||
return { success: true, data: response.data }
|
||||
} catch (error) {
|
||||
console.error('Error uploading avatar:', error)
|
||||
toast.error('Erreur lors de l\'upload de l\'avatar')
|
||||
return { success: false, error: error.response?.data?.detail || 'Erreur inconnue' }
|
||||
}
|
||||
}
|
||||
|
||||
// Notifications
|
||||
const notifications = ref([])
|
||||
const unreadCount = ref(0)
|
||||
|
||||
async function fetchNotifications() {
|
||||
if (!token.value) return
|
||||
|
||||
try {
|
||||
const response = await axios.get('/api/notifications?limit=50')
|
||||
notifications.value = response.data
|
||||
unreadCount.value = notifications.value.filter(n => !n.is_read).length
|
||||
} catch (error) {
|
||||
console.error('Error fetching notifications:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function markNotificationRead(notificationId) {
|
||||
try {
|
||||
await axios.put(`/api/notifications/${notificationId}/read`)
|
||||
const notification = notifications.value.find(n => n.id === notificationId)
|
||||
if (notification && !notification.is_read) {
|
||||
notification.is_read = true
|
||||
notification.read_at = new Date().toISOString()
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error marking notification read:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllNotificationsRead() {
|
||||
try {
|
||||
await axios.put('/api/notifications/read-all')
|
||||
notifications.value.forEach(n => {
|
||||
n.is_read = true
|
||||
n.read_at = new Date().toISOString()
|
||||
})
|
||||
unreadCount.value = 0
|
||||
} catch (error) {
|
||||
console.error('Error marking all notifications read:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUnreadCount() {
|
||||
if (!token.value) return
|
||||
|
||||
try {
|
||||
const response = await axios.get('/api/notifications/unread-count')
|
||||
unreadCount.value = response.data.unread_count
|
||||
} catch (error) {
|
||||
console.error('Error fetching unread count:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
token,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
fetchCurrentUser,
|
||||
updateProfile,
|
||||
uploadAvatar,
|
||||
// Notifications
|
||||
notifications,
|
||||
unreadCount,
|
||||
fetchNotifications,
|
||||
markNotificationRead,
|
||||
markAllNotificationsRead,
|
||||
fetchUnreadCount
|
||||
}
|
||||
})
|
||||
72
frontend/src/style.css
Normal file
72
frontend/src/style.css
Normal file
@@ -0,0 +1,72 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900 antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 transition-all duration-200;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500 shadow-lg hover:shadow-xl;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply btn bg-white text-secondary-700 border-secondary-200 hover:bg-secondary-50 focus:ring-primary-500 hover:border-primary-300;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-xl shadow-sm border border-secondary-100 overflow-hidden hover:shadow-md transition-shadow;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply block w-full px-3 py-2 border border-secondary-300 rounded-lg shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm transition-colors;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply block text-sm font-medium text-secondary-700 mb-1;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Discord-style gradients */
|
||||
.bg-gradient-discord {
|
||||
@apply bg-gradient-to-br from-primary-50 via-white to-secondary-50;
|
||||
}
|
||||
|
||||
.bg-gradient-primary {
|
||||
@apply bg-gradient-to-r from-primary-500 to-primary-600;
|
||||
}
|
||||
|
||||
.bg-gradient-secondary {
|
||||
@apply bg-gradient-to-r from-secondary-500 to-secondary-600;
|
||||
}
|
||||
|
||||
/* Status colors */
|
||||
.status-online {
|
||||
@apply bg-success-500;
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
@apply bg-secondary-400;
|
||||
}
|
||||
|
||||
.status-away {
|
||||
@apply bg-warning-500;
|
||||
}
|
||||
|
||||
.status-dnd {
|
||||
@apply bg-accent-500;
|
||||
}
|
||||
}
|
||||
73
frontend/src/utils/axios.js
Normal file
73
frontend/src/utils/axios.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import axios from 'axios'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import router from '@/router'
|
||||
|
||||
const instance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// Request interceptor
|
||||
instance.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
error => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Response interceptor
|
||||
instance.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
const toast = useToast()
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
// Ne pas rediriger si on est déjà sur une page d'auth
|
||||
const currentRoute = router.currentRoute.value
|
||||
if (!currentRoute.path.includes('/login') && !currentRoute.path.includes('/register')) {
|
||||
localStorage.removeItem('token')
|
||||
router.push('/login')
|
||||
toast.error('Session expirée, veuillez vous reconnecter')
|
||||
}
|
||||
} else if (error.response?.status === 403) {
|
||||
toast.error('Accès non autorisé')
|
||||
} else if (error.response?.status === 500) {
|
||||
toast.error('Erreur serveur, veuillez réessayer plus tard')
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default instance
|
||||
|
||||
// Fonction utilitaire pour construire les URLs des médias
|
||||
export function getMediaUrl(path) {
|
||||
if (!path) return null
|
||||
if (typeof path !== 'string') return path
|
||||
if (path.startsWith('http')) return path
|
||||
|
||||
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||
|
||||
// Déjà un chemin uploads complet
|
||||
if (path.startsWith('/uploads/')) {
|
||||
return `${baseUrl}${path}`
|
||||
}
|
||||
|
||||
// Chemins relatifs issus de l'API (ex: /avatars/..., /vlogs/..., /albums/...)
|
||||
if (path.startsWith('/')) {
|
||||
return `${baseUrl}/uploads${path}`
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return `${baseUrl}/uploads/${path}`
|
||||
}
|
||||
2129
frontend/src/views/Admin.vue
Normal file
2129
frontend/src/views/Admin.vue
Normal file
File diff suppressed because it is too large
Load Diff
862
frontend/src/views/AlbumDetail.vue
Normal file
862
frontend/src/views/AlbumDetail.vue
Normal file
@@ -0,0 +1,862 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
<p class="mt-4 text-gray-600">Chargement de l'album...</p>
|
||||
</div>
|
||||
|
||||
<!-- Album not found -->
|
||||
<div v-else-if="!album" class="text-center py-12">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-4">Album non trouvé</h1>
|
||||
<p class="text-gray-600 mb-6">L'album que vous recherchez n'existe pas ou a été supprimé.</p>
|
||||
<router-link to="/albums" class="btn-primary">
|
||||
Retour aux albums
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Album details -->
|
||||
<div v-else>
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<router-link to="/albums" class="text-primary-600 hover:text-primary-700 flex items-center">
|
||||
<ArrowLeft class="w-4 h-4 mr-2" />
|
||||
Retour aux albums
|
||||
</router-link>
|
||||
|
||||
<div v-if="canEdit" class="flex space-x-2">
|
||||
<button
|
||||
@click="showEditModal = true"
|
||||
class="btn-secondary"
|
||||
>
|
||||
<Edit class="w-4 h-4 mr-2" />
|
||||
Modifier
|
||||
</button>
|
||||
<button
|
||||
@click="showUploadModal = true"
|
||||
class="btn-primary"
|
||||
>
|
||||
<Upload class="w-4 h-4 mr-2" />
|
||||
Ajouter des médias
|
||||
</button>
|
||||
<button
|
||||
@click="deleteAlbum"
|
||||
class="btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300"
|
||||
>
|
||||
<Trash2 class="w-4 h-4 mr-2" />
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-6">
|
||||
<!-- Cover Image -->
|
||||
<div class="w-64 h-48 bg-gradient-to-br from-primary-400 to-primary-600 rounded-xl flex items-center justify-center">
|
||||
<Image v-if="!album.cover_image" class="w-16 h-16 text-white" />
|
||||
<img
|
||||
v-else
|
||||
:src="getMediaUrl(album.cover_image)"
|
||||
:alt="album.title"
|
||||
class="w-full h-full object-cover rounded-xl"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Album Info -->
|
||||
<div class="flex-1">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">{{ album.title }}</h1>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-3">
|
||||
<div v-if="album.description" class="text-gray-700">
|
||||
{{ album.description }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center text-gray-600">
|
||||
<User class="w-5 h-5 mr-3" />
|
||||
<span>Créé par {{ album.creator_name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center text-gray-600">
|
||||
<Calendar class="w-5 h-5 mr-3" />
|
||||
<span>{{ formatDate(album.created_at) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="album.event_title" class="flex items-center text-primary-600">
|
||||
<Calendar class="w-5 h-5 mr-3" />
|
||||
<router-link :to="`/events/${album.event_id}`" class="hover:underline">
|
||||
{{ album.event_title }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<h3 class="font-semibold text-gray-900 mb-3">Statistiques</h3>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-primary-600">{{ album.media_count || 0 }}</div>
|
||||
<div class="text-gray-600">Médias</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-success-600">{{ formatBytes(totalSize) }}</div>
|
||||
<div class="text-gray-600">Taille totale</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Media Section -->
|
||||
<div v-if="album.top_media && album.top_media.length > 0" class="card p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Top Media</h2>
|
||||
<p class="text-gray-600 mb-4">Les médias les plus appréciés de cet album</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
<div
|
||||
v-for="media in album.top_media"
|
||||
:key="media.id"
|
||||
class="group relative aspect-square bg-gray-100 rounded-lg overflow-hidden cursor-pointer"
|
||||
@click="openMediaViewer(media)"
|
||||
>
|
||||
<img
|
||||
v-if="media.media_type === 'image'"
|
||||
:src="getMediaUrl(media.thumbnail_path) || getMediaUrl(media.file_path)"
|
||||
:alt="media.caption || 'Image'"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
>
|
||||
<video
|
||||
v-else
|
||||
:src="getMediaUrl(media.file_path)"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
|
||||
<!-- Media Type Badge -->
|
||||
<div class="absolute top-1 left-1 bg-black bg-opacity-70 text-white text-xs px-1 py-0.5 rounded">
|
||||
{{ media.media_type === 'image' ? '📷' : '🎥' }}
|
||||
</div>
|
||||
|
||||
<!-- Likes Badge -->
|
||||
<div class="absolute top-1 right-1 bg-primary-600 text-white text-xs px-1 py-0.5 rounded flex items-center">
|
||||
<Heart class="w-2 h-2 mr-1" />
|
||||
{{ media.likes_count }}
|
||||
</div>
|
||||
|
||||
<!-- Caption -->
|
||||
<div v-if="media.caption" class="absolute bottom-0 left-0 right-0 bg-black bg-opacity-70 text-white text-xs p-1 truncate">
|
||||
{{ media.caption }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Media Gallery -->
|
||||
<div class="card p-6 mb-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Galerie</h2>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="viewMode = 'grid'"
|
||||
class="p-2 rounded-lg transition-colors"
|
||||
:class="viewMode === 'grid' ? 'bg-primary-100 text-primary-600' : 'text-gray-400 hover:text-gray-600'"
|
||||
>
|
||||
<Grid class="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
@click="viewMode = 'list'"
|
||||
class="p-2 rounded-lg transition-colors"
|
||||
:class="viewMode === 'list' ? 'bg-primary-100 text-primary-600' : 'text-gray-400 hover:text-gray-600'"
|
||||
>
|
||||
<List class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="album.media.length === 0" class="text-center py-12 text-gray-500">
|
||||
<Image class="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||
<h3 class="text-lg font-medium mb-2">Aucun média</h3>
|
||||
<p>Cet album ne contient pas encore de photos ou vidéos</p>
|
||||
</div>
|
||||
|
||||
<!-- Grid View -->
|
||||
<div v-else-if="viewMode === 'grid'" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<div
|
||||
v-for="media in album.media"
|
||||
:key="media.id"
|
||||
class="group relative aspect-square bg-gray-100 rounded-lg overflow-hidden cursor-pointer"
|
||||
@click="openMediaViewer(media)"
|
||||
>
|
||||
<img
|
||||
v-if="media.media_type === 'image'"
|
||||
:src="getMediaUrl(media.thumbnail_path) || getMediaUrl(media.file_path)"
|
||||
:alt="media.caption || 'Image'"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
>
|
||||
<video
|
||||
v-else
|
||||
:src="getMediaUrl(media.file_path)"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
|
||||
<!-- Media Type Badge -->
|
||||
<div class="absolute top-2 left-2 bg-black bg-opacity-70 text-white text-xs px-2 py-1 rounded">
|
||||
{{ media.media_type === 'image' ? '📷' : '🎥' }}
|
||||
</div>
|
||||
|
||||
<!-- Caption -->
|
||||
<div v-if="media.caption" class="absolute bottom-0 left-0 right-0 bg-black bg-opacity-70 text-white text-xs p-2">
|
||||
{{ media.caption }}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div v-if="canEdit" class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
@click.stop="deleteMedia(media.id)"
|
||||
class="bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-600"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Like Button -->
|
||||
<div class="absolute bottom-2 right-2 opacity-100 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
@click.stop="toggleMediaLike(media)"
|
||||
class="flex items-center space-x-2 px-3 py-2 rounded-full text-sm transition-colors shadow-lg"
|
||||
:class="media.is_liked ? 'bg-red-500 text-white hover:bg-red-600' : 'bg-black bg-opacity-80 text-white hover:bg-opacity-90'"
|
||||
>
|
||||
<Heart :class="media.is_liked ? 'fill-current' : ''" class="w-4 h-4" />
|
||||
<span class="font-medium">{{ media.likes_count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="media in album.media"
|
||||
:key="media.id"
|
||||
class="flex items-center space-x-4 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div class="w-16 h-16 bg-gray-200 rounded overflow-hidden">
|
||||
<img
|
||||
v-if="media.media_type === 'image'"
|
||||
:src="getMediaUrl(media.thumbnail_path) || getMediaUrl(media.file_path)"
|
||||
:alt="media.caption || 'Image'"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
<video
|
||||
v-else
|
||||
:src="getMediaUrl(media.file_path)"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-gray-900">{{ media.caption || 'Sans titre' }}</p>
|
||||
<p class="text-sm text-gray-600">{{ formatBytes(media.file_size) }} • {{ media.media_type === 'image' ? 'Image' : 'Vidéo' }}</p>
|
||||
<p class="text-xs text-gray-500">{{ formatDate(media.created_at) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="openMediaViewer(media)"
|
||||
class="p-2 text-gray-400 hover:text-primary-600 transition-colors"
|
||||
>
|
||||
<Eye class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="toggleMediaLike(media)"
|
||||
class="p-2 text-gray-400 hover:text-red-600 transition-colors"
|
||||
:class="{ 'text-red-600': media.is_liked }"
|
||||
>
|
||||
<Heart :class="media.is_liked ? 'fill-current' : ''" class="w-5 h-5" />
|
||||
<span class="ml-2 text-sm font-medium">{{ media.likes_count }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="canEdit"
|
||||
@click="deleteMedia(media.id)"
|
||||
class="p-2 text-gray-400 hover:text-accent-600 transition-colors"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Album Modal -->
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition ease-in duration-150"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showEditModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="bg-white rounded-xl max-w-md w-full p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Modifier l'album</h2>
|
||||
|
||||
<form @submit.prevent="updateAlbum" class="space-y-4">
|
||||
<div>
|
||||
<label class="label">Titre</label>
|
||||
<input
|
||||
v-model="editForm.title"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Description</label>
|
||||
<textarea
|
||||
v-model="editForm.description"
|
||||
rows="3"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="showEditModal = false"
|
||||
class="flex-1 btn-secondary"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="updating"
|
||||
class="flex-1 btn-primary"
|
||||
>
|
||||
{{ updating ? 'Mise à jour...' : 'Mettre à jour' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Upload Media Modal -->
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition ease-in duration-150"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showUploadModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="bg-white rounded-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
||||
<h2 class="text-xl font-semibold mb-4">Ajouter des médias</h2>
|
||||
|
||||
<form @submit.prevent="uploadMedia" class="space-y-4">
|
||||
<div>
|
||||
<label class="label">Photos et vidéos</label>
|
||||
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
|
||||
<input
|
||||
ref="mediaInput"
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="handleMediaChange"
|
||||
>
|
||||
|
||||
<div v-if="newMedia.length === 0" class="space-y-2">
|
||||
<Upload class="w-12 h-12 text-gray-400 mx-auto" />
|
||||
<p class="text-gray-600">Glissez-déposez ou cliquez pour sélectionner</p>
|
||||
<p class="text-sm text-gray-500">Images et vidéos (max 100MB par fichier)</p>
|
||||
<button
|
||||
type="button"
|
||||
@click="$refs.mediaInput.click()"
|
||||
class="btn-secondary"
|
||||
>
|
||||
Sélectionner des fichiers
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
<div
|
||||
v-for="(media, index) in newMedia"
|
||||
:key="index"
|
||||
class="relative aspect-square bg-gray-100 rounded overflow-hidden"
|
||||
>
|
||||
<img
|
||||
v-if="media.type === 'image'"
|
||||
:src="media.preview"
|
||||
:alt="media.name"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
<video
|
||||
v-else
|
||||
:src="media.preview"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
<button
|
||||
@click="removeMedia(index)"
|
||||
class="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-600"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div class="absolute bottom-1 left-1 bg-black bg-opacity-70 text-white text-xs px-1 py-0.5 rounded">
|
||||
{{ media.type === 'image' ? '📷' : '🎥' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="$refs.mediaInput.click()"
|
||||
class="btn-secondary text-sm"
|
||||
>
|
||||
Ajouter plus de fichiers
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="showUploadModal = false"
|
||||
class="flex-1 btn-secondary"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="uploading || newMedia.length === 0"
|
||||
class="flex-1 btn-primary"
|
||||
>
|
||||
{{ uploading ? 'Upload...' : 'Ajouter les médias' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Media Viewer Modal -->
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition ease-in duration-150"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="selectedMedia"
|
||||
class="fixed inset-0 bg-black bg-opacity-90 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="relative max-w-7xl max-h-[95vh] w-full h-full">
|
||||
<!-- Close Button -->
|
||||
<button
|
||||
@click="closeMediaViewer"
|
||||
class="absolute top-6 right-6 z-10 bg-black bg-opacity-70 text-white rounded-full w-12 h-12 flex items-center justify-center hover:bg-opacity-90 transition-colors shadow-lg"
|
||||
>
|
||||
<X class="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
|
||||
|
||||
<!-- Navigation Buttons -->
|
||||
<button
|
||||
v-if="album.media.length > 1"
|
||||
@click="previousMedia"
|
||||
class="absolute left-6 top-1/2 transform -translate-y-1/2 z-10 bg-black bg-opacity-70 text-white rounded-full w-12 h-12 flex items-center justify-center hover:bg-opacity-90 transition-colors shadow-lg"
|
||||
>
|
||||
<ChevronLeft class="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="album.media.length > 1"
|
||||
@click="nextMedia"
|
||||
class="absolute right-6 top-1/2 transform -translate-y-1/2 z-10 bg-black bg-opacity-70 text-white rounded-full w-12 h-12 flex items-center justify-center hover:bg-opacity-90 transition-colors shadow-lg"
|
||||
>
|
||||
<ChevronRight class="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<!-- Position Indicator -->
|
||||
<div v-if="album.media.length > 1" class="absolute top-6 left-1/2 transform -translate-x-1/2 z-10 bg-black bg-opacity-70 text-white px-4 py-2 rounded-full text-sm font-medium backdrop-blur-sm">
|
||||
{{ getCurrentMediaIndex() + 1 }} / {{ album.media.length }}
|
||||
</div>
|
||||
|
||||
<!-- Media Content -->
|
||||
<div class="flex flex-col items-center h-full">
|
||||
<!-- Image or Video -->
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<img
|
||||
v-if="selectedMedia.media_type === 'image'"
|
||||
:src="getMediaUrl(selectedMedia.thumbnail_path) || getMediaUrl(selectedMedia.file_path)"
|
||||
:alt="selectedMedia.caption || 'Image'"
|
||||
class="w-auto h-auto max-w-none max-h-none object-contain rounded-lg shadow-2xl"
|
||||
:style="getOptimalMediaSize()"
|
||||
>
|
||||
<video
|
||||
v-else
|
||||
:src="getMediaUrl(selectedMedia.file_path)"
|
||||
controls
|
||||
class="w-auto h-auto max-w-none max-h-none rounded-lg shadow-2xl"
|
||||
:style="getOptimalMediaSize()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Media Info -->
|
||||
<div class="mt-6 text-center text-white bg-black bg-opacity-50 rounded-xl p-6 backdrop-blur-sm">
|
||||
<h3 v-if="selectedMedia.caption" class="text-xl font-semibold mb-3 text-white">
|
||||
{{ selectedMedia.caption }}
|
||||
</h3>
|
||||
<div class="flex items-center justify-center space-x-6 text-sm text-gray-200 mb-4">
|
||||
<span class="flex items-center space-x-2">
|
||||
<span class="w-2 h-2 bg-blue-400 rounded-full"></span>
|
||||
<span>{{ formatBytes(selectedMedia.file_size) }}</span>
|
||||
</span>
|
||||
<span class="flex items-center space-x-2">
|
||||
<span class="w-2 h-2 bg-green-400 rounded-full"></span>
|
||||
<span>{{ selectedMedia.media_type === 'image' ? '📷 Image' : '🎥 Vidéo' }}</span>
|
||||
</span>
|
||||
<span class="flex items-center space-x-2">
|
||||
<span class="w-2 h-2 bg-purple-400 rounded-full"></span>
|
||||
<span>{{ formatDate(selectedMedia.created_at) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Like Button in Viewer -->
|
||||
<div class="flex items-center justify-center">
|
||||
<button
|
||||
@click="toggleMediaLikeFromViewer(selectedMedia)"
|
||||
class="flex items-center space-x-3 px-6 py-3 rounded-full transition-all duration-300 transform hover:scale-105"
|
||||
:class="selectedMedia.is_liked ? 'bg-red-500 text-white shadow-lg hover:bg-red-600' : 'bg-white bg-opacity-20 text-white hover:bg-opacity-40 hover:shadow-lg'"
|
||||
>
|
||||
<Heart :class="selectedMedia.is_liked ? 'fill-current' : ''" class="w-6 h-6" />
|
||||
<span class="font-medium text-lg">{{ selectedMedia.likes_count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import axios from '@/utils/axios'
|
||||
import { getMediaUrl } from '@/utils/axios'
|
||||
import { format, formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Image,
|
||||
User,
|
||||
Calendar,
|
||||
Edit,
|
||||
Upload,
|
||||
Trash2,
|
||||
Grid,
|
||||
List,
|
||||
X,
|
||||
Eye,
|
||||
Heart,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const album = ref(null)
|
||||
const loading = ref(true)
|
||||
const updating = ref(false)
|
||||
const uploading = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showUploadModal = ref(false)
|
||||
const viewMode = ref('grid')
|
||||
const selectedMedia = ref(null)
|
||||
|
||||
|
||||
const editForm = ref({
|
||||
title: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const newMedia = ref([])
|
||||
|
||||
const canEdit = computed(() =>
|
||||
album.value && (album.value.creator_id === authStore.user?.id || authStore.user?.is_admin)
|
||||
)
|
||||
|
||||
const totalSize = computed(() =>
|
||||
album.value?.media?.reduce((sum, media) => sum + (media.file_size || 0), 0) || 0
|
||||
)
|
||||
|
||||
function formatDate(date) {
|
||||
return format(new Date(date), 'dd MMMM yyyy', { locale: fr })
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
async function fetchAlbum() {
|
||||
try {
|
||||
const response = await axios.get(`/api/albums/${route.params.id}`)
|
||||
album.value = response.data
|
||||
|
||||
// Initialize edit form
|
||||
editForm.value = {
|
||||
title: album.value.title,
|
||||
description: album.value.description || ''
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors du chargement de l\'album')
|
||||
console.error('Error fetching album:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAlbum() {
|
||||
updating.value = true
|
||||
try {
|
||||
const response = await axios.put(`/api/albums/${album.value.id}`, editForm.value)
|
||||
album.value = response.data
|
||||
showEditModal.value = false
|
||||
toast.success('Album mis à jour')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la mise à jour')
|
||||
}
|
||||
updating.value = false
|
||||
}
|
||||
|
||||
async function deleteAlbum() {
|
||||
if (!confirm('Êtes-vous sûr de vouloir supprimer cet album ?')) return
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/albums/${album.value.id}`)
|
||||
toast.success('Album supprimé')
|
||||
router.push('/albums')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la suppression')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMediaChange(event) {
|
||||
const files = Array.from(event.target.files)
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.type.startsWith('image/') && !file.type.startsWith('video/')) {
|
||||
toast.error(`${file.name} n'est pas un fichier image ou vidéo valide`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (file.size > 100 * 1024 * 1024) {
|
||||
toast.error(`${file.name} est trop volumineux (max 100MB)`)
|
||||
continue
|
||||
}
|
||||
|
||||
const media = {
|
||||
file: file,
|
||||
name: file.name,
|
||||
type: file.type.startsWith('image/') ? 'image' : 'video',
|
||||
preview: URL.createObjectURL(file)
|
||||
}
|
||||
|
||||
newMedia.value.push(media)
|
||||
}
|
||||
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
function removeMedia(index) {
|
||||
const media = newMedia.value[index]
|
||||
if (media.preview && media.preview.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(media.preview)
|
||||
}
|
||||
newMedia.value.splice(index, 1)
|
||||
}
|
||||
|
||||
async function uploadMedia() {
|
||||
if (newMedia.value.length === 0) return
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
newMedia.value.forEach(media => {
|
||||
formData.append('files', media.file)
|
||||
})
|
||||
|
||||
await axios.post(`/api/albums/${album.value.id}/media`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
|
||||
// Refresh album data
|
||||
await fetchAlbum()
|
||||
|
||||
showUploadModal.value = false
|
||||
newMedia.value.forEach(media => {
|
||||
if (media.preview && media.preview.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(media.preview)
|
||||
}
|
||||
})
|
||||
newMedia.value = []
|
||||
|
||||
toast.success('Médias ajoutés avec succès')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de l\'upload')
|
||||
}
|
||||
uploading.value = false
|
||||
}
|
||||
|
||||
async function deleteMedia(mediaId) {
|
||||
if (!confirm('Êtes-vous sûr de vouloir supprimer ce média ?')) return
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/albums/${album.value.id}/media/${mediaId}`)
|
||||
await fetchAlbum()
|
||||
toast.success('Média supprimé')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la suppression')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleMediaLike(media) {
|
||||
try {
|
||||
const response = await axios.post(`/api/albums/${album.value.id}/media/${media.id}/like`)
|
||||
media.is_liked = response.data.is_liked
|
||||
media.likes_count = response.data.likes_count
|
||||
toast.success('Like mis à jour')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la mise à jour du like')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleMediaLikeFromViewer(media) {
|
||||
try {
|
||||
const response = await axios.post(`/api/albums/${album.value.id}/media/${media.id}/like`)
|
||||
media.is_liked = response.data.is_liked
|
||||
media.likes_count = response.data.likes_count
|
||||
toast.success('Like mis à jour')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la mise à jour du like')
|
||||
}
|
||||
}
|
||||
|
||||
function openMediaViewer(media) {
|
||||
selectedMedia.value = media
|
||||
|
||||
// Add keyboard event listeners
|
||||
document.addEventListener('keydown', handleKeyboardNavigation)
|
||||
}
|
||||
|
||||
function closeMediaViewer() {
|
||||
selectedMedia.value = null
|
||||
|
||||
// Remove keyboard event listeners
|
||||
document.removeEventListener('keydown', handleKeyboardNavigation)
|
||||
}
|
||||
|
||||
function handleKeyboardNavigation(event) {
|
||||
if (!selectedMedia.value) return
|
||||
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
closeMediaViewer()
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
if (album.value.media.length > 1) {
|
||||
previousMedia()
|
||||
}
|
||||
break
|
||||
case 'ArrowRight':
|
||||
if (album.value.media.length > 1) {
|
||||
nextMedia()
|
||||
}
|
||||
break
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
function previousMedia() {
|
||||
const currentIndex = album.value.media.findIndex(media => media.id === selectedMedia.value.id)
|
||||
if (currentIndex > 0) {
|
||||
selectedMedia.value = album.value.media[currentIndex - 1]
|
||||
}
|
||||
}
|
||||
|
||||
function nextMedia() {
|
||||
const currentIndex = album.value.media.findIndex(media => media.id === selectedMedia.value.id)
|
||||
if (currentIndex < album.value.media.length - 1) {
|
||||
selectedMedia.value = album.value.media[currentIndex + 1]
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentMediaIndex() {
|
||||
if (!selectedMedia.value || !album.value) return 0
|
||||
return album.value.media.findIndex(media => media.id === selectedMedia.value.id)
|
||||
}
|
||||
|
||||
function getOptimalMediaSize() {
|
||||
if (!selectedMedia.value) return {}
|
||||
|
||||
// Utilisation MAXIMALE de l'espace disponible
|
||||
if (selectedMedia.value.media_type === 'image') {
|
||||
// Images : 100% de l'écran avec juste une petite marge
|
||||
return {
|
||||
'max-width': '98vw',
|
||||
'max-height': '96vh',
|
||||
'width': 'auto',
|
||||
'height': 'auto',
|
||||
'object-fit': 'contain'
|
||||
}
|
||||
}
|
||||
|
||||
// Vidéos : presque plein écran
|
||||
return {
|
||||
'max-width': '96vw',
|
||||
'max-height': '94vh',
|
||||
'width': 'auto',
|
||||
'height': 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAlbum()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// Clean up event listeners
|
||||
document.removeEventListener('keydown', handleKeyboardNavigation)
|
||||
})
|
||||
</script>
|
||||
786
frontend/src/views/Albums.vue
Normal file
786
frontend/src/views/Albums.vue
Normal file
@@ -0,0 +1,786 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Albums photos</h1>
|
||||
<p class="text-gray-600 mt-1">Partagez vos souvenirs en photos et vidéos</p>
|
||||
</div>
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="btn-primary"
|
||||
>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Nouvel album
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Albums Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="album in albums"
|
||||
:key="album.id"
|
||||
class="card hover:shadow-lg transition-shadow cursor-pointer"
|
||||
@click="openAlbum(album)"
|
||||
>
|
||||
<!-- Cover Image -->
|
||||
<div class="aspect-square bg-gray-100 relative overflow-hidden">
|
||||
<img
|
||||
v-if="album.cover_image"
|
||||
:src="getMediaUrl(album.cover_image)"
|
||||
:alt="album.title"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<Image class="w-16 h-16 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<!-- Media Count Badge -->
|
||||
<div class="absolute top-2 right-2 bg-black bg-opacity-70 text-white text-xs px-2 py-1 rounded">
|
||||
{{ album.media_count }} média{{ album.media_count > 1 ? 's' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold text-gray-900 mb-2">{{ album.title }}</h3>
|
||||
|
||||
<div v-if="album.description" class="mb-3">
|
||||
<Mentions :content="album.description" :mentions="getMentionsFromContent(album.description)" class="text-sm text-gray-600 line-clamp-2" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 text-sm text-gray-600 mb-3">
|
||||
<img
|
||||
v-if="album.creator_avatar"
|
||||
:src="getMediaUrl(album.creator_avatar)"
|
||||
:alt="album.creator_name"
|
||||
class="w-5 h-5 rounded-full object-cover"
|
||||
>
|
||||
<div v-else class="w-5 h-5 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<User class="w-3 h-3 text-gray-500" />
|
||||
</div>
|
||||
<router-link
|
||||
:to="`/profile/${album.creator_id}`"
|
||||
class="text-gray-900 hover:text-primary-600 transition-colors cursor-pointer"
|
||||
>
|
||||
{{ album.creator_name }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm text-gray-500">
|
||||
<span>{{ formatRelativeDate(album.created_at) }}</span>
|
||||
<div v-if="album.event_title" class="text-primary-600">
|
||||
📅 {{ album.event_title }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load more -->
|
||||
<div v-if="hasMoreAlbums" class="text-center mt-8">
|
||||
<button
|
||||
@click="loadMoreAlbums"
|
||||
:disabled="loading"
|
||||
class="btn-secondary"
|
||||
>
|
||||
{{ loading ? 'Chargement...' : 'Charger plus' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="albums.length === 0 && !loading" class="text-center py-12">
|
||||
<Image class="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Aucun album</h3>
|
||||
<p class="text-gray-600">Créez le premier album pour partager vos photos !</p>
|
||||
</div>
|
||||
|
||||
<!-- Create Album Modal -->
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition ease-in duration-150"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showCreateModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="bg-white rounded-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
||||
<h2 class="text-xl font-semibold mb-4">Créer un nouvel album</h2>
|
||||
|
||||
<form @submit.prevent="createAlbum" class="space-y-4">
|
||||
<div>
|
||||
<label class="label">Titre</label>
|
||||
<input
|
||||
v-model="newAlbum.title"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
placeholder="Titre de l'album..."
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Description</label>
|
||||
<MentionInput
|
||||
v-model="newAlbum.description"
|
||||
:users="users"
|
||||
:rows="3"
|
||||
placeholder="Décrivez votre album... (utilisez @username pour mentionner)"
|
||||
@mentions-changed="handleAlbumMentionsChanged"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Lier à un événement (optionnel)</label>
|
||||
<select
|
||||
v-model="newAlbum.event_id"
|
||||
class="input"
|
||||
>
|
||||
<option value="">Aucun événement</option>
|
||||
<option
|
||||
v-for="event in events"
|
||||
:key="event.id"
|
||||
:value="event.id"
|
||||
>
|
||||
{{ event.title }} - {{ formatDate(event.date) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Photos et vidéos</label>
|
||||
<div
|
||||
class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center transition-colors"
|
||||
:class="{ 'border-primary-400 bg-primary-50': isDragOver }"
|
||||
@dragover.prevent="isDragOver = true"
|
||||
@dragleave.prevent="isDragOver = false"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<input
|
||||
ref="mediaInput"
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="handleMediaChange"
|
||||
>
|
||||
|
||||
<div v-if="newAlbum.media.length === 0" class="space-y-2">
|
||||
<Upload class="w-12 h-12 text-gray-400 mx-auto" />
|
||||
<p class="text-gray-600">Glissez-déposez ou cliquez pour sélectionner</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
Images et vidéos (max {{ uploadLimits.max_image_size_mb }}MB pour images, {{ uploadLimits.max_video_size_mb }}MB pour vidéos)
|
||||
<br>
|
||||
<span class="text-xs text-gray-400">Maximum {{ uploadLimits.max_media_per_album }} fichiers par album</span>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@click="$refs.mediaInput.click()"
|
||||
class="btn-secondary"
|
||||
>
|
||||
Sélectionner des fichiers
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
<div
|
||||
v-for="(media, index) in newAlbum.media"
|
||||
:key="index"
|
||||
class="relative aspect-square bg-gray-100 rounded overflow-hidden group"
|
||||
>
|
||||
<img
|
||||
v-if="media.type === 'image'"
|
||||
:src="media.preview"
|
||||
:alt="media.name"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
<video
|
||||
v-else
|
||||
:src="media.preview"
|
||||
class="w-full h-full object-cover"
|
||||
muted
|
||||
loop
|
||||
/>
|
||||
|
||||
<!-- File Info Overlay -->
|
||||
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-50 transition-all duration-200 flex items-end">
|
||||
<div class="w-full p-2 text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<div class="font-medium truncate">{{ media.name }}</div>
|
||||
<div class="text-gray-300">
|
||||
{{ formatFileSize(media.size) }}
|
||||
<span v-if="media.originalSize && media.originalSize > media.size" class="text-green-300">
|
||||
({{ Math.round((1 - media.size / media.originalSize) * 100) }}% compression)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Caption Input -->
|
||||
<div class="absolute bottom-0 left-0 right-0 bg-black bg-opacity-70 p-2">
|
||||
<input
|
||||
v-model="media.caption"
|
||||
type="text"
|
||||
:placeholder="`Légende pour ${media.name}`"
|
||||
class="w-full text-xs bg-transparent text-white placeholder-gray-300 border-none outline-none"
|
||||
maxlength="100"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="removeMedia(index)"
|
||||
class="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-600 transition-colors"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div class="absolute top-1 left-1 bg-black bg-opacity-70 text-white text-xs px-1 py-0.5 rounded">
|
||||
{{ media.type === 'image' ? '📷' : '🎥' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="$refs.mediaInput.click()"
|
||||
class="btn-secondary text-sm"
|
||||
>
|
||||
Ajouter plus de fichiers
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Progress Bar -->
|
||||
<div v-if="isUploading" class="mt-4 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-blue-900">{{ uploadStatus }}</span>
|
||||
<span class="text-sm text-blue-700">{{ currentFileIndex }}/{{ totalFiles }}</span>
|
||||
</div>
|
||||
<div class="w-full bg-blue-200 rounded-full h-2">
|
||||
<div
|
||||
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
:style="`width: ${uploadProgress}%`"
|
||||
></div>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-blue-600">
|
||||
{{ Math.round(uploadProgress) }}% terminé
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Results Summary -->
|
||||
<div v-if="uploadSuccess.length > 0 || uploadErrors.length > 0" class="mt-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div v-if="uploadSuccess.length > 0" class="mb-2">
|
||||
<div class="text-sm font-medium text-green-700">
|
||||
✅ {{ uploadSuccess.length }} fichier(s) uploadé(s) avec succès
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="uploadErrors.length > 0" class="mb-2">
|
||||
<div class="text-sm font-medium text-red-700">
|
||||
❌ {{ uploadErrors.length }} erreur(s) lors de l'upload
|
||||
</div>
|
||||
<div class="text-xs text-red-600 mt-1">
|
||||
<div v-for="error in uploadErrors" :key="error.file" class="mb-1">
|
||||
{{ error.file }}: {{ error.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="showCreateModal = false"
|
||||
:disabled="isUploading"
|
||||
class="flex-1 btn-secondary"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="creating || newAlbum.media.length === 0 || isUploading"
|
||||
class="flex-1 btn-primary"
|
||||
>
|
||||
<span v-if="isUploading">
|
||||
<Upload class="w-4 h-4 mr-2 animate-spin" />
|
||||
Upload en cours...
|
||||
</span>
|
||||
<span v-else-if="creating">
|
||||
Création...
|
||||
</span>
|
||||
<span v-else>
|
||||
Créer l'album
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useRouter } from 'vue-router'
|
||||
import axios from '@/utils/axios'
|
||||
import { getMediaUrl } from '@/utils/axios'
|
||||
import { formatDistanceToNow, format } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import {
|
||||
Plus,
|
||||
Image,
|
||||
User,
|
||||
Upload,
|
||||
X
|
||||
} from 'lucide-vue-next'
|
||||
import Mentions from '@/components/Mentions.vue'
|
||||
import MentionInput from '@/components/MentionInput.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const albums = ref([])
|
||||
const events = ref([])
|
||||
const users = ref([])
|
||||
const loading = ref(false)
|
||||
const creating = ref(false)
|
||||
const showCreateModal = ref(false)
|
||||
const hasMoreAlbums = ref(true)
|
||||
const offset = ref(0)
|
||||
const uploadLimits = ref({
|
||||
max_image_size_mb: 10,
|
||||
max_video_size_mb: 100,
|
||||
max_media_per_album: 50
|
||||
})
|
||||
|
||||
const newAlbum = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
event_id: '',
|
||||
media: []
|
||||
})
|
||||
|
||||
const albumMentions = ref([])
|
||||
|
||||
// Upload optimization states
|
||||
const uploadProgress = ref(0)
|
||||
const isUploading = ref(false)
|
||||
const uploadStatus = ref('')
|
||||
const currentFileIndex = ref(0)
|
||||
const totalFiles = ref(0)
|
||||
const uploadErrors = ref([])
|
||||
const uploadSuccess = ref([])
|
||||
const isDragOver = ref(false)
|
||||
|
||||
function formatRelativeDate(date) {
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return format(new Date(date), 'dd/MM/yyyy', { locale: fr })
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
function openAlbum(album) {
|
||||
router.push(`/albums/${album.id}`)
|
||||
}
|
||||
|
||||
function handleAlbumMentionsChanged(mentions) {
|
||||
albumMentions.value = mentions
|
||||
}
|
||||
|
||||
function getMentionsFromContent(content) {
|
||||
if (!content) return []
|
||||
|
||||
const mentions = []
|
||||
const mentionRegex = /@(\w+)/g
|
||||
let match
|
||||
|
||||
while ((match = mentionRegex.exec(content)) !== null) {
|
||||
const username = match[1]
|
||||
const user = users.value.find(u => u.username === username)
|
||||
if (user) {
|
||||
mentions.push({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
full_name: user.full_name
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return mentions
|
||||
}
|
||||
|
||||
async function fetchUsers() {
|
||||
try {
|
||||
const response = await axios.get('/api/users')
|
||||
users.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUploadLimits() {
|
||||
try {
|
||||
const response = await axios.get('/api/settings/upload-limits')
|
||||
uploadLimits.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Error fetching upload limits:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMediaChange(event) {
|
||||
const files = Array.from(event.target.files)
|
||||
const maxFiles = uploadLimits.value.max_media_per_album
|
||||
|
||||
if (newAlbum.value.media.length + files.length > maxFiles) {
|
||||
toast.error(`Maximum ${maxFiles} fichiers autorisés par album`)
|
||||
event.target.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
let validFiles = 0
|
||||
let skippedFiles = 0
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/') && !file.type.startsWith('video/')) {
|
||||
toast.error(`${file.name} n'est pas un fichier image ou vidéo valide`)
|
||||
skippedFiles++
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
const maxSizeMB = file.type.startsWith('image/') ? uploadLimits.value.max_image_size_mb : uploadLimits.value.max_video_size_mb
|
||||
const maxSizeBytes = maxSizeMB * 1024 * 1024
|
||||
|
||||
if (file.size > maxSizeBytes) {
|
||||
toast.error(`${file.name} est trop volumineux (max ${maxSizeMB}MB)`)
|
||||
skippedFiles++
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate file name (prevent special characters issues)
|
||||
if (file.name.length > 100) {
|
||||
toast.error(`${file.name} a un nom trop long (max 100 caractères)`)
|
||||
skippedFiles++
|
||||
continue
|
||||
}
|
||||
|
||||
// Optimize image if it's an image file
|
||||
let optimizedFile = file
|
||||
if (file.type.startsWith('image/')) {
|
||||
try {
|
||||
optimizedFile = await optimizeImage(file)
|
||||
} catch (error) {
|
||||
console.warn(`Could not optimize ${file.name}:`, error)
|
||||
optimizedFile = file // Fallback to original file
|
||||
}
|
||||
}
|
||||
|
||||
// Create media object with optimized preview
|
||||
const media = {
|
||||
file: optimizedFile,
|
||||
originalFile: file, // Keep reference to original for size display
|
||||
name: file.name,
|
||||
type: file.type.startsWith('image/') ? 'image' : 'video',
|
||||
size: optimizedFile.size,
|
||||
originalSize: file.size,
|
||||
preview: URL.createObjectURL(optimizedFile),
|
||||
caption: '' // Add caption field for user input
|
||||
}
|
||||
|
||||
newAlbum.value.media.push(media)
|
||||
validFiles++
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error processing file ${file.name}:`, error)
|
||||
toast.error(`Erreur lors du traitement de ${file.name}`)
|
||||
skippedFiles++
|
||||
}
|
||||
}
|
||||
|
||||
// Show summary
|
||||
if (validFiles > 0) {
|
||||
if (skippedFiles > 0) {
|
||||
toast.info(`${validFiles} fichier(s) ajouté(s), ${skippedFiles} ignoré(s)`)
|
||||
} else {
|
||||
toast.success(`${validFiles} fichier(s) ajouté(s)`)
|
||||
}
|
||||
}
|
||||
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
function handleDrop(event) {
|
||||
isDragOver.value = false
|
||||
const files = Array.from(event.dataTransfer.files)
|
||||
if (files.length > 0) {
|
||||
// Simulate file input change
|
||||
const fakeEvent = { target: { files: files } }
|
||||
handleMediaChange(fakeEvent)
|
||||
}
|
||||
}
|
||||
|
||||
async function optimizeImage(file) {
|
||||
return new Promise((resolve) => {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
resolve(file) // Return original file for non-images
|
||||
return
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
const img = new Image()
|
||||
|
||||
img.onload = () => {
|
||||
// Calculate optimal dimensions (much higher for modern screens)
|
||||
const maxWidth = 3840 // 4K support
|
||||
const maxHeight = 2160 // 4K support
|
||||
let { width, height } = img
|
||||
|
||||
// Only resize if image is REALLY too large
|
||||
if (width > maxWidth || height > maxHeight) {
|
||||
const ratio = Math.min(maxWidth / width, maxHeight / height)
|
||||
width *= ratio
|
||||
height *= ratio
|
||||
} else {
|
||||
// Keep original dimensions for most images
|
||||
resolve(file)
|
||||
return
|
||||
}
|
||||
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
// Draw optimized image
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
|
||||
// Convert to blob with HIGH quality
|
||||
canvas.toBlob((blob) => {
|
||||
const optimizedFile = new File([blob], file.name, {
|
||||
type: file.type,
|
||||
lastModified: Date.now()
|
||||
})
|
||||
resolve(optimizedFile)
|
||||
}, file.type, 0.95) // 95% quality for minimal loss
|
||||
}
|
||||
|
||||
img.src = URL.createObjectURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
function removeMedia(index) {
|
||||
const media = newAlbum.value.media[index]
|
||||
|
||||
// Clean up preview URLs
|
||||
if (media.preview && media.preview.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(media.preview)
|
||||
}
|
||||
|
||||
// Clean up original file preview if different
|
||||
if (media.originalFile && media.originalFile !== media.file) {
|
||||
// Note: originalFile doesn't have preview, but we could add cleanup here if needed
|
||||
}
|
||||
|
||||
newAlbum.value.media.splice(index, 1)
|
||||
}
|
||||
|
||||
async function createAlbum() {
|
||||
if (!newAlbum.value.title || newAlbum.value.media.length === 0) return
|
||||
|
||||
// Check max media per album limit
|
||||
if (uploadLimits.value.max_media_per_album && newAlbum.value.media.length > uploadLimits.value.max_media_per_album) {
|
||||
toast.error(`Nombre maximum de médias par album dépassé : ${uploadLimits.value.max_media_per_album}`)
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
isUploading.value = true
|
||||
uploadProgress.value = 0
|
||||
currentFileIndex.value = 0
|
||||
totalFiles.value = newAlbum.value.media.length
|
||||
uploadErrors.value = []
|
||||
uploadSuccess.value = []
|
||||
|
||||
try {
|
||||
// First create the album
|
||||
const albumData = {
|
||||
title: newAlbum.value.title,
|
||||
description: newAlbum.value.description,
|
||||
event_id: newAlbum.value.event_id || null
|
||||
}
|
||||
|
||||
uploadStatus.value = 'Création de l\'album...'
|
||||
const albumResponse = await axios.post('/api/albums', albumData)
|
||||
const album = albumResponse.data
|
||||
|
||||
// Upload media files in batches for better performance
|
||||
const batchSize = 5 // Upload 5 files at a time
|
||||
const totalBatches = Math.ceil(newAlbum.value.media.length / batchSize)
|
||||
|
||||
for (let batchIndex = 0; batchIndex < totalBatches; batchIndex++) {
|
||||
const startIndex = batchIndex * batchSize
|
||||
const endIndex = Math.min(startIndex + batchSize, newAlbum.value.media.length)
|
||||
const batch = newAlbum.value.media.slice(startIndex, endIndex)
|
||||
|
||||
uploadStatus.value = `Upload du lot ${batchIndex + 1}/${totalBatches}...`
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
batch.forEach((media, index) => {
|
||||
formData.append('files', media.file)
|
||||
if (media.caption) {
|
||||
formData.append('captions', media.caption)
|
||||
}
|
||||
})
|
||||
|
||||
await axios.post(`/api/albums/${album.id}/media`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (progressEvent) => {
|
||||
// Update progress for this batch
|
||||
const batchProgress = (progressEvent.loaded / progressEvent.total) * 100
|
||||
const overallProgress = ((batchIndex * batchSize + batch.length) / newAlbum.value.media.length) * 100
|
||||
uploadProgress.value = Math.min(overallProgress, 100)
|
||||
}
|
||||
})
|
||||
|
||||
// Mark batch as successful
|
||||
batch.forEach((media, index) => {
|
||||
const globalIndex = startIndex + index
|
||||
currentFileIndex.value = globalIndex + 1
|
||||
uploadSuccess.value.push({
|
||||
file: media.name,
|
||||
type: media.type
|
||||
})
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error uploading batch ${batchIndex + 1}:`, error)
|
||||
|
||||
// Mark batch as failed
|
||||
batch.forEach((media, index) => {
|
||||
const globalIndex = startIndex + index
|
||||
currentFileIndex.value = globalIndex + 1
|
||||
uploadErrors.value.push({
|
||||
file: media.name,
|
||||
message: error.response?.data?.detail || 'Erreur lors de l\'upload'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Small delay between batches to avoid overwhelming the server
|
||||
if (batchIndex < totalBatches - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
|
||||
uploadStatus.value = 'Finalisation...'
|
||||
uploadProgress.value = 100
|
||||
|
||||
// Refresh albums list
|
||||
await fetchAlbums()
|
||||
|
||||
// Show results
|
||||
if (uploadErrors.value.length === 0) {
|
||||
toast.success(`Album créé avec succès ! ${uploadSuccess.value.length} fichier(s) uploadé(s)`)
|
||||
} else if (uploadSuccess.value.length > 0) {
|
||||
toast.warning(`Album créé avec ${uploadSuccess.value.length} fichier(s) uploadé(s) et ${uploadErrors.value.length} erreur(s)`)
|
||||
} else {
|
||||
toast.error('Erreur lors de l\'upload de tous les fichiers')
|
||||
}
|
||||
|
||||
showCreateModal.value = false
|
||||
resetForm()
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating album:', error)
|
||||
toast.error('Erreur lors de la création de l\'album')
|
||||
} finally {
|
||||
creating.value = false
|
||||
isUploading.value = false
|
||||
uploadProgress.value = 0
|
||||
currentFileIndex.value = 0
|
||||
totalFiles.value = 0
|
||||
uploadStatus.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
// Clean up media previews
|
||||
newAlbum.value.media.forEach(media => {
|
||||
if (media.preview && media.preview.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(media.preview)
|
||||
}
|
||||
})
|
||||
|
||||
// Reset form data
|
||||
newAlbum.value = {
|
||||
title: '',
|
||||
description: '',
|
||||
event_id: '',
|
||||
media: []
|
||||
}
|
||||
|
||||
// Reset upload states
|
||||
uploadProgress.value = 0
|
||||
isUploading.value = false
|
||||
uploadStatus.value = ''
|
||||
currentFileIndex.value = 0
|
||||
totalFiles.value = 0
|
||||
uploadErrors.value = []
|
||||
uploadSuccess.value = []
|
||||
}
|
||||
|
||||
async function fetchAlbums() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await axios.get(`/api/albums?limit=12&offset=${offset.value}`)
|
||||
if (offset.value === 0) {
|
||||
albums.value = response.data
|
||||
} else {
|
||||
albums.value.push(...response.data)
|
||||
}
|
||||
|
||||
hasMoreAlbums.value = response.data.length === 12
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors du chargement des albums')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function fetchEvents() {
|
||||
try {
|
||||
const response = await axios.get('/api/events')
|
||||
events.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Error fetching events:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreAlbums() {
|
||||
offset.value += 12
|
||||
await fetchAlbums()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAlbums()
|
||||
fetchEvents()
|
||||
fetchUsers()
|
||||
fetchUploadLimits()
|
||||
})
|
||||
</script>
|
||||
510
frontend/src/views/EventDetail.vue
Normal file
510
frontend/src/views/EventDetail.vue
Normal file
@@ -0,0 +1,510 @@
|
||||
<template>
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
<p class="mt-4 text-gray-600">Chargement de l'événement...</p>
|
||||
</div>
|
||||
|
||||
<!-- Event not found -->
|
||||
<div v-else-if="!event" class="text-center py-12">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-4">Événement non trouvé</h1>
|
||||
<p class="text-gray-600 mb-6">L'événement que vous recherchez n'existe pas ou a été supprimé.</p>
|
||||
<router-link to="/events" class="btn-primary">
|
||||
Retour aux événements
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Event details -->
|
||||
<div v-else>
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<router-link to="/events" class="text-primary-600 hover:text-primary-700 flex items-center">
|
||||
<ArrowLeft class="w-4 h-4 mr-2" />
|
||||
Retour aux événements
|
||||
</router-link>
|
||||
|
||||
<div v-if="canEdit" class="flex space-x-2">
|
||||
<button
|
||||
@click="showEditModal = true"
|
||||
class="btn-secondary"
|
||||
>
|
||||
<Edit class="w-4 h-4 mr-2" />
|
||||
Modifier
|
||||
</button>
|
||||
<button
|
||||
@click="deleteEvent"
|
||||
class="btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300"
|
||||
>
|
||||
<Trash2 class="w-4 h-4 mr-2" />
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-6">
|
||||
<!-- Cover Image -->
|
||||
<div class="w-64 h-48 bg-gradient-to-br from-primary-400 to-primary-600 rounded-xl flex items-center justify-center">
|
||||
<Calendar v-if="!event.cover_image" class="w-16 h-16 text-white" />
|
||||
<img
|
||||
v-else
|
||||
:src="getMediaUrl(event.cover_image)"
|
||||
:alt="event.title"
|
||||
class="w-full h-full object-cover rounded-xl"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Event Info -->
|
||||
<div class="flex-1">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">{{ event.title }}</h1>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center text-gray-600">
|
||||
<Clock class="w-5 h-5 mr-3" />
|
||||
<span>{{ formatDate(event.date) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="event.end_date" class="flex items-center text-gray-600">
|
||||
<Clock class="w-5 h-5 mr-3" />
|
||||
<span>Fin : {{ formatDate(event.end_date) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="event.location" class="flex items-center text-gray-600">
|
||||
<MapPin class="w-5 h-5 mr-3" />
|
||||
<span>{{ event.location }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center text-gray-600">
|
||||
<User class="w-5 h-5 mr-3" />
|
||||
<span>Organisé par {{ event.creator_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Participation Stats -->
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<h3 class="font-semibold text-gray-900 mb-3">Participation</h3>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-success-600">{{ event.present_count || 0 }}</div>
|
||||
<div class="text-gray-600">Présents</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-warning-600">{{ event.maybe_count || 0 }}</div>
|
||||
<div class="text-gray-600">Peut-être</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-accent-600">{{ event.absent_count || 0 }}</div>
|
||||
<div class="text-gray-600">Absents</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-secondary-600">{{ event.pending_count || 0 }}</div>
|
||||
<div class="text-gray-600">En attente</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div v-if="event.description" class="card p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Description</h2>
|
||||
<p class="text-gray-700 whitespace-pre-wrap">{{ event.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Map Section -->
|
||||
<div v-if="event.latitude && event.longitude" class="card p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Localisation</h2>
|
||||
<div class="bg-gray-100 rounded-lg p-6 h-48 flex items-center justify-center">
|
||||
<div class="text-center text-gray-600">
|
||||
<MapPin class="w-12 h-12 mx-auto mb-2 text-primary-600" />
|
||||
<p class="text-sm">Carte interactive</p>
|
||||
<p class="text-xs mt-1">{{ event.latitude }}, {{ event.longitude }}</p>
|
||||
<a
|
||||
:href="`https://www.openstreetmap.org/?mlat=${event.latitude}&mlon=${event.longitude}&zoom=15`"
|
||||
target="_blank"
|
||||
class="text-primary-600 hover:underline text-sm mt-2 inline-block"
|
||||
>
|
||||
Voir sur OpenStreetMap
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- My Participation -->
|
||||
<div class="card p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Ma participation</h2>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
@click="updateParticipation('present')"
|
||||
class="flex-1 py-3 px-4 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="getParticipationClass('present')"
|
||||
>
|
||||
✓ Présent
|
||||
</button>
|
||||
<button
|
||||
@click="updateParticipation('maybe')"
|
||||
class="flex-1 py-3 px-4 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="getParticipationClass('maybe')"
|
||||
>
|
||||
? Peut-être
|
||||
</button>
|
||||
<button
|
||||
@click="updateParticipation('absent')"
|
||||
class="flex-1 py-3 px-4 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="getParticipationClass('absent')"
|
||||
>
|
||||
✗ Absent
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Participants -->
|
||||
<div class="card p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Participants</h2>
|
||||
|
||||
<div v-if="event.participations.length === 0" class="text-center py-8 text-gray-500">
|
||||
Aucun participant pour le moment
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="participation in event.participations"
|
||||
:key="participation.user_id"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<img
|
||||
v-if="participation.avatar_url"
|
||||
:src="getMediaUrl(participation.avatar_url)"
|
||||
:alt="participation.full_name"
|
||||
class="w-10 h-10 rounded-full object-cover"
|
||||
>
|
||||
<div v-else class="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<User class="w-5 h-5 text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900">{{ participation.full_name }}</p>
|
||||
<p class="text-sm text-gray-600">@{{ participation.username }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<span
|
||||
class="px-3 py-1 rounded-full text-xs font-medium"
|
||||
:class="getStatusClass(participation.status)"
|
||||
>
|
||||
{{ getStatusText(participation.status) }}
|
||||
</span>
|
||||
<span v-if="participation.response_date" class="text-xs text-gray-500">
|
||||
{{ formatRelativeDate(participation.response_date) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Related Albums -->
|
||||
<div class="card p-6 mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Albums liés</h2>
|
||||
<router-link
|
||||
:to="`/albums?event_id=${event.id}`"
|
||||
class="text-primary-600 hover:text-primary-700 text-sm"
|
||||
>
|
||||
Voir tous les albums →
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="relatedAlbums.length === 0" class="text-center py-8 text-gray-500">
|
||||
Aucun album lié à cet événement
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<router-link
|
||||
v-for="album in relatedAlbums"
|
||||
:key="album.id"
|
||||
:to="`/albums/${album.id}`"
|
||||
class="block hover:shadow-lg transition-all duration-300 rounded-xl overflow-hidden bg-white border border-gray-200 hover:border-primary-300 hover:scale-105 transform"
|
||||
>
|
||||
<div class="aspect-[4/3] bg-gray-100 relative overflow-hidden">
|
||||
<img
|
||||
v-if="album.cover_image"
|
||||
:src="getMediaUrl(album.cover_image)"
|
||||
:alt="album.title"
|
||||
class="w-full h-full object-cover hover:scale-110 transition-transform duration-300"
|
||||
>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<Image class="w-16 h-16 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<!-- Media Count Badge -->
|
||||
<div class="absolute top-3 right-3 bg-black bg-opacity-80 text-white text-sm px-3 py-1.5 rounded-full font-medium">
|
||||
{{ album.media_count }} média{{ album.media_count > 1 ? 's' : '' }}
|
||||
</div>
|
||||
|
||||
<!-- Hover Overlay -->
|
||||
<div class="absolute inset-0 bg-black bg-opacity-0 hover:bg-opacity-20 transition-all duration-300 flex items-center justify-center">
|
||||
<div class="opacity-0 hover:opacity-100 transition-opacity duration-300">
|
||||
<div class="bg-white bg-opacity-90 text-gray-900 px-4 py-2 rounded-full font-medium">
|
||||
Voir l'album →
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold text-gray-900 text-lg mb-2 line-clamp-2">{{ album.title }}</h3>
|
||||
<div class="flex items-center space-x-2 text-sm text-gray-600 mb-2">
|
||||
<User class="w-4 h-4" />
|
||||
<span>{{ album.creator_name }}</span>
|
||||
</div>
|
||||
<p v-if="album.description" class="text-sm text-gray-500 line-clamp-2">{{ album.description }}</p>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Event Modal -->
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition ease-in duration-150"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showEditModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="bg-white rounded-xl max-w-md w-full p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Modifier l'événement</h2>
|
||||
|
||||
<form @submit.prevent="updateEvent" class="space-y-4">
|
||||
<div>
|
||||
<label class="label">Titre</label>
|
||||
<input
|
||||
v-model="editForm.title"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Description</label>
|
||||
<textarea
|
||||
v-model="editForm.description"
|
||||
rows="3"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Date et heure</label>
|
||||
<input
|
||||
v-model="editForm.date"
|
||||
type="datetime-local"
|
||||
required
|
||||
class="input"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Lieu</label>
|
||||
<input
|
||||
v-model="editForm.location"
|
||||
type="text"
|
||||
class="input"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="showEditModal = false"
|
||||
class="flex-1 btn-secondary"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="updating"
|
||||
class="flex-1 btn-primary"
|
||||
>
|
||||
{{ updating ? 'Mise à jour...' : 'Mettre à jour' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import axios from '@/utils/axios'
|
||||
import { getMediaUrl } from '@/utils/axios'
|
||||
import { format, formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
Clock,
|
||||
MapPin,
|
||||
User,
|
||||
Edit,
|
||||
Trash2,
|
||||
Image
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const event = ref(null)
|
||||
const relatedAlbums = ref([])
|
||||
const loading = ref(true)
|
||||
const updating = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
|
||||
const editForm = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
date: '',
|
||||
location: ''
|
||||
})
|
||||
|
||||
const canEdit = computed(() =>
|
||||
event.value && (event.value.creator_id === authStore.user?.id || authStore.user?.is_admin)
|
||||
)
|
||||
|
||||
function formatDate(date) {
|
||||
return format(new Date(date), 'EEEE d MMMM yyyy à HH:mm', { locale: fr })
|
||||
}
|
||||
|
||||
function formatRelativeDate(date) {
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||
}
|
||||
|
||||
function getParticipationClass(status) {
|
||||
const participation = event.value?.participations.find(p => p.user_id === authStore.user?.id)
|
||||
const isSelected = participation?.status === status
|
||||
|
||||
if (status === 'present') {
|
||||
return isSelected
|
||||
? 'bg-success-100 text-success-700 border-success-300'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-success-50'
|
||||
} else if (status === 'maybe') {
|
||||
return isSelected
|
||||
? 'bg-warning-100 text-warning-700 border-warning-300'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-warning-50'
|
||||
} else {
|
||||
return isSelected
|
||||
? 'bg-accent-100 text-accent-700 border-accent-300'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-accent-50'
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusClass(status) {
|
||||
switch (status) {
|
||||
case 'present':
|
||||
return 'bg-success-100 text-success-700'
|
||||
case 'maybe':
|
||||
return 'bg-warning-100 text-warning-700'
|
||||
case 'absent':
|
||||
return 'bg-accent-100 text-accent-700'
|
||||
default:
|
||||
return 'bg-secondary-100 text-secondary-700'
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusText(status) {
|
||||
switch (status) {
|
||||
case 'present':
|
||||
return 'Présent'
|
||||
case 'maybe':
|
||||
return 'Peut-être'
|
||||
case 'absent':
|
||||
return 'Absent'
|
||||
default:
|
||||
return 'En attente'
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEvent() {
|
||||
try {
|
||||
const response = await axios.get(`/api/events/${route.params.id}`)
|
||||
event.value = response.data
|
||||
|
||||
// Fetch related albums
|
||||
const albumsResponse = await axios.get(`/api/albums?event_id=${event.value.id}`)
|
||||
relatedAlbums.value = albumsResponse.data
|
||||
|
||||
// Initialize edit form
|
||||
editForm.value = {
|
||||
title: event.value.title,
|
||||
description: event.value.description || '',
|
||||
date: format(new Date(event.value.date), "yyyy-MM-dd'T'HH:mm", { locale: fr }),
|
||||
location: event.value.location || ''
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors du chargement de l\'événement')
|
||||
console.error('Error fetching event:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateParticipation(status) {
|
||||
try {
|
||||
const response = await axios.put(`/api/events/${event.value.id}/participation`, { status })
|
||||
event.value = response.data
|
||||
toast.success('Participation mise à jour')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la mise à jour')
|
||||
}
|
||||
}
|
||||
|
||||
async function updateEvent() {
|
||||
updating.value = true
|
||||
try {
|
||||
const response = await axios.put(`/api/events/${event.value.id}`, {
|
||||
...editForm.value,
|
||||
date: new Date(editForm.value.date).toISOString()
|
||||
})
|
||||
event.value = response.data
|
||||
showEditModal.value = false
|
||||
toast.success('Événement mis à jour')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la mise à jour')
|
||||
}
|
||||
updating.value = false
|
||||
}
|
||||
|
||||
async function deleteEvent() {
|
||||
if (!confirm('Êtes-vous sûr de vouloir supprimer cet événement ?')) return
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/events/${event.value.id}`)
|
||||
toast.success('Événement supprimé')
|
||||
router.push('/events')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la suppression')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchEvent()
|
||||
})
|
||||
</script>
|
||||
505
frontend/src/views/Events.vue
Normal file
505
frontend/src/views/Events.vue
Normal file
@@ -0,0 +1,505 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Événements</h1>
|
||||
<p class="text-gray-600 mt-1">Organisez et participez aux événements du groupe</p>
|
||||
</div>
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="btn-primary"
|
||||
>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Nouvel événement
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-gray-200 mb-8">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
<button
|
||||
@click="activeTab = 'upcoming'"
|
||||
class="py-2 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||
:class="activeTab === 'upcoming'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||
>
|
||||
À venir
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'past'"
|
||||
class="py-2 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||
:class="activeTab === 'past'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||
>
|
||||
Passés
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'all'"
|
||||
class="py-2 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||
:class="activeTab === 'all'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||
>
|
||||
Tous
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Events Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div v-for="event in filteredEvents" :key="event.id" class="card hover:shadow-lg transition-shadow cursor-pointer" @click="openEvent(event)">
|
||||
<div class="aspect-video bg-gray-100 relative overflow-hidden">
|
||||
<img v-if="event.cover_image" :src="getMediaUrl(event.cover_image)" :alt="event.title" class="w-full h-full object-cover">
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<Calendar class="w-16 h-16 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<!-- Date Badge -->
|
||||
<div class="absolute top-2 left-2 bg-black bg-opacity-70 text-white text-xs px-2 py-1 rounded">
|
||||
{{ formatDate(event.date) }}
|
||||
</div>
|
||||
|
||||
<!-- Participation Badge -->
|
||||
<div class="absolute top-2 right-2">
|
||||
<span
|
||||
v-if="getUserParticipation(event)"
|
||||
class="px-2 py-1 rounded-full text-xs font-medium text-white"
|
||||
:class="getParticipationBadgeClass(getUserParticipation(event))"
|
||||
>
|
||||
{{ getParticipationText(getUserParticipation(event)) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold text-gray-900 mb-2 line-clamp-2">{{ event.title }}</h3>
|
||||
|
||||
<!-- Description with mentions -->
|
||||
<div v-if="event.description" class="mb-3">
|
||||
<Mentions :content="event.description" :mentions="getMentionsFromContent(event.description)" class="text-sm text-gray-600 line-clamp-2" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center text-gray-600 mb-3">
|
||||
<MapPin class="w-4 h-4 mr-2" />
|
||||
<span class="text-sm">{{ event.location || 'Lieu non spécifié' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center text-gray-600 mb-4">
|
||||
<User class="w-4 h-4 mr-2" />
|
||||
<router-link
|
||||
:to="`/profile/${event.creator_id}`"
|
||||
class="text-sm text-gray-900 hover:text-primary-600 transition-colors cursor-pointer"
|
||||
>
|
||||
{{ event.creator_name }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Quick Participation Buttons -->
|
||||
<div class="flex gap-2 mb-3">
|
||||
<button
|
||||
@click.stop="quickParticipation(event.id, 'present')"
|
||||
class="flex-1 py-2 px-3 rounded text-xs font-medium transition-colors"
|
||||
:class="getUserParticipation(event) === 'present' ? 'bg-success-100 text-success-700' : 'bg-gray-100 text-gray-700 hover:bg-success-50'"
|
||||
>
|
||||
✓ Présent
|
||||
</button>
|
||||
<button
|
||||
@click.stop="quickParticipation(event.id, 'maybe')"
|
||||
class="flex-1 py-2 px-3 rounded text-xs font-medium transition-colors"
|
||||
:class="getUserParticipation(event) === 'maybe' ? 'bg-warning-100 text-warning-700' : 'bg-gray-100 text-gray-700 hover:bg-warning-50'"
|
||||
>
|
||||
? Peut-être
|
||||
</button>
|
||||
<button
|
||||
@click.stop="quickParticipation(event.id, 'absent')"
|
||||
class="flex-1 py-2 px-3 rounded text-xs font-medium transition-colors"
|
||||
:class="getUserParticipation(event) === 'absent' ? 'bg-accent-100 text-accent-700' : 'bg-gray-100 text-gray-700 hover:bg-accent-50'"
|
||||
>
|
||||
✗ Absent
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Participation Stats -->
|
||||
<div class="flex items-center justify-between text-xs text-gray-500">
|
||||
<span>{{ event.present_count || 0 }} présents</span>
|
||||
<span>{{ event.maybe_count || 0 }} peut-être</span>
|
||||
<span>{{ event.absent_count || 0 }} absents</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load more -->
|
||||
<div v-if="hasMoreEvents" class="text-center mt-8">
|
||||
<button
|
||||
@click="loadMoreEvents"
|
||||
:disabled="loading"
|
||||
class="btn-secondary"
|
||||
>
|
||||
{{ loading ? 'Chargement...' : 'Charger plus' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="filteredEvents.length === 0 && !loading" class="text-center py-12">
|
||||
<Calendar class="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">
|
||||
{{ activeTab === 'upcoming' ? 'Aucun événement à venir' :
|
||||
activeTab === 'past' ? 'Aucun événement passé' : 'Aucun événement' }}
|
||||
</h3>
|
||||
<p class="text-gray-600">
|
||||
{{ activeTab === 'upcoming' ? 'Créez le premier événement pour commencer !' :
|
||||
activeTab === 'past' ? 'Les événements passés apparaîtront ici' :
|
||||
'Créez le premier événement pour commencer !' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Create Event Modal -->
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition ease-in duration-150"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showCreateModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="bg-white rounded-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
||||
<h2 class="text-xl font-semibold mb-4">Créer un nouvel événement</h2>
|
||||
|
||||
<form @submit.prevent="createEvent" class="space-y-4">
|
||||
<div>
|
||||
<label class="label">Titre</label>
|
||||
<input
|
||||
v-model="newEvent.title"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
placeholder="Titre de l'événement..."
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Description (optionnel)</label>
|
||||
<MentionInput
|
||||
v-model="newEvent.description"
|
||||
:users="users"
|
||||
:rows="3"
|
||||
placeholder="Décrivez votre événement... (utilisez @username pour mentionner)"
|
||||
@mentions-changed="handleEventMentionsChanged"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Lieu</label>
|
||||
<input
|
||||
v-model="newEvent.location"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="Adresse ou lieu de l'événement..."
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Map Section -->
|
||||
<div>
|
||||
<label class="label">Coordonnées géographiques (optionnel)</label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="text-sm text-gray-600">Latitude</label>
|
||||
<input
|
||||
v-model="newEvent.latitude"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
class="input"
|
||||
placeholder="48.8566"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-600">Longitude</label>
|
||||
<input
|
||||
v-model="newEvent.longitude"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
class="input"
|
||||
placeholder="2.3522"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Preview -->
|
||||
<div v-if="newEvent.latitude && newEvent.longitude" class="mt-3">
|
||||
<div class="bg-gray-100 rounded-lg p-4 h-32 flex items-center justify-center">
|
||||
<div class="text-center text-gray-600">
|
||||
<MapPin class="w-8 h-8 mx-auto mb-2 text-primary-600" />
|
||||
<p class="text-sm">Localisation sélectionnée</p>
|
||||
<p class="text-xs mt-1">{{ newEvent.latitude }}, {{ newEvent.longitude }}</p>
|
||||
<a
|
||||
:href="`https://www.openstreetmap.org/?mlat=${newEvent.latitude}&mlon=${newEvent.longitude}&zoom=15`"
|
||||
target="_blank"
|
||||
class="text-primary-600 hover:underline text-xs mt-2 inline-block"
|
||||
>
|
||||
Voir sur OpenStreetMap
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label">Date et heure</label>
|
||||
<input
|
||||
v-model="newEvent.date"
|
||||
type="datetime-local"
|
||||
required
|
||||
class="input"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Date de fin (optionnel)</label>
|
||||
<input
|
||||
v-model="newEvent.end_date"
|
||||
type="datetime-local"
|
||||
class="input"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="showCreateModal = false"
|
||||
class="flex-1 btn-secondary"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="creating"
|
||||
class="flex-1 btn-primary"
|
||||
>
|
||||
{{ creating ? 'Création...' : 'Créer l\'événement' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useRouter } from 'vue-router'
|
||||
import axios from '@/utils/axios'
|
||||
import { getMediaUrl } from '@/utils/axios'
|
||||
import { formatDistanceToNow, format } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import {
|
||||
Plus,
|
||||
Calendar,
|
||||
User,
|
||||
MapPin
|
||||
} from 'lucide-vue-next'
|
||||
import Mentions from '@/components/Mentions.vue'
|
||||
import MentionInput from '@/components/MentionInput.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const events = ref([])
|
||||
const users = ref([])
|
||||
const loading = ref(false)
|
||||
const creating = ref(false)
|
||||
const showCreateModal = ref(false)
|
||||
const hasMoreEvents = ref(true)
|
||||
const offset = ref(0)
|
||||
const activeTab = ref('upcoming')
|
||||
|
||||
const newEvent = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
date: '',
|
||||
location: '',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
end_date: null
|
||||
})
|
||||
|
||||
const eventMentions = ref([])
|
||||
|
||||
const filteredEvents = computed(() => {
|
||||
if (activeTab.value === 'upcoming') {
|
||||
return events.value.filter(event => new Date(event.date) >= new Date())
|
||||
} else if (activeTab.value === 'past') {
|
||||
return events.value.filter(event => new Date(event.date) < new Date())
|
||||
}
|
||||
return events.value
|
||||
})
|
||||
|
||||
function formatRelativeDate(date) {
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return format(new Date(date), 'dd/MM/yyyy', { locale: fr })
|
||||
}
|
||||
|
||||
function openEvent(event) {
|
||||
router.push(`/events/${event.id}`)
|
||||
}
|
||||
|
||||
function handleEventMentionsChanged(mentions) {
|
||||
eventMentions.value = mentions
|
||||
}
|
||||
|
||||
async function fetchUsers() {
|
||||
try {
|
||||
const response = await axios.get('/api/users')
|
||||
users.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function getMentionsFromContent(content) {
|
||||
if (!content) return []
|
||||
|
||||
const mentions = []
|
||||
const mentionRegex = /@(\w+)/g
|
||||
let match
|
||||
|
||||
while ((match = mentionRegex.exec(content)) !== null) {
|
||||
const username = match[1]
|
||||
const user = users.value.find(u => u.username === username)
|
||||
if (user) {
|
||||
mentions.push({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
full_name: user.full_name
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return mentions
|
||||
}
|
||||
|
||||
function getUserParticipation(event) {
|
||||
const participation = event.participations?.find(p => p.user_id === authStore.user?.id)
|
||||
return participation?.status || null
|
||||
}
|
||||
|
||||
function getParticipationBadgeClass(status) {
|
||||
switch (status) {
|
||||
case 'present': return 'bg-success-600'
|
||||
case 'maybe': return 'bg-warning-600'
|
||||
case 'absent': return 'bg-accent-600'
|
||||
default: return 'bg-gray-600'
|
||||
}
|
||||
}
|
||||
|
||||
function getParticipationText(status) {
|
||||
switch (status) {
|
||||
case 'present': return 'Présent'
|
||||
case 'maybe': return 'Peut-être'
|
||||
case 'absent': return 'Absent'
|
||||
default: return 'En attente'
|
||||
}
|
||||
}
|
||||
|
||||
async function quickParticipation(eventId, status) {
|
||||
try {
|
||||
const response = await axios.put(`/api/events/${eventId}/participation`, {
|
||||
status: status
|
||||
})
|
||||
|
||||
// Update the event in the list
|
||||
const eventIndex = events.value.findIndex(e => e.id === eventId)
|
||||
if (eventIndex !== -1) {
|
||||
events.value[eventIndex] = response.data
|
||||
}
|
||||
|
||||
toast.success(`Participation mise à jour : ${getParticipationText(status)}`)
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la mise à jour de la participation')
|
||||
}
|
||||
}
|
||||
|
||||
async function createEvent() {
|
||||
if (!newEvent.value.title || !newEvent.value.date) return
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
const eventData = {
|
||||
title: newEvent.value.title,
|
||||
description: newEvent.value.description,
|
||||
date: new Date(newEvent.value.date).toISOString(),
|
||||
location: newEvent.value.location,
|
||||
latitude: newEvent.value.latitude,
|
||||
longitude: newEvent.value.longitude,
|
||||
end_date: newEvent.value.end_date ? new Date(newEvent.value.end_date).toISOString() : null
|
||||
}
|
||||
|
||||
await axios.post('/api/events', eventData)
|
||||
|
||||
// Refresh events list
|
||||
await fetchEvents()
|
||||
|
||||
showCreateModal.value = false
|
||||
resetForm()
|
||||
toast.success('Événement créé avec succès')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la création de l\'événement')
|
||||
}
|
||||
creating.value = false
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
newEvent.value = {
|
||||
title: '',
|
||||
description: '',
|
||||
date: '',
|
||||
location: '',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
end_date: null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEvents() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await axios.get(`/api/events?limit=12&offset=${offset.value}`)
|
||||
if (offset.value === 0) {
|
||||
events.value = response.data
|
||||
} else {
|
||||
events.value.push(...response.data)
|
||||
}
|
||||
|
||||
hasMoreEvents.value = response.data.length === 12
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors du chargement des événements')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function loadMoreEvents() {
|
||||
offset.value += 12
|
||||
await fetchEvents()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchEvents()
|
||||
fetchUsers()
|
||||
})
|
||||
</script>
|
||||
304
frontend/src/views/Home.vue
Normal file
304
frontend/src/views/Home.vue
Normal file
@@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Welcome Section -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">
|
||||
Salut {{ user?.full_name }} ! 👋
|
||||
</h1>
|
||||
<p class="text-gray-600">Voici ce qui se passe dans le groupe</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Prochain événement</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ nextEvent?.title || 'Aucun' }}</p>
|
||||
<p v-if="nextEvent" class="text-sm text-gray-500 mt-1">
|
||||
{{ formatDate(nextEvent.date) }}
|
||||
</p>
|
||||
</div>
|
||||
<Calendar class="w-8 h-8 text-primary-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Taux de présence</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ Math.round(user?.attendance_rate || 0) }}%</p>
|
||||
</div>
|
||||
<TrendingUp class="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Nouveaux posts</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ recentPosts }}</p>
|
||||
<p class="text-sm text-gray-500 mt-1">Cette semaine</p>
|
||||
</div>
|
||||
<MessageSquare class="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Membres actifs</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ activeMembers }}</p>
|
||||
</div>
|
||||
<Users class="w-8 h-8 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<!-- Recent Posts -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="card">
|
||||
<div class="p-6 border-b border-gray-100">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Publications récentes</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
<div v-if="posts.length === 0" class="p-6 text-center text-gray-500">
|
||||
Aucune publication récente
|
||||
</div>
|
||||
<div
|
||||
v-for="post in posts"
|
||||
:key="post.id"
|
||||
class="p-6 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div class="flex items-start space-x-3">
|
||||
<img
|
||||
v-if="post.author_avatar"
|
||||
:src="getMediaUrl(post.author_avatar)"
|
||||
:alt="post.author_name"
|
||||
class="w-10 h-10 rounded-full object-cover"
|
||||
>
|
||||
<div v-else class="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<User class="w-5 h-5 text-primary-600" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<p class="font-medium text-gray-900">{{ post.author_name }}</p>
|
||||
<span class="text-xs text-gray-500">{{ formatRelativeDate(post.created_at) }}</span>
|
||||
</div>
|
||||
<div class="mt-1 text-gray-700">
|
||||
<Mentions :content="post.content" :mentions="post.mentioned_users || []" />
|
||||
</div>
|
||||
|
||||
<!-- Post Image -->
|
||||
<img
|
||||
v-if="post.image_url"
|
||||
:src="getMediaUrl(post.image_url)"
|
||||
:alt="post.content"
|
||||
class="mt-3 rounded-lg max-w-full max-h-48 object-cover"
|
||||
>
|
||||
|
||||
<!-- Post Actions -->
|
||||
<div class="flex items-center space-x-4 mt-3 text-sm text-gray-500">
|
||||
<button
|
||||
@click="togglePostLike(post)"
|
||||
class="flex items-center space-x-1 hover:text-primary-600 transition-colors"
|
||||
:class="{ 'text-primary-600': post.is_liked }"
|
||||
>
|
||||
<Heart :class="post.is_liked ? 'fill-current' : ''" class="w-4 h-4" />
|
||||
<span>{{ post.likes_count || 0 }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="toggleComments(post.id)"
|
||||
class="flex items-center space-x-1 hover:text-primary-600 transition-colors"
|
||||
>
|
||||
<MessageCircle class="w-4 h-4" />
|
||||
<span>{{ post.comments_count || 0 }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<router-link
|
||||
to="/posts"
|
||||
class="block p-4 text-center text-primary-600 hover:bg-gray-50 font-medium"
|
||||
>
|
||||
Voir toutes les publications →
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upcoming Events -->
|
||||
<div>
|
||||
<div class="card">
|
||||
<div class="p-6 border-b border-gray-100">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Événements à venir</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
<div v-if="upcomingEvents.length === 0" class="p-6 text-center text-gray-500">
|
||||
Aucun événement prévu
|
||||
</div>
|
||||
<router-link
|
||||
v-for="event in upcomingEvents"
|
||||
:key="event.id"
|
||||
:to="`/events/${event.id}`"
|
||||
class="block p-4 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<h3 class="font-medium text-gray-900">{{ event.title }}</h3>
|
||||
<p class="text-sm text-gray-600 mt-1">{{ formatDate(event.date) }}</p>
|
||||
<p v-if="event.location" class="text-sm text-gray-500 mt-1">
|
||||
📍 {{ event.location }}
|
||||
</p>
|
||||
<div class="mt-3 flex items-center space-x-4 text-xs">
|
||||
<span class="text-green-600">
|
||||
✓ {{ event.present_count }} présents
|
||||
</span>
|
||||
<span class="text-yellow-600">
|
||||
? {{ event.maybe_count }} peut-être
|
||||
</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
<router-link
|
||||
to="/events"
|
||||
class="block p-4 text-center text-primary-600 hover:bg-gray-50 font-medium"
|
||||
>
|
||||
Voir tous les événements →
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Recent Vlogs -->
|
||||
<div class="card mt-6">
|
||||
<div class="p-6 border-b border-gray-100">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Derniers vlogs</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
<div v-if="recentVlogs.length === 0" class="p-6 text-center text-gray-500">
|
||||
Aucun vlog récent
|
||||
</div>
|
||||
<router-link
|
||||
v-for="vlog in recentVlogs"
|
||||
:key="vlog.id"
|
||||
:to="`/vlogs/${vlog.id}`"
|
||||
class="block p-4 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div class="aspect-video bg-gray-100 rounded-lg mb-3 relative overflow-hidden">
|
||||
<img
|
||||
v-if="vlog.thumbnail_url"
|
||||
:src="getMediaUrl(vlog.thumbnail_url)"
|
||||
:alt="vlog.title"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<Film class="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center">
|
||||
<Play class="w-12 h-12 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="font-medium text-gray-900">{{ vlog.title }}</h3>
|
||||
<p class="text-sm text-gray-600 mt-1">Par {{ vlog.author_name }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">{{ vlog.views_count }} vues</p>
|
||||
</router-link>
|
||||
</div>
|
||||
<router-link
|
||||
to="/vlogs"
|
||||
class="block p-4 text-center text-primary-600 hover:bg-gray-50 font-medium"
|
||||
>
|
||||
Voir tous les vlogs →
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import axios from '@/utils/axios'
|
||||
import { getMediaUrl } from '@/utils/axios'
|
||||
import { format, formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import {
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
MessageSquare,
|
||||
Users,
|
||||
User,
|
||||
Film,
|
||||
Play,
|
||||
Heart,
|
||||
MessageCircle
|
||||
} from 'lucide-vue-next'
|
||||
import Mentions from '@/components/Mentions.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const posts = ref([])
|
||||
const upcomingEvents = ref([])
|
||||
const recentVlogs = ref([])
|
||||
const stats = ref({})
|
||||
|
||||
const user = computed(() => authStore.user)
|
||||
const nextEvent = computed(() => upcomingEvents.value[0])
|
||||
const recentPosts = computed(() => stats.value.recent_posts || 0)
|
||||
const activeMembers = computed(() => stats.value.active_members || 0)
|
||||
|
||||
function formatDate(date) {
|
||||
return format(new Date(date), 'EEEE d MMMM à HH:mm', { locale: fr })
|
||||
}
|
||||
|
||||
function formatRelativeDate(date) {
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||
}
|
||||
|
||||
async function fetchDashboardData() {
|
||||
try {
|
||||
// Fetch recent posts
|
||||
const postsResponse = await axios.get('/api/posts?limit=5')
|
||||
posts.value = postsResponse.data
|
||||
|
||||
// Fetch upcoming events
|
||||
const eventsResponse = await axios.get('/api/events?upcoming=true')
|
||||
upcomingEvents.value = eventsResponse.data.slice(0, 3)
|
||||
|
||||
// Fetch recent vlogs
|
||||
const vlogsResponse = await axios.get('/api/vlogs?limit=2')
|
||||
recentVlogs.value = vlogsResponse.data
|
||||
|
||||
// Fetch stats
|
||||
const statsResponse = await axios.get('/api/stats/overview')
|
||||
stats.value = statsResponse.data
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePostLike(post) {
|
||||
try {
|
||||
const response = await axios.post(`/api/posts/${post.id}/like`)
|
||||
post.is_liked = response.data.is_liked
|
||||
post.likes_count = response.data.likes_count
|
||||
} catch (error) {
|
||||
console.error('Error toggling like:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleComments(postId) {
|
||||
const post = posts.value.find(p => p.id === postId)
|
||||
if (post) {
|
||||
post.showComments = !post.showComments
|
||||
if (post.showComments && !post.comments) {
|
||||
post.comments = []
|
||||
post.newComment = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchDashboardData()
|
||||
})
|
||||
</script>
|
||||
177
frontend/src/views/Information.vue
Normal file
177
frontend/src/views/Information.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">Informations</h1>
|
||||
<p class="text-lg text-gray-600">Restez informés des dernières nouvelles de LeDiscord</p>
|
||||
</div>
|
||||
|
||||
<!-- Category Filter -->
|
||||
<div class="flex flex-wrap gap-2 justify-center mb-8">
|
||||
<button
|
||||
@click="selectedCategory = null"
|
||||
:class="[
|
||||
'px-4 py-2 rounded-full text-sm font-medium transition-colors',
|
||||
selectedCategory === null
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
]"
|
||||
>
|
||||
Toutes
|
||||
</button>
|
||||
<button
|
||||
v-for="category in availableCategories"
|
||||
:key="category"
|
||||
@click="selectedCategory = category"
|
||||
:class="[
|
||||
'px-4 py-2 rounded-full text-sm font-medium transition-colors',
|
||||
selectedCategory === category
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
]"
|
||||
>
|
||||
{{ getCategoryLabel(category) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||
<p class="text-gray-600">Chargement des informations...</p>
|
||||
</div>
|
||||
|
||||
<!-- No Information -->
|
||||
<div v-else-if="filteredInformations.length === 0" class="text-center py-12">
|
||||
<div class="text-gray-400 mb-4">
|
||||
<svg class="mx-auto h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Aucune information</h3>
|
||||
<p class="text-gray-600">
|
||||
{{ selectedCategory ? `Aucune information dans la catégorie "${getCategoryLabel(selectedCategory)}"` : 'Aucune information disponible pour le moment' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Information List -->
|
||||
<div v-else class="space-y-6">
|
||||
<div
|
||||
v-for="info in filteredInformations"
|
||||
:key="info.id"
|
||||
class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span
|
||||
:class="[
|
||||
'px-3 py-1 rounded-full text-xs font-medium',
|
||||
getCategoryBadgeClass(info.category)
|
||||
]"
|
||||
>
|
||||
{{ getCategoryLabel(info.category) }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500">
|
||||
{{ formatDate(info.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-2">{{ info.title }}</h2>
|
||||
</div>
|
||||
<div v-if="info.priority > 0" class="flex items-center gap-1 text-amber-600">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium">Important</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="prose prose-gray max-w-none">
|
||||
<div class="whitespace-pre-wrap text-gray-700 leading-relaxed">{{ info.content }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm text-gray-500">
|
||||
<span>Mis à jour le {{ formatDate(info.updated_at) }}</span>
|
||||
<span v-if="!info.is_published" class="text-amber-600 font-medium">Brouillon</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import axios from '@/utils/axios'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// State
|
||||
const informations = ref([])
|
||||
const loading = ref(true)
|
||||
const selectedCategory = ref(null)
|
||||
|
||||
// Computed
|
||||
const availableCategories = computed(() => {
|
||||
const categories = [...new Set(informations.value.map(info => info.category))]
|
||||
return categories.sort()
|
||||
})
|
||||
|
||||
const filteredInformations = computed(() => {
|
||||
if (!selectedCategory.value) {
|
||||
return informations.value
|
||||
}
|
||||
return informations.value.filter(info => info.category === selectedCategory.value)
|
||||
})
|
||||
|
||||
// Methods
|
||||
function getCategoryLabel(category) {
|
||||
const labels = {
|
||||
'general': 'Général',
|
||||
'release': 'Nouvelle version',
|
||||
'upcoming': 'À venir',
|
||||
'maintenance': 'Maintenance',
|
||||
'feature': 'Nouvelle fonctionnalité',
|
||||
'bugfix': 'Correction de bug'
|
||||
}
|
||||
return labels[category] || category
|
||||
}
|
||||
|
||||
function getCategoryBadgeClass(category) {
|
||||
const classes = {
|
||||
'general': 'bg-gray-100 text-gray-800',
|
||||
'release': 'bg-green-100 text-green-800',
|
||||
'upcoming': 'bg-blue-100 text-blue-800',
|
||||
'maintenance': 'bg-yellow-100 text-yellow-800',
|
||||
'feature': 'bg-purple-100 text-purple-800',
|
||||
'bugfix': 'bg-red-100 text-red-800'
|
||||
}
|
||||
return classes[category] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||
}
|
||||
|
||||
async function fetchInformations() {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await axios.get('/api/information/public')
|
||||
informations.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Error fetching informations:', error)
|
||||
toast.error('Erreur lors du chargement des informations')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
fetchInformations()
|
||||
})
|
||||
</script>
|
||||
89
frontend/src/views/Login.vue
Normal file
89
frontend/src/views/Login.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6">Connexion</h2>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="label">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
required
|
||||
class="input"
|
||||
placeholder="ton.email@example.com"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="label">Mot de passe</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
required
|
||||
class="input"
|
||||
placeholder="••••••••"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="w-full btn-primary"
|
||||
>
|
||||
{{ loading ? 'Connexion...' : 'Se connecter' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<p v-if="registrationEnabled" class="text-sm text-gray-600">
|
||||
Pas encore de compte ?
|
||||
<router-link to="/register" class="font-medium text-primary-600 hover:text-primary-500">
|
||||
S'inscrire
|
||||
</router-link>
|
||||
</p>
|
||||
<p v-else class="text-sm text-gray-500">
|
||||
Les nouvelles inscriptions sont temporairement désactivées
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import axios from '@/utils/axios'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const form = ref({
|
||||
email: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const registrationEnabled = ref(true)
|
||||
|
||||
async function handleLogin() {
|
||||
loading.value = true
|
||||
await authStore.login(form.value.email, form.value.password)
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function checkRegistrationStatus() {
|
||||
try {
|
||||
const response = await axios.get('/api/settings/public/registration-status')
|
||||
const status = response.data
|
||||
registrationEnabled.value = status.can_register
|
||||
} catch (error) {
|
||||
console.error('Error checking registration status:', error)
|
||||
// En cas d'erreur, on active l'inscription par défaut
|
||||
registrationEnabled.value = true
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkRegistrationStatus()
|
||||
})
|
||||
</script>
|
||||
533
frontend/src/views/MyTickets.vue
Normal file
533
frontend/src/views/MyTickets.vue
Normal file
@@ -0,0 +1,533 @@
|
||||
<template>
|
||||
<div class="max-w-6xl mx-auto px-4 py-8">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">Mes tickets</h1>
|
||||
<p class="text-lg text-gray-600">Suivez vos demandes et signalements</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-white rounded-lg shadow p-4 text-center">
|
||||
<div class="text-2xl font-bold text-blue-600">{{ ticketStats.open }}</div>
|
||||
<div class="text-sm text-gray-600">Ouverts</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-4 text-center">
|
||||
<div class="text-2xl font-bold text-yellow-600">{{ ticketStats.in_progress }}</div>
|
||||
<div class="text-sm text-gray-600">En cours</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-4 text-center">
|
||||
<div class="text-2xl font-bold text-green-600">{{ ticketStats.resolved }}</div>
|
||||
<div class="text-sm text-gray-600">Résolus</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-4 text-center">
|
||||
<div class="text-2xl font-bold text-gray-600">{{ ticketStats.total }}</div>
|
||||
<div class="text-sm text-gray-600">Total</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-2 justify-center mb-6">
|
||||
<button
|
||||
@click="selectedStatus = null"
|
||||
:class="[
|
||||
'px-4 py-2 rounded-full text-sm font-medium transition-colors',
|
||||
selectedStatus === null
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
]"
|
||||
>
|
||||
Tous
|
||||
</button>
|
||||
<button
|
||||
v-for="status in availableStatuses"
|
||||
:key="status"
|
||||
@click="selectedStatus = status"
|
||||
:class="[
|
||||
'px-4 py-2 rounded-full text-sm font-medium transition-colors',
|
||||
selectedStatus === status
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
]"
|
||||
>
|
||||
{{ getStatusLabel(status) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||
<p class="text-gray-600">Chargement de vos tickets...</p>
|
||||
</div>
|
||||
|
||||
<!-- No Tickets -->
|
||||
<div v-else-if="filteredTickets.length === 0" class="text-center py-12">
|
||||
<div class="text-gray-400 mb-4">
|
||||
<svg class="mx-auto h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Aucun ticket</h3>
|
||||
<p class="text-gray-600">
|
||||
{{ selectedStatus ? `Aucun ticket avec le statut "${getStatusLabel(selectedStatus)}"` : 'Vous n\'avez pas encore créé de ticket' }}
|
||||
</p>
|
||||
<button
|
||||
@click="showTicketModal = true"
|
||||
class="mt-4 btn-primary"
|
||||
>
|
||||
Créer mon premier ticket
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tickets List -->
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="ticket in filteredTickets"
|
||||
:key="ticket.id"
|
||||
class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span
|
||||
:class="[
|
||||
'px-3 py-1 rounded-full text-xs font-medium',
|
||||
getTypeBadgeClass(ticket.ticket_type)
|
||||
]"
|
||||
>
|
||||
{{ getTypeLabel(ticket.ticket_type) }}
|
||||
</span>
|
||||
<span
|
||||
:class="[
|
||||
'px-3 py-1 rounded-full text-xs font-medium',
|
||||
getStatusBadgeClass(ticket.status)
|
||||
]"
|
||||
>
|
||||
{{ getStatusLabel(ticket.status) }}
|
||||
</span>
|
||||
<span
|
||||
:class="[
|
||||
'px-3 py-1 rounded-full text-xs font-medium',
|
||||
getPriorityBadgeClass(ticket.priority)
|
||||
]"
|
||||
>
|
||||
{{ getPriorityLabel(ticket.priority) }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500">
|
||||
{{ formatDate(ticket.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-2">{{ ticket.title }}</h2>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
@click="editTicket(ticket)"
|
||||
class="text-blue-600 hover:text-blue-800 text-sm font-medium"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
<button
|
||||
@click="deleteTicket(ticket.id)"
|
||||
class="text-red-600 hover:text-red-800 text-sm font-medium"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="prose prose-gray max-w-none mb-4">
|
||||
<div class="whitespace-pre-wrap text-gray-700 leading-relaxed">{{ ticket.description }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Screenshot -->
|
||||
<div v-if="ticket.screenshot_path" class="mb-4">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">Screenshot :</h4>
|
||||
<img
|
||||
:src="getMediaUrl(ticket.screenshot_path)"
|
||||
:alt="ticket.title"
|
||||
class="max-w-md rounded-lg border border-gray-200 cursor-pointer hover:opacity-90 transition-opacity"
|
||||
@click="viewScreenshot(ticket.screenshot_path)"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Admin Notes -->
|
||||
<div v-if="ticket.admin_notes" class="mb-4 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<h4 class="text-sm font-medium text-blue-800 mb-2">Réponse de l'équipe :</h4>
|
||||
<div class="text-sm text-blue-700 whitespace-pre-wrap">{{ ticket.admin_notes }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm text-gray-500">
|
||||
<span>Mis à jour le {{ formatDate(ticket.updated_at) }}</span>
|
||||
<span v-if="ticket.assigned_admin_name" class="text-blue-600">
|
||||
Assigné à {{ ticket.assigned_admin_name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ticket Modal -->
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition ease-in duration-150"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showTicketModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="bg-white rounded-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold">
|
||||
{{ editingTicket ? 'Modifier le ticket' : 'Nouveau ticket' }}
|
||||
</h2>
|
||||
<button
|
||||
@click="showTicketModal = false"
|
||||
class="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="saveTicket" class="space-y-6">
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label class="label">Titre *</label>
|
||||
<input
|
||||
v-model="ticketForm.title"
|
||||
type="text"
|
||||
class="input"
|
||||
required
|
||||
maxlength="200"
|
||||
placeholder="Titre de votre ticket"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Type -->
|
||||
<div>
|
||||
<label class="label">Type</label>
|
||||
<select v-model="ticketForm.ticket_type" class="input">
|
||||
<option value="bug">🐛 Bug</option>
|
||||
<option value="feature_request">💡 Demande de fonctionnalité</option>
|
||||
<option value="improvement">✨ Amélioration</option>
|
||||
<option value="support">❓ Support</option>
|
||||
<option value="other">📝 Autre</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Priority -->
|
||||
<div>
|
||||
<label class="label">Priorité</label>
|
||||
<select v-model="ticketForm.priority" class="input">
|
||||
<option value="low">🟢 Faible</option>
|
||||
<option value="medium">🟡 Moyenne</option>
|
||||
<option value="high">🟠 Élevée</option>
|
||||
<option value="urgent">🔴 Urgente</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label class="label">Description *</label>
|
||||
<textarea
|
||||
v-model="ticketForm.description"
|
||||
class="input resize-none"
|
||||
rows="6"
|
||||
required
|
||||
placeholder="Décrivez votre problème ou votre demande en détail..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Screenshot -->
|
||||
<div>
|
||||
<label class="label">Screenshot (optionnel)</label>
|
||||
<input
|
||||
ref="screenshotInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="input"
|
||||
@change="handleScreenshotChange"
|
||||
>
|
||||
<div class="mt-1 text-sm text-gray-600">
|
||||
Formats acceptés : JPG, PNG, GIF, WebP (max 5MB)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="showTicketModal = false"
|
||||
class="flex-1 btn-secondary"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-1 btn-primary"
|
||||
:disabled="savingTicket"
|
||||
>
|
||||
<Save class="w-4 h-4 mr-2" />
|
||||
{{ savingTicket ? 'Sauvegarde...' : 'Sauvegarder' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Screenshot Viewer Modal -->
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition ease-in duration-150"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showScreenshotModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-75 z-50 flex items-center justify-center p-4"
|
||||
@click="showScreenshotModal = false"
|
||||
>
|
||||
<div class="max-w-4xl max-h-[90vh] overflow-auto">
|
||||
<img
|
||||
:src="selectedScreenshot"
|
||||
alt="Screenshot"
|
||||
class="max-w-full h-auto rounded-lg"
|
||||
@click.stop
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import { Save } from 'lucide-vue-next'
|
||||
import axios from '@/utils/axios'
|
||||
import { getMediaUrl } from '@/utils/axios'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// State
|
||||
const tickets = ref([])
|
||||
const loading = ref(true)
|
||||
const selectedStatus = ref(null)
|
||||
const showTicketModal = ref(false)
|
||||
const editingTicket = ref(null)
|
||||
const savingTicket = ref(false)
|
||||
const showScreenshotModal = ref(false)
|
||||
const selectedScreenshot = ref('')
|
||||
const screenshotInput = ref(null)
|
||||
|
||||
const ticketForm = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
ticket_type: 'other',
|
||||
priority: 'medium'
|
||||
})
|
||||
|
||||
// Computed
|
||||
const availableStatuses = computed(() => {
|
||||
const statuses = [...new Set(tickets.value.map(ticket => ticket.status))]
|
||||
return statuses.sort()
|
||||
})
|
||||
|
||||
const filteredTickets = computed(() => {
|
||||
if (!selectedStatus.value) {
|
||||
return tickets.value
|
||||
}
|
||||
return tickets.value.filter(ticket => ticket.status === selectedStatus.value)
|
||||
})
|
||||
|
||||
const ticketStats = computed(() => {
|
||||
const stats = {
|
||||
open: tickets.value.filter(t => t.status === 'open').length,
|
||||
in_progress: tickets.value.filter(t => t.status === 'in_progress').length,
|
||||
resolved: tickets.value.filter(t => t.status === 'resolved').length,
|
||||
total: tickets.value.length
|
||||
}
|
||||
return stats
|
||||
})
|
||||
|
||||
// Methods
|
||||
function getTypeLabel(type) {
|
||||
const labels = {
|
||||
'bug': 'Bug',
|
||||
'feature_request': 'Fonctionnalité',
|
||||
'improvement': 'Amélioration',
|
||||
'support': 'Support',
|
||||
'other': 'Autre'
|
||||
}
|
||||
return labels[type] || type
|
||||
}
|
||||
|
||||
function getStatusLabel(status) {
|
||||
const labels = {
|
||||
'open': 'Ouvert',
|
||||
'in_progress': 'En cours',
|
||||
'resolved': 'Résolu',
|
||||
'closed': 'Fermé'
|
||||
}
|
||||
return labels[status] || status
|
||||
}
|
||||
|
||||
function getPriorityLabel(priority) {
|
||||
const labels = {
|
||||
'low': 'Faible',
|
||||
'medium': 'Moyenne',
|
||||
'high': 'Élevée',
|
||||
'urgent': 'Urgente'
|
||||
}
|
||||
return labels[priority] || priority
|
||||
}
|
||||
|
||||
function getTypeBadgeClass(type) {
|
||||
const classes = {
|
||||
'bug': 'bg-red-100 text-red-800',
|
||||
'feature_request': 'bg-blue-100 text-blue-800',
|
||||
'improvement': 'bg-green-100 text-green-800',
|
||||
'support': 'bg-yellow-100 text-yellow-800',
|
||||
'other': 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
return classes[type] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
|
||||
function getStatusBadgeClass(status) {
|
||||
const classes = {
|
||||
'open': 'bg-blue-100 text-blue-800',
|
||||
'in_progress': 'bg-yellow-100 text-yellow-800',
|
||||
'resolved': 'bg-green-100 text-green-800',
|
||||
'closed': 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
return classes[status] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
|
||||
function getPriorityBadgeClass(priority) {
|
||||
const classes = {
|
||||
'low': 'bg-green-100 text-green-800',
|
||||
'medium': 'bg-yellow-100 text-yellow-800',
|
||||
'high': 'bg-orange-100 text-orange-800',
|
||||
'urgent': 'bg-red-100 text-red-800'
|
||||
}
|
||||
return classes[priority] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||
}
|
||||
|
||||
function resetTicketForm() {
|
||||
ticketForm.value = {
|
||||
title: '',
|
||||
description: '',
|
||||
ticket_type: 'other',
|
||||
priority: 'medium'
|
||||
}
|
||||
editingTicket.value = null
|
||||
if (screenshotInput.value) {
|
||||
screenshotInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function editTicket(ticket) {
|
||||
editingTicket.value = ticket
|
||||
ticketForm.value = {
|
||||
title: ticket.title,
|
||||
description: ticket.description,
|
||||
ticket_type: ticket.ticket_type,
|
||||
priority: ticket.priority
|
||||
}
|
||||
showTicketModal.value = true
|
||||
}
|
||||
|
||||
function handleScreenshotChange(event) {
|
||||
const file = event.target.files[0]
|
||||
if (file && file.size > 5 * 1024 * 1024) {
|
||||
toast.error('Le fichier est trop volumineux (max 5MB)')
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function viewScreenshot(screenshotPath) {
|
||||
selectedScreenshot.value = getMediaUrl(screenshotPath)
|
||||
showScreenshotModal.value = true
|
||||
}
|
||||
|
||||
async function saveTicket() {
|
||||
savingTicket.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('title', ticketForm.value.title)
|
||||
formData.append('description', ticketForm.value.description)
|
||||
formData.append('ticket_type', ticketForm.value.ticket_type)
|
||||
formData.append('priority', ticketForm.value.priority)
|
||||
|
||||
if (screenshotInput.value && screenshotInput.value.files[0]) {
|
||||
formData.append('screenshot', screenshotInput.value.files[0])
|
||||
}
|
||||
|
||||
if (editingTicket.value) {
|
||||
// Update existing ticket
|
||||
await axios.put(`/api/tickets/${editingTicket.value.id}`, ticketForm.value)
|
||||
toast.success('Ticket mis à jour')
|
||||
} else {
|
||||
// Create new ticket
|
||||
await axios.post('/api/tickets/', formData)
|
||||
toast.success('Ticket créé avec succès')
|
||||
}
|
||||
|
||||
await fetchTickets()
|
||||
showTicketModal.value = false
|
||||
resetTicketForm()
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la sauvegarde')
|
||||
console.error('Error saving ticket:', error)
|
||||
} finally {
|
||||
savingTicket.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTicket(ticketId) {
|
||||
if (!confirm('Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible.')) return
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/tickets/${ticketId}`)
|
||||
await fetchTickets()
|
||||
toast.success('Ticket supprimé')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la suppression')
|
||||
console.error('Error deleting ticket:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTickets() {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await axios.get('/api/tickets/')
|
||||
tickets.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Error fetching tickets:', error)
|
||||
toast.error('Erreur lors du chargement des tickets')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
fetchTickets()
|
||||
})
|
||||
</script>
|
||||
521
frontend/src/views/Posts.vue
Normal file
521
frontend/src/views/Posts.vue
Normal file
@@ -0,0 +1,521 @@
|
||||
<template>
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Publications</h1>
|
||||
<p class="text-gray-600 mt-1">Partagez vos moments avec le groupe</p>
|
||||
</div>
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="btn-primary"
|
||||
>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Nouvelle publication
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Create Post Form -->
|
||||
<div class="card p-6 mb-8">
|
||||
<div class="flex items-start space-x-3">
|
||||
<img
|
||||
v-if="user?.avatar_url"
|
||||
:src="getMediaUrl(user.avatar_url)"
|
||||
:alt="user?.full_name"
|
||||
class="w-10 h-10 rounded-full object-cover"
|
||||
>
|
||||
<div v-else class="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<User class="w-5 h-5 text-primary-600" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<MentionInput
|
||||
v-model="newPost.content"
|
||||
:users="users"
|
||||
:rows="3"
|
||||
placeholder="Quoi de neuf ? Mentionnez des amis avec @..."
|
||||
@mentions-changed="handleMentionsChanged"
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<div class="flex items-center justify-between mt-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="showImageUpload = !showImageUpload"
|
||||
class="text-gray-500 hover:text-primary-600 transition-colors"
|
||||
title="Ajouter une image"
|
||||
>
|
||||
<Image class="w-5 h-5" />
|
||||
</button>
|
||||
<span class="text-xs text-gray-500">{{ newPost.content.length }}/5000</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="createPost"
|
||||
:disabled="!newPost.content.trim() || creating"
|
||||
class="btn-primary"
|
||||
>
|
||||
{{ creating ? 'Publication...' : 'Publier' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Image upload -->
|
||||
<div v-if="showImageUpload" class="mt-3">
|
||||
<input
|
||||
ref="imageInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="handleImageChange"
|
||||
>
|
||||
<button
|
||||
@click="$refs.imageInput.click()"
|
||||
class="btn-secondary text-sm"
|
||||
>
|
||||
<Upload class="w-4 h-4 mr-2" />
|
||||
Sélectionner une image
|
||||
</button>
|
||||
<div v-if="newPost.image_url" class="mt-2 relative inline-block">
|
||||
<img
|
||||
:src="getMediaUrl(newPost.image_url)"
|
||||
:alt="newPost.content"
|
||||
class="max-w-xs max-h-32 rounded-lg object-cover"
|
||||
>
|
||||
<button
|
||||
@click="removeImage"
|
||||
class="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-600"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Posts List -->
|
||||
<div class="space-y-6">
|
||||
<div
|
||||
v-for="post in posts"
|
||||
:key="post.id"
|
||||
class="card p-6"
|
||||
>
|
||||
<!-- Post Header -->
|
||||
<div class="flex items-start space-x-3 mb-4">
|
||||
<img
|
||||
v-if="post.author_avatar"
|
||||
:src="getMediaUrl(post.author_avatar)"
|
||||
:alt="post.author_name"
|
||||
class="w-10 h-10 rounded-full object-cover"
|
||||
>
|
||||
<div v-else class="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<User class="w-5 h-5 text-primary-600" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<router-link
|
||||
:to="`/profile/${post.author_id}`"
|
||||
class="font-medium text-gray-900 hover:text-primary-600 transition-colors"
|
||||
>
|
||||
{{ post.author_name }}
|
||||
</router-link>
|
||||
<span class="text-sm text-gray-500">{{ formatRelativeDate(post.created_at) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Mentions -->
|
||||
<div v-if="post.mentioned_users && post.mentioned_users.length > 0" class="mt-1">
|
||||
<span class="text-xs text-gray-500">Mentionne : </span>
|
||||
<span
|
||||
v-for="mentionedUser in post.mentioned_users"
|
||||
:key="mentionedUser.id"
|
||||
class="text-xs text-primary-600 hover:underline cursor-pointer"
|
||||
>
|
||||
@{{ mentionedUser.username }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete button for author or admin -->
|
||||
<button
|
||||
v-if="canDeletePost(post)"
|
||||
@click="deletePost(post.id)"
|
||||
class="text-gray-400 hover:text-red-600 transition-colors"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Post Content -->
|
||||
<div class="mb-4">
|
||||
<Mentions :content="post.content" :mentions="post.mentioned_users" />
|
||||
|
||||
<!-- Post Image -->
|
||||
<img
|
||||
v-if="post.image_url"
|
||||
:src="getMediaUrl(post.image_url)"
|
||||
:alt="post.content"
|
||||
class="mt-3 rounded-lg max-w-full max-h-96 object-cover"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Post Actions -->
|
||||
<div class="flex items-center space-x-6 text-sm text-gray-500">
|
||||
<button
|
||||
@click="togglePostLike(post)"
|
||||
class="flex items-center space-x-2 hover:text-primary-600 transition-colors"
|
||||
:class="{ 'text-primary-600': post.is_liked }"
|
||||
>
|
||||
<Heart :class="post.is_liked ? 'fill-current' : ''" class="w-4 h-4" />
|
||||
<span>{{ post.likes_count || 0 }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="toggleComments(post.id)"
|
||||
class="flex items-center space-x-2 hover:text-primary-600 transition-colors"
|
||||
>
|
||||
<MessageCircle class="w-4 h-4" />
|
||||
<span>{{ post.comments_count || 0 }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Comments Section -->
|
||||
<div v-if="post.showComments" class="mt-4 pt-4 border-t border-gray-100">
|
||||
<!-- Add Comment -->
|
||||
<div class="flex items-start space-x-3 mb-4">
|
||||
<img
|
||||
v-if="user?.avatar_url"
|
||||
:src="getMediaUrl(user.avatar_url)"
|
||||
:alt="user?.full_name"
|
||||
class="w-8 h-8 rounded-full object-cover"
|
||||
>
|
||||
<div v-else class="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<User class="w-4 h-4 text-primary-600" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<MentionInput
|
||||
v-model="post.newComment"
|
||||
:users="users"
|
||||
:rows="2"
|
||||
placeholder="Ajouter un commentaire... (utilisez @username pour mentionner)"
|
||||
@mentions-changed="(mentions) => handleCommentMentionsChanged(post.id, mentions)"
|
||||
/>
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
<span class="text-xs text-gray-500">{{ (post.newComment || '').length }}/500</span>
|
||||
<button
|
||||
@click="addComment(post)"
|
||||
:disabled="!post.newComment?.trim()"
|
||||
class="btn-primary text-sm px-3 py-1"
|
||||
>
|
||||
Commenter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comments List -->
|
||||
<div v-if="post.comments && post.comments.length > 0" class="space-y-3">
|
||||
<div
|
||||
v-for="comment in post.comments"
|
||||
:key="comment.id"
|
||||
class="flex items-start space-x-3 p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<img
|
||||
v-if="comment.author_avatar"
|
||||
:src="getMediaUrl(comment.author_avatar)"
|
||||
:alt="comment.author_name"
|
||||
class="w-6 h-6 rounded-full object-cover"
|
||||
>
|
||||
<div v-else class="w-6 h-6 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<User class="w-3 h-3 text-gray-500" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<router-link
|
||||
:to="`/profile/${comment.author_id}`"
|
||||
class="font-medium text-sm text-gray-900 hover:text-primary-600 transition-colors"
|
||||
>
|
||||
{{ comment.author_name }}
|
||||
</router-link>
|
||||
<span class="text-xs text-gray-500">{{ formatRelativeDate(comment.created_at) }}</span>
|
||||
</div>
|
||||
<Mentions :content="comment.content" :mentions="comment.mentioned_users || []" class="text-sm text-gray-700 mt-1" />
|
||||
</div>
|
||||
|
||||
<!-- Delete comment button -->
|
||||
<button
|
||||
v-if="canDeleteComment(comment)"
|
||||
@click="deleteComment(post.id, comment.id)"
|
||||
class="text-gray-400 hover:text-red-600 transition-colors"
|
||||
title="Supprimer"
|
||||
>
|
||||
<X class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load more -->
|
||||
<div v-if="hasMorePosts" class="text-center mt-8">
|
||||
<button
|
||||
@click="loadMorePosts"
|
||||
:disabled="loading"
|
||||
class="btn-secondary"
|
||||
>
|
||||
{{ loading ? 'Chargement...' : 'Charger plus' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="posts.length === 0 && !loading" class="text-center py-12">
|
||||
<MessageSquare class="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Aucune publication</h3>
|
||||
<p class="text-gray-600">Soyez le premier à partager quelque chose !</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import axios from '@/utils/axios'
|
||||
import { getMediaUrl } from '@/utils/axios'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import {
|
||||
Plus,
|
||||
User,
|
||||
Image,
|
||||
Upload,
|
||||
X,
|
||||
Heart,
|
||||
MessageCircle,
|
||||
Trash2,
|
||||
MessageSquare
|
||||
} from 'lucide-vue-next'
|
||||
import Mentions from '@/components/Mentions.vue'
|
||||
import MentionInput from '@/components/MentionInput.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const user = computed(() => authStore.user)
|
||||
const posts = ref([])
|
||||
const users = ref([])
|
||||
const loading = ref(false)
|
||||
const creating = ref(false)
|
||||
const showCreateModal = ref(false)
|
||||
const showImageUpload = ref(false)
|
||||
|
||||
const offset = ref(0)
|
||||
const hasMorePosts = ref(true)
|
||||
|
||||
const newPost = ref({
|
||||
content: '',
|
||||
image_url: '',
|
||||
mentioned_user_ids: []
|
||||
})
|
||||
|
||||
function formatRelativeDate(date) {
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||
}
|
||||
|
||||
function handleMentionsChanged(mentions) {
|
||||
newPost.value.mentioned_user_ids = mentions.map(m => m.id)
|
||||
}
|
||||
|
||||
function handleCommentMentionsChanged(postId, mentions) {
|
||||
const post = posts.value.find(p => p.id === postId)
|
||||
if (post) {
|
||||
post.commentMentions = mentions
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function createPost() {
|
||||
if (!newPost.value.content.trim()) return
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
const response = await axios.post('/api/posts', {
|
||||
content: newPost.value.content,
|
||||
image_url: newPost.value.image_url,
|
||||
mentioned_user_ids: newPost.value.mentioned_user_ids
|
||||
})
|
||||
|
||||
// Add new post to the beginning of the list
|
||||
posts.value.unshift(response.data)
|
||||
|
||||
// Reset form
|
||||
newPost.value = {
|
||||
content: '',
|
||||
image_url: '',
|
||||
mentioned_user_ids: []
|
||||
}
|
||||
showImageUpload.value = false
|
||||
|
||||
toast.success('Publication créée avec succès')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la création de la publication')
|
||||
}
|
||||
creating.value = false
|
||||
}
|
||||
|
||||
async function togglePostLike(post) {
|
||||
try {
|
||||
const response = await axios.post(`/api/posts/${post.id}/like`)
|
||||
post.is_liked = response.data.is_liked
|
||||
post.likes_count = response.data.likes_count
|
||||
toast.success(post.is_liked ? 'Post liké' : 'Like retiré')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la mise à jour du like')
|
||||
}
|
||||
}
|
||||
|
||||
function toggleComments(postId) {
|
||||
const post = posts.value.find(p => p.id === postId)
|
||||
if (post) {
|
||||
post.showComments = !post.showComments
|
||||
if (post.showComments && !post.comments) {
|
||||
post.comments = []
|
||||
post.newComment = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function addComment(post) {
|
||||
if (!post.newComment?.trim()) return
|
||||
|
||||
try {
|
||||
const response = await axios.post(`/api/posts/${post.id}/comment`, {
|
||||
content: post.newComment.trim()
|
||||
})
|
||||
|
||||
// Add new comment to the post
|
||||
if (!post.comments) post.comments = []
|
||||
post.comments.push(response.data.comment)
|
||||
post.comments_count = (post.comments_count || 0) + 1
|
||||
|
||||
// Reset comment input
|
||||
post.newComment = ''
|
||||
|
||||
toast.success('Commentaire ajouté')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de l\'ajout du commentaire')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteComment(postId, commentId) {
|
||||
try {
|
||||
await axios.delete(`/api/posts/${postId}/comment/${commentId}`)
|
||||
|
||||
const post = posts.value.find(p => p.id === postId)
|
||||
if (post && post.comments) {
|
||||
post.comments = post.comments.filter(c => c.id !== commentId)
|
||||
post.comments_count = Math.max(0, (post.comments_count || 1) - 1)
|
||||
}
|
||||
|
||||
toast.success('Commentaire supprimé')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la suppression du commentaire')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImageChange(event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Veuillez sélectionner une image')
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('L\'image est trop volumineuse (max 5MB)')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await axios.post('/api/posts/upload-image', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
|
||||
newPost.value.image_url = response.data.image_url
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de l\'upload de l\'image')
|
||||
}
|
||||
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
function removeImage() {
|
||||
newPost.value.image_url = ''
|
||||
}
|
||||
|
||||
async function fetchPosts() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await axios.get(`/api/posts?limit=10&offset=${offset.value}`)
|
||||
if (offset.value === 0) {
|
||||
posts.value = response.data
|
||||
} else {
|
||||
posts.value.push(...response.data)
|
||||
}
|
||||
|
||||
hasMorePosts.value = response.data.length === 10
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors du chargement des publications')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function fetchUsers() {
|
||||
try {
|
||||
const response = await axios.get('/api/users')
|
||||
users.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMorePosts() {
|
||||
offset.value += 10
|
||||
await fetchPosts()
|
||||
}
|
||||
|
||||
async function deletePost(postId) {
|
||||
if (!confirm('Êtes-vous sûr de vouloir supprimer cette publication ?')) return
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/posts/${postId}`)
|
||||
posts.value = posts.value.filter(p => p.id !== postId)
|
||||
toast.success('Publication supprimée')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la suppression')
|
||||
}
|
||||
}
|
||||
|
||||
function canDeletePost(post) {
|
||||
return user.value && (post.author_id === user.value.id || user.value.is_admin)
|
||||
}
|
||||
|
||||
function canDeleteComment(comment) {
|
||||
return user.value && (comment.author_id === user.value.id || user.value.is_admin)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchPosts()
|
||||
fetchUsers()
|
||||
})
|
||||
</script>
|
||||
253
frontend/src/views/Profile.vue
Normal file
253
frontend/src/views/Profile.vue
Normal file
@@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-8">Mon profil</h1>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<!-- Avatar Section -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="card p-6">
|
||||
<div class="text-center">
|
||||
<div class="relative inline-block">
|
||||
<img
|
||||
v-if="user?.avatar_url"
|
||||
:src="getMediaUrl(user.avatar_url)"
|
||||
:alt="user?.full_name"
|
||||
class="w-32 h-32 rounded-full object-cover border-4 border-white shadow-lg"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="w-32 h-32 rounded-full bg-primary-100 flex items-center justify-center border-4 border-white shadow-lg"
|
||||
>
|
||||
<User class="w-16 h-16 text-primary-600" />
|
||||
</div>
|
||||
|
||||
<!-- Upload Button Overlay -->
|
||||
<button
|
||||
@click="$refs.avatarInput.click()"
|
||||
class="absolute bottom-0 right-0 bg-primary-600 text-white p-2 rounded-full shadow-lg hover:bg-primary-700 transition-colors"
|
||||
title="Changer l'avatar"
|
||||
>
|
||||
<Camera class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="avatarInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="handleAvatarChange"
|
||||
>
|
||||
|
||||
<h2 class="text-xl font-semibold text-gray-900 mt-4">{{ user?.full_name }}</h2>
|
||||
<p class="text-gray-600">@{{ user?.username }}</p>
|
||||
|
||||
<div class="mt-4 text-sm text-gray-500">
|
||||
<p>Membre depuis {{ formatDate(user?.created_at) }}</p>
|
||||
<p>Taux de présence : {{ Math.round(user?.attendance_rate || 0) }}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Profile Form -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="card p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">Informations personnelles</h3>
|
||||
|
||||
<form @submit.prevent="updateProfile" class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="label">Nom complet</label>
|
||||
<input
|
||||
v-model="form.full_name"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
placeholder="Prénom Nom"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Nom d'utilisateur</label>
|
||||
<input
|
||||
v-model="form.username"
|
||||
type="text"
|
||||
disabled
|
||||
class="input bg-gray-50"
|
||||
title="Le nom d'utilisateur ne peut pas être modifié"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Email</label>
|
||||
<input
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
disabled
|
||||
class="input bg-gray-50"
|
||||
title="L'email ne peut pas être modifié"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Bio</label>
|
||||
<textarea
|
||||
v-model="form.bio"
|
||||
rows="4"
|
||||
class="input"
|
||||
placeholder="Parlez-nous un peu de vous..."
|
||||
maxlength="500"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">{{ (form.bio || '').length }}/500 caractères</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="updating"
|
||||
class="btn-primary"
|
||||
>
|
||||
{{ updating ? 'Mise à jour...' : 'Mettre à jour' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="resetForm"
|
||||
class="btn-secondary"
|
||||
>
|
||||
Réinitialiser
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Stats Section -->
|
||||
<div class="card p-6 mt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">Mes statistiques</h3>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-primary-600">{{ stats.posts_count || 0 }}</div>
|
||||
<div class="text-sm text-gray-600">Publications</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-green-600">{{ stats.vlogs_count || 0 }}</div>
|
||||
<div class="text-sm text-gray-600">Vlogs</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-blue-600">{{ stats.albums_count || 0 }}</div>
|
||||
<div class="text-sm text-gray-600">Albums</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-purple-600">{{ stats.events_created || 0 }}</div>
|
||||
<div class="text-sm text-gray-600">Événements créés</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import axios from '@/utils/axios'
|
||||
import { getMediaUrl } from '@/utils/axios'
|
||||
import { format } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import { User, Camera } from 'lucide-vue-next'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const user = computed(() => authStore.user)
|
||||
const updating = ref(false)
|
||||
const stats = ref({})
|
||||
|
||||
const form = ref({
|
||||
full_name: '',
|
||||
username: '',
|
||||
email: '',
|
||||
bio: ''
|
||||
})
|
||||
|
||||
function formatDate(date) {
|
||||
if (!date) return ''
|
||||
return format(new Date(date), 'MMMM yyyy', { locale: fr })
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
full_name: user.value?.full_name || '',
|
||||
username: user.value?.username || '',
|
||||
email: user.value?.email || '',
|
||||
bio: user.value?.bio || ''
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProfile() {
|
||||
updating.value = true
|
||||
try {
|
||||
const result = await authStore.updateProfile({
|
||||
full_name: form.value.full_name,
|
||||
bio: form.value.bio
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Profil mis à jour avec succès')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la mise à jour du profil')
|
||||
}
|
||||
updating.value = false
|
||||
}
|
||||
|
||||
async function handleAvatarChange(event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Veuillez sélectionner une image')
|
||||
return
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('L\'image est trop volumineuse (max 5MB)')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authStore.uploadAvatar(file)
|
||||
if (result.success) {
|
||||
toast.success('Avatar mis à jour avec succès')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de l\'upload de l\'avatar')
|
||||
}
|
||||
|
||||
// Reset input
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
async function fetchUserStats() {
|
||||
try {
|
||||
const response = await axios.get(`/api/stats/user/${user.value.id}`)
|
||||
stats.value = response.data.content_stats
|
||||
} catch (error) {
|
||||
console.error('Error fetching user stats:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
resetForm()
|
||||
fetchUserStats()
|
||||
})
|
||||
</script>
|
||||
190
frontend/src/views/Register.vue
Normal file
190
frontend/src/views/Register.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6">Inscription</h2>
|
||||
|
||||
<form @submit.prevent="handleRegister" class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="label">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
required
|
||||
:disabled="!registrationEnabled"
|
||||
class="input"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': !registrationEnabled }"
|
||||
placeholder="ton.email@example.com"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="username" class="label">Nom d'utilisateur</label>
|
||||
<input
|
||||
id="username"
|
||||
v-model="form.username"
|
||||
type="text"
|
||||
required
|
||||
minlength="3"
|
||||
maxlength="50"
|
||||
:disabled="!registrationEnabled"
|
||||
class="input"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': !registrationEnabled }"
|
||||
placeholder="tonpseudo"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="full_name" class="label">Nom complet</label>
|
||||
<input
|
||||
id="full_name"
|
||||
v-model="form.full_name"
|
||||
type="text"
|
||||
required
|
||||
:disabled="!registrationEnabled"
|
||||
class="input"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': !registrationEnabled }"
|
||||
placeholder="Prénom Nom"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="label">Mot de passe</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
required
|
||||
minlength="6"
|
||||
:disabled="!registrationEnabled"
|
||||
class="input"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': !registrationEnabled }"
|
||||
placeholder="••••••••"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password_confirm" class="label">Confirmer le mot de passe</label>
|
||||
<input
|
||||
id="password_confirm"
|
||||
v-model="form.password_confirm"
|
||||
type="password"
|
||||
required
|
||||
minlength="6"
|
||||
:disabled="!registrationEnabled"
|
||||
class="input"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': !registrationEnabled }"
|
||||
placeholder="••••••••"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="text-red-600 text-sm">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading || !registrationEnabled"
|
||||
class="w-full btn-primary"
|
||||
>
|
||||
{{ loading ? 'Inscription...' : !registrationEnabled ? 'Inscriptions désactivées' : 'S\'inscrire' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-sm text-gray-600">
|
||||
Déjà un compte ?
|
||||
<router-link to="/login" class="font-medium text-primary-600 hover:text-primary-500">
|
||||
Se connecter
|
||||
</router-link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Vérification du statut d'inscription -->
|
||||
<div v-if="!registrationEnabled" class="mt-6 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">Inscriptions désactivées</h3>
|
||||
<p class="text-sm text-yellow-700 mt-1">
|
||||
Les nouvelles inscriptions sont temporairement désactivées. Veuillez contacter l'administrateur.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import axios from '@/utils/axios'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const form = ref({
|
||||
email: '',
|
||||
username: '',
|
||||
full_name: '',
|
||||
password: '',
|
||||
password_confirm: ''
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const registrationEnabled = ref(true)
|
||||
|
||||
async function handleRegister() {
|
||||
error.value = ''
|
||||
|
||||
if (!registrationEnabled.value) {
|
||||
error.value = 'Les inscriptions sont actuellement désactivées'
|
||||
return
|
||||
}
|
||||
|
||||
if (form.value.password !== form.value.password_confirm) {
|
||||
error.value = 'Les mots de passe ne correspondent pas'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
const result = await authStore.register({
|
||||
email: form.value.email,
|
||||
username: form.value.username,
|
||||
full_name: form.value.full_name,
|
||||
password: form.value.password
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
error.value = result.error
|
||||
}
|
||||
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function checkRegistrationStatus() {
|
||||
try {
|
||||
const response = await axios.get('/api/settings/public/registration-status')
|
||||
const status = response.data
|
||||
registrationEnabled.value = status.can_register
|
||||
|
||||
// Afficher des informations supplémentaires si l'inscription est désactivée
|
||||
if (!status.registration_enabled) {
|
||||
console.log('Registration disabled by admin')
|
||||
} else if (status.current_users_count >= status.max_users) {
|
||||
console.log(`Maximum users reached: ${status.current_users_count}/${status.max_users}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking registration status:', error)
|
||||
// En cas d'erreur, on désactive l'inscription pour éviter les problèmes de sécurité
|
||||
registrationEnabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkRegistrationStatus()
|
||||
})
|
||||
</script>
|
||||
307
frontend/src/views/Stats.vue
Normal file
307
frontend/src/views/Stats.vue
Normal file
@@ -0,0 +1,307 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-8">Statistiques du groupe</h1>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
<p class="mt-4 text-gray-600">Chargement des statistiques...</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats content -->
|
||||
<div v-else>
|
||||
<!-- Overview Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div class="card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Membres actifs</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ overview.total_users }}</p>
|
||||
</div>
|
||||
<Users class="w-8 h-8 text-primary-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Événements</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ overview.total_events }}</p>
|
||||
</div>
|
||||
<Calendar class="w-8 h-8 text-success-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Albums</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ overview.total_albums }}</p>
|
||||
</div>
|
||||
<Image class="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Vlogs</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ overview.total_vlogs }}</p>
|
||||
</div>
|
||||
<Film class="w-8 h-8 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- Attendance Stats -->
|
||||
<div class="card p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-6">Taux de présence</h2>
|
||||
|
||||
<div v-if="attendanceStats.attendance_stats.length === 0" class="text-center py-8 text-gray-500">
|
||||
Aucune donnée de présence disponible
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="(user, index) in attendanceStats.attendance_stats.slice(0, 5)"
|
||||
:key="user.user_id"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center text-sm font-medium text-primary-600">
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<UserAvatar :user="user" size="md" :show-user-info="true" />
|
||||
</div>
|
||||
|
||||
<div class="text-right">
|
||||
<div class="text-lg font-bold text-primary-600">{{ Math.round(user.attendance_rate) }}%</div>
|
||||
<div class="text-xs text-gray-500">{{ user.present_count }}/{{ user.total_past_events }} événements</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="attendanceStats.best_attendee" class="mt-4 p-3 bg-success-50 rounded-lg border border-success-200">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Trophy class="w-5 h-5 text-success-600" />
|
||||
<span class="text-sm font-medium text-success-800">
|
||||
🏆 {{ attendanceStats.best_attendee.full_name }} est le plus assidu avec {{ Math.round(attendanceStats.best_attendee.attendance_rate) }}% de présence !
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fun Stats -->
|
||||
<div class="card p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-6">Statistiques fun</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Most Active Poster -->
|
||||
<div v-if="funStats.most_active_poster" class="p-3 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<div class="flex items-center space-x-2">
|
||||
<MessageSquare class="w-5 h-5 text-blue-600" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-800">Posteur le plus actif</p>
|
||||
<p class="text-xs text-blue-600">{{ funStats.most_active_poster.full_name }} ({{ funStats.most_active_poster.post_count }} posts)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Most Mentioned -->
|
||||
<div v-if="funStats.most_mentioned" class="p-3 bg-green-50 rounded-lg border border-green-200">
|
||||
<div class="flex items-center space-x-2">
|
||||
<AtSign class="w-5 h-5 text-green-600" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-green-800">Le plus mentionné</p>
|
||||
<p class="text-xs text-green-600">{{ funStats.most_mentioned.full_name }} ({{ funStats.most_mentioned.mention_count }} mentions)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Biggest Vlogger -->
|
||||
<div v-if="funStats.biggest_vlogger" class="p-3 bg-purple-50 rounded-lg border border-purple-200">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Film class="w-5 h-5 text-purple-600" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-purple-800">Vlogger le plus prolifique</p>
|
||||
<p class="text-xs text-purple-600">{{ funStats.biggest_vlogger.full_name }} ({{ funStats.biggest_vlogger.vlog_count }} vlogs)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo Addict -->
|
||||
<div v-if="funStats.photo_addict" class="p-3 bg-yellow-50 rounded-lg border border-yellow-200">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Image class="w-5 h-5 text-yellow-600" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-yellow-800">Accro aux photos</p>
|
||||
<p class="text-xs text-yellow-600">{{ funStats.photo_addict.full_name }} ({{ funStats.photo_addict.album_count }} albums)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Organizer -->
|
||||
<div v-if="funStats.event_organizer" class="p-3 bg-red-50 rounded-lg border border-red-200">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Calendar class="w-5 h-5 text-red-600" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-red-800">Organisateur d'événements</p>
|
||||
<p class="text-xs text-red-600">{{ funStats.event_organizer.full_name }} ({{ funStats.event_organizer.event_count }} événements)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Most Viewed Vlog -->
|
||||
<div v-if="funStats.most_viewed_vlog" class="p-3 bg-indigo-50 rounded-lg border border-indigo-200">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Eye class="w-5 h-5 text-indigo-600" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-indigo-800">Vlog le plus vu</p>
|
||||
<p class="text-xs text-indigo-600">{{ funStats.most_viewed_vlog.title }} ({{ funStats.most_viewed_vlog.views_count }} vues)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="card p-6 mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-6">Activité récente (30 derniers jours)</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-primary-600">{{ overview.recent_events || 0 }}</div>
|
||||
<div class="text-sm text-gray-600">Nouveaux événements</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-success-600">{{ overview.recent_posts || 0 }}</div>
|
||||
<div class="text-sm text-gray-600">Nouvelles publications</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-purple-600">{{ overview.recent_vlogs || 0 }}</div>
|
||||
<div class="text-sm text-gray-600">Nouveaux vlogs</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Stats -->
|
||||
<div class="card p-6 mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-6">Statistiques par utilisateur</h2>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Utilisateur</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Taux de présence</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Publications</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Vlogs</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Albums</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Événements créés</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr
|
||||
v-for="user in userStats"
|
||||
:key="user.user.id"
|
||||
class="hover:bg-gray-50"
|
||||
>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<UserAvatar :user="user.user" size="md" :show-user-info="true" />
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="text-sm font-medium text-gray-900">{{ Math.round(user.user.attendance_rate) }}%</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ user.content_stats.posts_count }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ user.content_stats.vlogs_count }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ user.content_stats.albums_count }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ user.content_stats.events_created }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import axios from '@/utils/axios'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import {
|
||||
Users,
|
||||
Calendar,
|
||||
Image,
|
||||
Film,
|
||||
Trophy,
|
||||
MessageSquare,
|
||||
AtSign,
|
||||
Eye
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const loading = ref(true)
|
||||
const overview = ref({})
|
||||
const attendanceStats = ref({})
|
||||
const funStats = ref({})
|
||||
const userStats = ref([])
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
// Fetch overview stats
|
||||
const overviewResponse = await axios.get('/api/stats/overview')
|
||||
overview.value = overviewResponse.data
|
||||
|
||||
// Fetch attendance stats
|
||||
const attendanceResponse = await axios.get('/api/stats/attendance')
|
||||
attendanceStats.value = attendanceResponse.data
|
||||
|
||||
// Fetch fun stats
|
||||
const funResponse = await axios.get('/api/stats/fun')
|
||||
funStats.value = funResponse.data
|
||||
|
||||
// Fetch user stats for all users
|
||||
const usersResponse = await axios.get('/api/users')
|
||||
const userStatsPromises = usersResponse.data.map(async (user) => {
|
||||
try {
|
||||
const userStatsResponse = await axios.get(`/api/stats/user/${user.id}`)
|
||||
return userStatsResponse.data
|
||||
} catch (error) {
|
||||
console.error(`Error fetching stats for user ${user.id}:`, error)
|
||||
return {
|
||||
user: user,
|
||||
content_stats: { posts_count: 0, vlogs_count: 0, albums_count: 0, events_created: 0 }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const userStatsResults = await Promise.all(userStatsPromises)
|
||||
userStats.value = userStatsResults.sort((a, b) => b.user.attendance_rate - a.user.attendance_rate)
|
||||
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors du chargement des statistiques')
|
||||
console.error('Error fetching stats:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchStats()
|
||||
})
|
||||
</script>
|
||||
223
frontend/src/views/UserProfile.vue
Normal file
223
frontend/src/views/UserProfile.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
<p class="mt-4 text-gray-600">Chargement du profil...</p>
|
||||
</div>
|
||||
|
||||
<!-- Profile not found -->
|
||||
<div v-else-if="!profileUser" class="text-center py-12">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-4">Profil non trouvé</h1>
|
||||
<p class="text-gray-600 mb-6">L'utilisateur que vous recherchez n'existe pas ou a été supprimé.</p>
|
||||
<router-link to="/" class="btn-primary">
|
||||
Retour à l'accueil
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Profile content -->
|
||||
<div v-else>
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<router-link to="/" class="text-primary-600 hover:text-primary-700 flex items-center">
|
||||
<ArrowLeft class="w-4 h-4 mr-2" />
|
||||
Retour à l'accueil
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Profile Info -->
|
||||
<div class="card p-8 text-center">
|
||||
<!-- Avatar -->
|
||||
<div class="mb-6">
|
||||
<div class="relative inline-block">
|
||||
<img
|
||||
v-if="profileUser.avatar_url"
|
||||
:src="getMediaUrl(profileUser.avatar_url)"
|
||||
:alt="profileUser.full_name"
|
||||
class="w-32 h-32 rounded-full object-cover mx-auto border-4 border-white shadow-lg"
|
||||
>
|
||||
<div v-else class="w-32 h-32 rounded-full bg-primary-100 flex items-center justify-center mx-auto border-4 border-white shadow-lg">
|
||||
<User class="w-16 h-16 text-primary-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Info -->
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">{{ profileUser.full_name }}</h1>
|
||||
<p class="text-xl text-gray-600 mb-4">@{{ profileUser.username }}</p>
|
||||
|
||||
<div v-if="profileUser.bio" class="text-gray-700 mb-6 max-w-2xl mx-auto">
|
||||
{{ profileUser.bio }}
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-6 mt-8">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-primary-600">{{ userStats.posts_count || 0 }}</div>
|
||||
<div class="text-gray-600">Publications</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-success-600">{{ userStats.vlogs_count || 0 }}</div>
|
||||
<div class="text-gray-600">Vlogs</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-blue-600">{{ userStats.albums_count || 0 }}</div>
|
||||
<div class="text-gray-600">Albums</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-purple-600">{{ userStats.events_created || 0 }}</div>
|
||||
<div class="text-gray-600">Événements</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attendance Rate -->
|
||||
<div v-if="profileUser.attendance_rate !== undefined" class="mt-6">
|
||||
<div class="text-center">
|
||||
<div class="text-lg font-semibold text-gray-900 mb-2">Taux de présence</div>
|
||||
<div class="text-3xl font-bold text-success-600">{{ profileUser.attendance_rate.toFixed(1) }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="card p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Activité récente</h2>
|
||||
|
||||
<div v-if="recentActivity.length === 0" class="text-center py-8 text-gray-500">
|
||||
Aucune activité récente
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="activity in recentActivity"
|
||||
:key="activity.id"
|
||||
class="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
|
||||
@click="navigateToActivity(activity)"
|
||||
>
|
||||
<div class="w-10 h-10 bg-primary-100 rounded-full flex items-center justify-center">
|
||||
<component :is="getActivityIcon(activity.type)" class="w-5 h-5 text-primary-600" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-gray-900">{{ activity.description }}</p>
|
||||
<p class="text-xs text-gray-500">{{ formatRelativeDate(activity.created_at) }}</p>
|
||||
</div>
|
||||
<ArrowRight class="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Member Since -->
|
||||
<div class="card p-6 text-center">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Membre depuis</h3>
|
||||
<p class="text-2xl text-primary-600">{{ formatDate(profileUser.created_at) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import axios from '@/utils/axios'
|
||||
import { getMediaUrl } from '@/utils/axios'
|
||||
import { format, formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import { User, ArrowLeft, ArrowRight, MessageSquare, Video, Image, Calendar, Activity } from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const loading = ref(true)
|
||||
const profileUser = ref(null)
|
||||
const userStats = ref({})
|
||||
const recentActivity = ref([])
|
||||
|
||||
function formatDate(date) {
|
||||
if (!date) return ''
|
||||
return format(new Date(date), 'MMMM yyyy', { locale: fr })
|
||||
}
|
||||
|
||||
function formatRelativeDate(date) {
|
||||
if (!date) return ''
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||
}
|
||||
|
||||
function getActivityIcon(type) {
|
||||
switch (type) {
|
||||
case 'post':
|
||||
return MessageSquare
|
||||
case 'vlog':
|
||||
return Video
|
||||
case 'album':
|
||||
return Image
|
||||
case 'event':
|
||||
return Calendar
|
||||
default:
|
||||
return Activity
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToActivity(activity) {
|
||||
if (activity.link) {
|
||||
router.push(activity.link)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProfile(userId) {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await axios.get(`/api/users/${userId}`)
|
||||
profileUser.value = response.data
|
||||
await fetchUserStats(userId)
|
||||
await fetchRecentActivity(userId)
|
||||
} catch (error) {
|
||||
console.error('Error fetching profile:', error)
|
||||
profileUser.value = null
|
||||
toast.error('Erreur lors du chargement du profil')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUserStats(userId) {
|
||||
try {
|
||||
const response = await axios.get(`/api/stats/user/${userId}`)
|
||||
userStats.value = response.data.content_stats
|
||||
} catch (error) {
|
||||
console.error('Error fetching user stats:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRecentActivity(userId) {
|
||||
try {
|
||||
const response = await axios.get(`/api/stats/activity/user/${userId}`)
|
||||
recentActivity.value = response.data.activity
|
||||
} catch (error) {
|
||||
console.error('Error fetching recent activity:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const userId = route.params.id
|
||||
|
||||
if (!userId) {
|
||||
toast.error('ID utilisateur manquant')
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
|
||||
// Empêcher de voir son propre profil ici
|
||||
if (parseInt(userId) === authStore.user?.id) {
|
||||
router.push('/profile')
|
||||
return
|
||||
}
|
||||
|
||||
await fetchProfile(userId)
|
||||
})
|
||||
</script>
|
||||
370
frontend/src/views/VlogDetail.vue
Normal file
370
frontend/src/views/VlogDetail.vue
Normal file
@@ -0,0 +1,370 @@
|
||||
<template>
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
<p class="mt-4 text-gray-600">Chargement du vlog...</p>
|
||||
</div>
|
||||
|
||||
<!-- Vlog not found -->
|
||||
<div v-else-if="!vlog" class="text-center py-12">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-4">Vlog non trouvé</h1>
|
||||
<p class="text-gray-600 mb-6">Le vlog que vous recherchez n'existe pas ou a été supprimé.</p>
|
||||
<router-link to="/vlogs" class="btn-primary">
|
||||
Retour aux vlogs
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Vlog details -->
|
||||
<div v-else>
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<router-link to="/vlogs" class="text-primary-600 hover:text-primary-700 flex items-center">
|
||||
<ArrowLeft class="w-4 h-4 mr-2" />
|
||||
Retour aux vlogs
|
||||
</router-link>
|
||||
|
||||
<div v-if="canEdit" class="flex space-x-2">
|
||||
<button
|
||||
@click="showEditModal = true"
|
||||
class="btn-secondary"
|
||||
>
|
||||
<Edit class="w-4 h-4 mr-2" />
|
||||
Modifier
|
||||
</button>
|
||||
<button
|
||||
@click="deleteVlog"
|
||||
class="btn-secondary text-accent-600 hover:text-accent-700 hover:border-accent-300"
|
||||
>
|
||||
<Trash2 class="w-4 h-4 mr-2" />
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">{{ vlog.title }}</h1>
|
||||
|
||||
<div class="flex items-center space-x-6 text-gray-600 mb-6">
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
v-if="vlog.author_avatar"
|
||||
:src="vlog.author_avatar"
|
||||
:alt="vlog.author_name"
|
||||
class="w-8 h-8 rounded-full object-cover mr-3"
|
||||
>
|
||||
<div v-else class="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center mr-3">
|
||||
<User class="w-4 h-4 text-primary-600" />
|
||||
</div>
|
||||
<span>Par {{ vlog.author_name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<Calendar class="w-4 h-4 mr-2" />
|
||||
<span>{{ formatDate(vlog.created_at) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<Eye class="w-4 h-4 mr-2" />
|
||||
<span>{{ vlog.views_count }} vue{{ vlog.views_count > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="vlog.duration" class="flex items-center">
|
||||
<Clock class="w-4 h-4 mr-2" />
|
||||
<span>{{ formatDuration(vlog.duration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video Player -->
|
||||
<div class="card p-6 mb-8">
|
||||
<VideoPlayer
|
||||
:src="vlog.video_url"
|
||||
:poster="vlog.thumbnail_url"
|
||||
:title="vlog.title"
|
||||
:description="vlog.description"
|
||||
:duration="vlog.duration"
|
||||
:views-count="vlog.views_count"
|
||||
:likes-count="vlog.likes_count"
|
||||
:comments-count="vlog.comments?.length || 0"
|
||||
:is-liked="vlog.is_liked"
|
||||
@like="toggleLike"
|
||||
@toggle-comments="showComments = !showComments"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div v-if="vlog.description" class="card p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Description</h2>
|
||||
<p class="text-gray-700 whitespace-pre-wrap">{{ vlog.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Comments Section -->
|
||||
<div class="card p-6 mb-8">
|
||||
<VlogComments
|
||||
:vlog-id="vlog.id"
|
||||
:comments="vlog.comments || []"
|
||||
:comment-users="users"
|
||||
@comment-added="onCommentAdded"
|
||||
@comment-deleted="onCommentDeleted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Related Vlogs -->
|
||||
<div class="card p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Autres vlogs de {{ vlog.author_name }}</h2>
|
||||
|
||||
<div v-if="relatedVlogs.length === 0" class="text-center py-8 text-gray-500">
|
||||
Aucun autre vlog de cet auteur
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<router-link
|
||||
v-for="relatedVlog in relatedVlogs"
|
||||
:key="relatedVlog.id"
|
||||
:to="`/vlogs/${relatedVlog.id}`"
|
||||
class="block hover:shadow-md transition-shadow rounded-lg overflow-hidden"
|
||||
>
|
||||
<div class="aspect-video bg-gray-100 relative overflow-hidden">
|
||||
<img
|
||||
v-if="relatedVlog.thumbnail_url"
|
||||
:src="getMediaUrl(relatedVlog.thumbnail_url)"
|
||||
:alt="relatedVlog.title"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<Film class="w-12 h-12 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<!-- Play Button Overlay -->
|
||||
<div class="absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center">
|
||||
<Play class="w-8 h-8 text-white" />
|
||||
</div>
|
||||
|
||||
<!-- Duration Badge -->
|
||||
<div v-if="relatedVlog.duration" class="absolute bottom-2 right-2 bg-black bg-opacity-70 text-white text-xs px-2 py-1 rounded">
|
||||
{{ formatDuration(relatedVlog.duration) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-3">
|
||||
<h3 class="font-medium text-gray-900 line-clamp-2">{{ relatedVlog.title }}</h3>
|
||||
<p class="text-sm text-gray-600 mt-1">{{ formatRelativeDate(relatedVlog.created_at) }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">{{ relatedVlog.views_count }} vue{{ relatedVlog.views_count > 1 ? 's' : '' }}</p>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Vlog Modal -->
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition ease-in duration-150"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showEditModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="bg-white rounded-xl max-w-md w-full p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Modifier le vlog</h2>
|
||||
|
||||
<form @submit.prevent="updateVlog" class="space-y-4">
|
||||
<div>
|
||||
<label class="label">Titre</label>
|
||||
<input
|
||||
v-model="editForm.title"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Description</label>
|
||||
<textarea
|
||||
v-model="editForm.description"
|
||||
rows="3"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="showEditModal = false"
|
||||
class="flex-1 btn-secondary"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="updating"
|
||||
class="flex-1 btn-primary"
|
||||
>
|
||||
{{ updating ? 'Mise à jour...' : 'Mettre à jour' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import axios from '@/utils/axios'
|
||||
import { getMediaUrl } from '@/utils/axios'
|
||||
import { format, formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import {
|
||||
ArrowLeft,
|
||||
User,
|
||||
Calendar,
|
||||
Eye,
|
||||
Clock,
|
||||
Edit,
|
||||
Trash2,
|
||||
Film,
|
||||
Play
|
||||
} from 'lucide-vue-next'
|
||||
import VideoPlayer from '@/components/VideoPlayer.vue'
|
||||
import VlogComments from '@/components/VlogComments.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const vlog = ref(null)
|
||||
const relatedVlogs = ref([])
|
||||
const users = ref([])
|
||||
const loading = ref(true)
|
||||
const updating = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showComments = ref(true)
|
||||
|
||||
const editForm = ref({
|
||||
title: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const canEdit = computed(() =>
|
||||
vlog.value && (vlog.value.author_id === authStore.user?.id || authStore.user?.is_admin)
|
||||
)
|
||||
|
||||
function formatDate(date) {
|
||||
return format(new Date(date), 'dd MMMM yyyy', { locale: fr })
|
||||
}
|
||||
|
||||
function formatRelativeDate(date) {
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (!seconds) return '--:--'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = seconds % 60
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
async function toggleLike() {
|
||||
try {
|
||||
const response = await axios.post(`/api/vlogs/${vlog.value.id}/like`)
|
||||
// Refresh vlog data to get updated like count
|
||||
await fetchVlog()
|
||||
toast.success(response.data.message)
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la mise à jour du like')
|
||||
}
|
||||
}
|
||||
|
||||
function onCommentAdded(comment) {
|
||||
// Add the new comment to the vlog
|
||||
if (!vlog.value.comments) {
|
||||
vlog.value.comments = []
|
||||
}
|
||||
vlog.value.comments.unshift(comment)
|
||||
}
|
||||
|
||||
function onCommentDeleted(commentId) {
|
||||
// Remove the deleted comment from the vlog
|
||||
if (vlog.value.comments) {
|
||||
const index = vlog.value.comments.findIndex(c => c.id === commentId)
|
||||
if (index > -1) {
|
||||
vlog.value.comments.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUsers() {
|
||||
try {
|
||||
const response = await axios.get('/api/users')
|
||||
users.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchVlog() {
|
||||
try {
|
||||
const response = await axios.get(`/api/vlogs/${route.params.id}`)
|
||||
vlog.value = response.data
|
||||
|
||||
// Initialize edit form
|
||||
editForm.value = {
|
||||
title: vlog.value.title,
|
||||
description: vlog.value.description || ''
|
||||
}
|
||||
|
||||
// Fetch related vlogs from same author
|
||||
const relatedResponse = await axios.get(`/api/vlogs?limit=6&offset=0`)
|
||||
relatedVlogs.value = relatedResponse.data
|
||||
.filter(v => v.id !== vlog.value.id && v.author_id === vlog.value.author_id)
|
||||
.slice(0, 3)
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors du chargement du vlog')
|
||||
console.error('Error fetching vlog:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateVlog() {
|
||||
updating.value = true
|
||||
try {
|
||||
const response = await axios.put(`/api/vlogs/${vlog.value.id}`, editForm.value)
|
||||
vlog.value = response.data
|
||||
showEditModal.value = false
|
||||
toast.success('Vlog mis à jour')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la mise à jour')
|
||||
}
|
||||
updating.value = false
|
||||
}
|
||||
|
||||
async function deleteVlog() {
|
||||
if (!confirm('Êtes-vous sûr de vouloir supprimer ce vlog ?')) return
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/vlogs/${vlog.value.id}`)
|
||||
toast.success('Vlog supprimé')
|
||||
router.push('/vlogs')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la suppression')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchVlog()
|
||||
fetchUsers()
|
||||
})
|
||||
</script>
|
||||
473
frontend/src/views/Vlogs.vue
Normal file
473
frontend/src/views/Vlogs.vue
Normal file
@@ -0,0 +1,473 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Vlogs</h1>
|
||||
<p class="text-gray-600 mt-1">Partagez vos moments en vidéo avec le groupe</p>
|
||||
</div>
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="btn-primary"
|
||||
>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Nouveau vlog
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Vlogs Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="vlog in vlogs"
|
||||
:key="vlog.id"
|
||||
class="card hover:shadow-lg transition-shadow cursor-pointer"
|
||||
@click="openVlog(vlog)"
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<div class="aspect-video bg-gray-100 relative overflow-hidden">
|
||||
<img
|
||||
v-if="vlog.thumbnail_url"
|
||||
:src="getMediaUrl(vlog.thumbnail_url)"
|
||||
:alt="vlog.title"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<Film class="w-16 h-16 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<!-- Play Button Overlay -->
|
||||
<div class="absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center">
|
||||
<Play class="w-12 h-12 text-white" />
|
||||
</div>
|
||||
|
||||
<!-- Duration Badge -->
|
||||
<div v-if="vlog.duration" class="absolute bottom-2 right-2 bg-black bg-opacity-70 text-white text-xs px-2 py-1 rounded">
|
||||
{{ formatDuration(vlog.duration) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold text-gray-900 mb-2 line-clamp-2">{{ vlog.title }}</h3>
|
||||
|
||||
<!-- Description with mentions -->
|
||||
<div v-if="vlog.description" class="mb-3">
|
||||
<Mentions :content="vlog.description" :mentions="getMentionsFromContent(vlog.description)" class="text-gray-600 text-sm line-clamp-2" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 text-sm text-gray-600 mb-3">
|
||||
<img
|
||||
v-if="vlog.author_avatar"
|
||||
:src="vlog.author_avatar"
|
||||
:alt="vlog.author_name"
|
||||
class="w-5 h-5 rounded-full object-cover"
|
||||
>
|
||||
<div v-else class="w-5 h-5 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<User class="w-3 h-3 text-gray-500" />
|
||||
</div>
|
||||
<router-link
|
||||
:to="`/profile/${vlog.author_id}`"
|
||||
class="text-gray-900 hover:text-primary-600 transition-colors cursor-pointer"
|
||||
>
|
||||
{{ vlog.author_name }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm text-gray-500">
|
||||
<span>{{ formatRelativeDate(vlog.created_at) }}</span>
|
||||
<div class="flex items-center space-x-1">
|
||||
<Eye class="w-4 h-4" />
|
||||
<span>{{ vlog.views_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load more -->
|
||||
<div v-if="hasMoreVlogs" class="text-center mt-8">
|
||||
<button
|
||||
@click="loadMoreVlogs"
|
||||
:disabled="loading"
|
||||
class="btn-secondary"
|
||||
>
|
||||
{{ loading ? 'Chargement...' : 'Charger plus' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="vlogs.length === 0 && !loading" class="text-center py-12">
|
||||
<Film class="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Aucun vlog</h3>
|
||||
<p class="text-gray-600">Soyez le premier à partager un vlog !</p>
|
||||
</div>
|
||||
|
||||
<!-- Create Vlog Modal -->
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition ease-in duration-150"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showCreateModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="bg-white rounded-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
||||
<h2 class="text-xl font-semibold mb-4">Créer un nouveau vlog</h2>
|
||||
|
||||
<form @submit.prevent="createVlog" class="space-y-4">
|
||||
<div>
|
||||
<label class="label">Titre</label>
|
||||
<input
|
||||
v-model="newVlog.title"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
placeholder="Titre de votre vlog..."
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Description</label>
|
||||
<MentionInput
|
||||
v-model="newVlog.description"
|
||||
:users="users"
|
||||
:rows="3"
|
||||
placeholder="Décrivez votre vlog... (utilisez @username pour mentionner)"
|
||||
@mentions-changed="handleVlogMentionsChanged"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Vidéo</label>
|
||||
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
|
||||
<input
|
||||
ref="videoInput"
|
||||
type="file"
|
||||
accept="video/*"
|
||||
class="hidden"
|
||||
@change="handleVideoChange"
|
||||
>
|
||||
|
||||
<div v-if="!newVlog.video" class="space-y-2">
|
||||
<Upload class="w-12 h-12 text-gray-400 mx-auto" />
|
||||
<p class="text-gray-600">Cliquez pour sélectionner une vidéo</p>
|
||||
<p class="text-sm text-gray-500">MP4, WebM, MOV (max {{ uploadLimits.max_video_size_mb }}MB)</p>
|
||||
<button
|
||||
type="button"
|
||||
@click="$refs.videoInput.click()"
|
||||
class="btn-secondary"
|
||||
>
|
||||
Sélectionner une vidéo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<video
|
||||
:src="newVlog.video"
|
||||
class="w-full max-h-48 object-cover rounded"
|
||||
controls
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeVideo"
|
||||
class="btn-secondary text-sm"
|
||||
>
|
||||
Changer de vidéo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Miniature (optionnel)</label>
|
||||
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
|
||||
<input
|
||||
ref="thumbnailInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="handleThumbnailChange"
|
||||
>
|
||||
|
||||
<div v-if="!newVlog.thumbnail" class="space-y-2">
|
||||
<Image class="w-12 h-12 text-gray-400 mx-auto" />
|
||||
<p class="text-gray-600">Ajoutez une miniature personnalisée</p>
|
||||
<button
|
||||
type="button"
|
||||
@click="$refs.thumbnailInput.click()"
|
||||
class="btn-secondary"
|
||||
>
|
||||
Sélectionner une image
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<img
|
||||
:src="newVlog.thumbnail"
|
||||
class="w-full max-h-48 object-cover rounded mx-auto"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeThumbnail"
|
||||
class="btn-secondary text-sm"
|
||||
>
|
||||
Changer la miniature
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="showCreateModal = false"
|
||||
class="flex-1 btn-secondary"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="creating || !newVlog.video"
|
||||
class="flex-1 btn-primary"
|
||||
>
|
||||
{{ creating ? 'Création...' : 'Créer le vlog' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useRouter } from 'vue-router'
|
||||
import axios from '@/utils/axios'
|
||||
import { getMediaUrl } from '@/utils/axios'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import {
|
||||
Plus,
|
||||
Film,
|
||||
Play,
|
||||
User,
|
||||
Eye,
|
||||
Upload,
|
||||
Image
|
||||
} from 'lucide-vue-next'
|
||||
import Mentions from '@/components/Mentions.vue'
|
||||
import MentionInput from '@/components/MentionInput.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const vlogs = ref([])
|
||||
const users = ref([])
|
||||
const loading = ref(false)
|
||||
const creating = ref(false)
|
||||
const showCreateModal = ref(false)
|
||||
const hasMoreVlogs = ref(true)
|
||||
const offset = ref(0)
|
||||
const uploadLimits = ref({
|
||||
max_video_size_mb: 100
|
||||
})
|
||||
|
||||
const newVlog = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
video: null,
|
||||
thumbnail: null
|
||||
})
|
||||
|
||||
const vlogMentions = ref([])
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = seconds % 60
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatRelativeDate(date) {
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: fr })
|
||||
}
|
||||
|
||||
function openVlog(vlog) {
|
||||
router.push(`/vlogs/${vlog.id}`)
|
||||
}
|
||||
|
||||
async function handleVideoChange(event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
if (!file.type.startsWith('video/')) {
|
||||
toast.error('Veuillez sélectionner une vidéo')
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > uploadLimits.max_video_size_mb * 1024 * 1024) {
|
||||
toast.error(`La vidéo est trop volumineuse (max ${uploadLimits.max_video_size_mb}MB)`)
|
||||
return
|
||||
}
|
||||
|
||||
// Create preview URL
|
||||
newVlog.value.video = URL.createObjectURL(file)
|
||||
newVlog.value.videoFile = file
|
||||
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
function removeVideo() {
|
||||
if (newVlog.value.video && newVlog.value.video.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(newVlog.value.video)
|
||||
}
|
||||
newVlog.value.video = null
|
||||
newVlog.value.videoFile = null
|
||||
}
|
||||
|
||||
async function handleThumbnailChange(event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Veuillez sélectionner une image')
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > uploadLimits.max_image_size_mb * 1024 * 1024) {
|
||||
toast.error(`L'image est trop volumineuse (max ${uploadLimits.max_image_size_mb}MB)`)
|
||||
return
|
||||
}
|
||||
|
||||
// Create preview URL
|
||||
newVlog.value.thumbnail = URL.createObjectURL(file)
|
||||
newVlog.value.thumbnailFile = file
|
||||
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
function removeThumbnail() {
|
||||
if (newVlog.value.thumbnail && newVlog.value.thumbnail.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(newVlog.value.thumbnail)
|
||||
}
|
||||
newVlog.value.thumbnail = null
|
||||
newVlog.value.thumbnailFile = null
|
||||
}
|
||||
|
||||
function handleVlogMentionsChanged(mentions) {
|
||||
vlogMentions.value = mentions
|
||||
}
|
||||
|
||||
async function fetchUsers() {
|
||||
try {
|
||||
const response = await axios.get('/api/users')
|
||||
users.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUploadLimits() {
|
||||
try {
|
||||
const response = await axios.get('/api/settings/upload-limits')
|
||||
uploadLimits.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Error fetching upload limits:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function getMentionsFromContent(content) {
|
||||
if (!content) return []
|
||||
|
||||
const mentions = []
|
||||
const mentionRegex = /@(\w+)/g
|
||||
let match
|
||||
|
||||
while ((match = mentionRegex.exec(content)) !== null) {
|
||||
const username = match[1]
|
||||
const user = users.value.find(u => u.username === username)
|
||||
if (user) {
|
||||
mentions.push({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
full_name: user.full_name
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return mentions
|
||||
}
|
||||
|
||||
async function createVlog() {
|
||||
if (!newVlog.value.title || !newVlog.value.videoFile) return
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('title', newVlog.value.title)
|
||||
if (newVlog.value.description) {
|
||||
formData.append('description', newVlog.value.description)
|
||||
}
|
||||
formData.append('video', newVlog.value.videoFile)
|
||||
if (newVlog.value.thumbnailFile) {
|
||||
formData.append('thumbnail', newVlog.value.thumbnailFile)
|
||||
}
|
||||
|
||||
const response = await axios.post('/api/vlogs/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
|
||||
vlogs.value.unshift(response.data)
|
||||
showCreateModal.value = false
|
||||
resetForm()
|
||||
toast.success('Vlog créé avec succès')
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la création du vlog')
|
||||
}
|
||||
creating.value = false
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
removeVideo()
|
||||
removeThumbnail()
|
||||
newVlog.value = {
|
||||
title: '',
|
||||
description: '',
|
||||
video: null,
|
||||
thumbnail: null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchVlogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await axios.get(`/api/vlogs?limit=12&offset=${offset.value}`)
|
||||
if (offset.value === 0) {
|
||||
vlogs.value = response.data
|
||||
} else {
|
||||
vlogs.value.push(...response.data)
|
||||
}
|
||||
|
||||
hasMoreVlogs.value = response.data.length === 12
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors du chargement des vlogs')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function loadMoreVlogs() {
|
||||
offset.value += 12
|
||||
await fetchVlogs()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchVlogs()
|
||||
fetchUsers()
|
||||
fetchUploadLimits()
|
||||
})
|
||||
</script>
|
||||
78
frontend/tailwind.config.js
Normal file
78
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,78 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Discord-style color palette
|
||||
primary: {
|
||||
50: '#f5f3ff', // Violet très pâle
|
||||
100: '#ede9fe', // Violet pâle
|
||||
200: '#ddd6fe', // Violet clair
|
||||
300: '#c4b5fd', // Violet moyen
|
||||
400: '#a78bfa', // Violet
|
||||
500: '#8b5cf6', // Violet principal
|
||||
600: '#7c3aed', // Violet foncé
|
||||
700: '#6d28d9', // Violet plus foncé
|
||||
800: '#5b21b6', // Violet très foncé
|
||||
900: '#4c1d95', // Violet le plus foncé
|
||||
},
|
||||
secondary: {
|
||||
50: '#f8fafc', // Gris très pâle
|
||||
100: '#f1f5f9', // Gris pâle
|
||||
200: '#e2e8f0', // Gris clair
|
||||
300: '#cbd5e1', // Gris moyen
|
||||
400: '#94a3b8', // Gris
|
||||
500: '#64748b', // Gris principal
|
||||
600: '#475569', // Gris foncé
|
||||
700: '#334155', // Gris plus foncé
|
||||
800: '#1e293b', // Gris très foncé
|
||||
900: '#0f172a', // Gris le plus foncé
|
||||
},
|
||||
accent: {
|
||||
50: '#fef2f2', // Rouge très pâle
|
||||
100: '#fee2e2', // Rouge pâle
|
||||
200: '#fecaca', // Rouge clair
|
||||
300: '#fca5a5', // Rouge moyen
|
||||
400: '#f87171', // Rouge
|
||||
500: '#ef4444', // Rouge principal
|
||||
600: '#dc2626', // Rouge foncé
|
||||
700: '#b91c1c', // Rouge plus foncé
|
||||
800: '#991b1b', // Rouge très foncé
|
||||
900: '#7f1d1d', // Rouge le plus foncé
|
||||
},
|
||||
success: {
|
||||
50: '#f0fdf4', // Vert très pâle
|
||||
100: '#dcfce7', // Vert pâle
|
||||
200: '#bbf7d0', // Vert clair
|
||||
300: '#86efac', // Vert moyen
|
||||
400: '#4ade80', // Vert
|
||||
500: '#22c55e', // Vert principal
|
||||
600: '#16a34a', // Vert foncé
|
||||
700: '#15803d', // Vert plus foncé
|
||||
800: '#166534', // Vert très foncé
|
||||
900: '#14532d', // Vert le plus foncé
|
||||
},
|
||||
warning: {
|
||||
50: '#fffbeb', // Jaune très pâle
|
||||
100: '#fef3c7', // Jaune pâle
|
||||
200: '#fde68a', // Jaune clair
|
||||
300: '#fcd34d', // Jaune moyen
|
||||
400: '#fbbf24', // Jaune
|
||||
500: '#f59e0b', // Jaune principal
|
||||
600: '#d97706', // Jaune foncé
|
||||
700: '#b45309', // Jaune plus foncé
|
||||
800: '#92400e', // Jaune très foncé
|
||||
900: '#78350f', // Jaune le plus foncé
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
26
frontend/vite.config.js
Normal file
26
frontend/vite.config.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const { defineConfig } = require('vite')
|
||||
const vue = require('@vitejs/plugin-vue')
|
||||
const path = require('path')
|
||||
|
||||
module.exports = defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://backend:8000',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/uploads': {
|
||||
target: 'http://backend:8000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user