diff --git a/api_v1_events.php b/api_v1_events.php new file mode 100644 index 0000000..7e02417 --- /dev/null +++ b/api_v1_events.php @@ -0,0 +1,224 @@ + false, 'error' => 'Champs obligatoires manquants']); + exit; + } + + // Check if channel exists and get server_id + $stmt = db()->prepare("SELECT server_id FROM channels WHERE id = ?"); + $stmt->execute([$channel_id]); + $channel = $stmt->fetch(); + + if (!$channel) { + echo json_encode(['success' => false, 'error' => 'Salon introuvable']); + exit; + } + + $server_id = $channel['server_id']; + + // Check permission + if (!Permissions::hasPermission($user_id, $server_id, Permissions::MANAGE_CHANNELS)) { + echo json_encode(['success' => false, 'error' => 'Permission refusée']); + exit; + } + + $banner_url = null; + // Handle banner upload + if (isset($_FILES['banner_image']) && $_FILES['banner_image']['error'] === UPLOAD_ERR_OK) { + $file = $_FILES['banner_image']; + $allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; + $maxSize = 5 * 1024 * 1024; // 5MB for banners + + if (!in_array($file['type'], $allowedTypes)) { + echo json_encode(['success' => false, 'error' => 'Format d\'image non supporté']); + exit; + } + + if ($file['size'] > $maxSize) { + echo json_encode(['success' => false, 'error' => 'Image trop volumineuse (max 5Mo)']); + exit; + } + + $extension = pathinfo($file['name'], PATHINFO_EXTENSION); + if (empty($extension)) { + $extensions = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif']; + $extension = $extensions[$file['type']] ?? 'png'; + } + + $filename = 'banner_' . $channel_id . '_' . time() . '_' . rand(1000, 9999) . '.' . $extension; + $dir = __DIR__ . '/assets/images/banners/'; + if (!is_dir($dir)) mkdir($dir, 0775, true); + + $targetPath = $dir . $filename; + if (move_uploaded_file($file['tmp_name'], $targetPath)) { + $banner_url = 'assets/images/banners/' . $filename; + } + } + + try { + $stmt = db()->prepare("INSERT INTO channel_events + (channel_id, user_id, title, description, banner_url, banner_color, start_date, start_time, end_date, end_time, frequency, is_permanent, enable_reactions) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); + $stmt->execute([ + $channel_id, $user_id, $title, $description, $banner_url, $banner_color, + $start_date, $start_time, $end_date, $end_time, $frequency, $is_permanent, $enable_reactions + ]); + + echo json_encode(['success' => true, 'event_id' => db()->lastInsertId()]); + } catch (Exception $e) { + echo json_encode(['success' => false, 'error' => 'Erreur lors de la création : ' . $e->getMessage()]); + } + exit; + } + + if ($action === 'update') { + $event_id = $_POST['event_id'] ?? 0; + $title = trim($_POST['title'] ?? ''); + $description = trim($_POST['description'] ?? ''); + $start_date = $_POST['start_date'] ?? ''; + $start_time = $_POST['start_time'] ?? ''; + $end_date = $_POST['end_date'] ?? ''; + $end_time = $_POST['end_time'] ?? ''; + $frequency = $_POST['frequency'] ?? ''; + $is_permanent = isset($_POST['is_permanent']) ? (int)$_POST['is_permanent'] : 0; + $enable_reactions = isset($_POST['enable_reactions']) ? (int)$_POST['enable_reactions'] : 0; + $banner_color = $_POST['banner_color'] ?? null; + + if (!$event_id || !$title || !$start_date || !$start_time || (!$is_permanent && (!$end_date || !$end_time))) { + echo json_encode(['success' => false, 'error' => 'Champs obligatoires manquants']); + exit; + } + + $stmt = db()->prepare("SELECT ce.*, c.server_id FROM channel_events ce JOIN channels c ON ce.channel_id = c.id WHERE ce.id = ?"); + $stmt->execute([$event_id]); + $event = $stmt->fetch(); + + if (!$event) { + echo json_encode(['success' => false, 'error' => 'Événement introuvable']); + exit; + } + + if ($event['user_id'] != $user_id && !Permissions::hasPermission($user_id, $event['server_id'], Permissions::MANAGE_CHANNELS)) { + echo json_encode(['success' => false, 'error' => 'Permission refusée']); + exit; + } + + $banner_url = $event['banner_url']; + if (isset($_FILES['banner_image']) && $_FILES['banner_image']['error'] === UPLOAD_ERR_OK) { + // ... (upload logic same as create) + $file = $_FILES['banner_image']; + $allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; + $maxSize = 5 * 1024 * 1024; + if (in_array($file['type'], $allowedTypes) && $file['size'] <= $maxSize) { + $extension = pathinfo($file['name'], PATHINFO_EXTENSION) ?: 'png'; + $filename = 'banner_' . $event['channel_id'] . '_' . time() . '_' . rand(1000, 9999) . '.' . $extension; + $dir = __DIR__ . '/assets/images/banners/'; + if (!is_dir($dir)) mkdir($dir, 0775, true); + if (move_uploaded_file($file['tmp_name'], $dir . $filename)) { + if ($banner_url && file_exists(__DIR__ . '/' . $banner_url)) @unlink(__DIR__ . '/' . $banner_url); + $banner_url = 'assets/images/banners/' . $filename; + } + } + } + + try { + $stmt = db()->prepare("UPDATE channel_events SET + title = ?, description = ?, banner_url = ?, banner_color = ?, + start_date = ?, start_time = ?, end_date = ?, end_time = ?, + frequency = ?, is_permanent = ?, enable_reactions = ? + WHERE id = ?"); + $stmt->execute([ + $title, $description, $banner_url, $banner_color, + $start_date, $start_time, $end_date, $end_time, + $frequency, $is_permanent, $enable_reactions, $event_id + ]); + echo json_encode(['success' => true]); + } catch (Exception $e) { + echo json_encode(['success' => false, 'error' => 'Erreur lors de la mise à jour']); + } + exit; + } + + if ($action === 'participate') { + $event_id = $_POST['event_id'] ?? 0; + if (!$event_id) exit; + + $stmt = db()->prepare("SELECT * FROM event_participations WHERE event_id = ? AND user_id = ?"); + $stmt->execute([$event_id, $user_id]); + if ($stmt->fetch()) { + $stmt = db()->prepare("DELETE FROM event_participations WHERE event_id = ? AND user_id = ?"); + $stmt->execute([$event_id, $user_id]); + echo json_encode(['success' => true, 'action' => 'removed']); + } else { + $stmt = db()->prepare("INSERT INTO event_participations (event_id, user_id) VALUES (?, ?)"); + $stmt->execute([$event_id, $user_id]); + echo json_encode(['success' => true, 'action' => 'added']); + } + exit; + } + + if ($action === 'delete') { + $event_id = $_POST['event_id'] ?? 0; + if (!$event_id) { + echo json_encode(['success' => false, 'error' => 'ID d\'événement manquant']); + exit; + } + + // Get event to find channel and server + $stmt = db()->prepare("SELECT ce.*, c.server_id FROM channel_events ce JOIN channels c ON ce.channel_id = c.id WHERE ce.id = ?"); + $stmt->execute([$event_id]); + $event = $stmt->fetch(); + + if (!$event) { + echo json_encode(['success' => false, 'error' => 'Événement introuvable']); + exit; + } + + // Check permission (creator or manage_channels) + if ($event['user_id'] != $user_id && !Permissions::hasPermission($user_id, $event['server_id'], Permissions::MANAGE_CHANNELS)) { + echo json_encode(['success' => false, 'error' => 'Permission refusée']); + exit; + } + + try { + // Delete banner file if it exists + if ($event['banner_url'] && file_exists(__DIR__ . '/' . $event['banner_url'])) { + @unlink(__DIR__ . '/' . $event['banner_url']); + } + + $stmt = db()->prepare("DELETE FROM channel_events WHERE id = ?"); + $stmt->execute([$event_id]); + + echo json_encode(['success' => true]); + } catch (Exception $e) { + echo json_encode(['success' => false, 'error' => 'Erreur lors de la suppression']); + } + exit; + } +} + +echo json_encode(['success' => false, 'error' => 'Requête invalide']); diff --git a/assets/images/banners/banner_24_1771614033_1623.jpg b/assets/images/banners/banner_24_1771614033_1623.jpg new file mode 100644 index 0000000..ab5fcf7 Binary files /dev/null and b/assets/images/banners/banner_24_1771614033_1623.jpg differ diff --git a/assets/js/main.js b/assets/js/main.js index e20f0f5..7641da0 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -3313,4 +3313,234 @@ document.addEventListener('DOMContentLoaded', () => { setInterval(updateInviteTimer, 1000); updateInviteTimer(); } + + // Event Channel Management + const addEventBtn = document.getElementById('addEventBtn'); + const addEventModal = document.getElementById('addEventModal'); + const eventForm = document.getElementById('event-form'); + const isPermanentCheckbox = document.getElementById('is_permanent'); + const enableReactionsCheckbox = document.getElementById('enable_reactions'); + const endDateContainer = document.getElementById('end-date-container'); + const eventBannerImage = document.getElementById('event-banner-image'); + const eventBannerColor = document.getElementById('event-banner-color'); + const eventBannerPreview = document.getElementById('event-banner-preview'); + const addEventModalEl = document.getElementById('addEventModal'); + const eventActionInput = document.getElementById('event-action'); + const eventIdInput = document.getElementById('event-id'); + const eventModalTitle = document.getElementById('event-modal-title'); + const eventSubmitBtn = document.getElementById('event-submit-btn'); + + // Reset modal for creation + document.querySelector('[data-bs-target="#addEventModal"]')?.addEventListener('click', () => { + if (eventForm) { + eventForm.reset(); + eventActionInput.value = 'create'; + eventIdInput.value = ''; + eventModalTitle.innerText = 'Ajouter un événement'; + eventSubmitBtn.innerText = 'Créer l\'événement'; + if (endDateContainer) endDateContainer.style.display = 'block'; + if (eventBannerPreview) { + eventBannerPreview.style.backgroundImage = 'none'; + eventBannerPreview.style.backgroundColor = '#5865f2'; + } + } + }); + + isPermanentCheckbox?.addEventListener('change', (e) => { + if (endDateContainer) { + endDateContainer.style.display = e.target.checked ? 'none' : 'block'; + const inputs = endDateContainer.querySelectorAll('input'); + inputs.forEach(i => i.required = !e.target.checked); + } + }); + + eventBannerImage?.addEventListener('change', (e) => { + const file = e.target.files[0]; + if (file && eventBannerPreview) { + const reader = new FileReader(); + reader.onload = (ex) => { + eventBannerPreview.style.backgroundImage = `url('${ex.target.result}')`; + eventBannerPreview.style.backgroundColor = 'transparent'; + }; + reader.readAsDataURL(file); + } + }); + + eventBannerColor?.addEventListener('input', (e) => { + if (eventBannerPreview && (!eventBannerImage.files || eventBannerImage.files.length === 0)) { + eventBannerPreview.style.backgroundImage = 'none'; + eventBannerPreview.style.backgroundColor = e.target.value; + } + }); + + eventForm?.addEventListener('submit', async (e) => { + e.preventDefault(); + const btn = eventSubmitBtn; + const originalText = btn.innerHTML; + const action = eventActionInput.value; + btn.disabled = true; + btn.innerHTML = ' Enregistrement...'; + + const formData = new FormData(eventForm); + formData.set('action', action); + formData.append('channel_id', window.activeChannelId); + + // Handle frequency checkboxes + const days = []; + document.querySelectorAll('.day-check:checked').forEach(cb => days.push(cb.value)); + formData.append('frequency', days.join(',')); + + try { + const resp = await fetch('api_v1_events.php', { + method: 'POST', + body: formData + }); + const result = await resp.json(); + if (result.success) { + location.reload(); + } else { + alert(result.error || 'Erreur lors de l\'enregistrement'); + btn.disabled = false; + btn.innerHTML = originalText; + } + } catch (err) { + console.error(err); + btn.disabled = false; + btn.innerHTML = originalText; + } + }); + + document.addEventListener('click', async (e) => { + // Delete event + const deleteBtn = e.target.closest('.delete-event-btn'); + if (deleteBtn) { + if (!confirm('Supprimer cet événement ?')) return; + const eventId = deleteBtn.dataset.id; + const formData = new FormData(); + formData.append('action', 'delete'); + formData.append('event_id', eventId); + + try { + const resp = await fetch('api_v1_events.php', { method: 'POST', body: formData }); + const result = await resp.json(); + if (result.success) { + deleteBtn.closest('.col-12').remove(); + } else { + alert(result.error || 'Erreur lors de la suppression'); + } + } catch (err) { console.error(err); } + return; + } + + // Edit event + const editBtn = e.target.closest('.edit-event-btn'); + if (editBtn) { + const data = editBtn.dataset; + eventActionInput.value = 'update'; + eventIdInput.value = data.id; + eventModalTitle.innerText = 'Modifier l\'événement'; + eventSubmitBtn.innerText = 'Enregistrer les modifications'; + + eventForm.querySelector('[name="title"]').value = data.title; + eventForm.querySelector('[name="description"]').value = data.description; + eventForm.querySelector('[name="start_date"]').value = data.startDate; + eventForm.querySelector('[name="start_time"]').value = data.startTime; + eventForm.querySelector('[name="end_date"]').value = data.endDate; + eventForm.querySelector('[name="end_time"]').value = data.endTime; + + isPermanentCheckbox.checked = data.isPermanent == '1'; + endDateContainer.style.display = isPermanentCheckbox.checked ? 'none' : 'block'; + endDateContainer.querySelectorAll('input').forEach(i => i.required = !isPermanentCheckbox.checked); + + enableReactionsCheckbox.checked = data.enableReactions == '1'; + + // Frequency + const freq = data.frequency ? data.frequency.split(',') : []; + document.querySelectorAll('.day-check').forEach(cb => { + cb.checked = freq.includes(cb.value); + }); + + // Banner + if (data.bannerUrl) { + eventBannerPreview.style.backgroundImage = `url('${data.bannerUrl}')`; + eventBannerPreview.style.backgroundColor = 'transparent'; + } else { + eventBannerPreview.style.backgroundImage = 'none'; + eventBannerPreview.style.backgroundColor = data.bannerColor || '#5865f2'; + eventBannerColor.value = data.bannerColor || '#5865f2'; + } + + const modal = new bootstrap.Modal(addEventModalEl); + modal.show(); + return; + } + + // Participate + const participateBtn = e.target.closest('.participate-btn'); + if (participateBtn) { + const eventId = participateBtn.dataset.id; + const formData = new FormData(); + formData.append('action', 'participate'); + formData.append('event_id', eventId); + + try { + const resp = await fetch('api_v1_events.php', { method: 'POST', body: formData }); + const result = await resp.json(); + if (result.success) { + const isParticipating = result.action === 'added'; + participateBtn.classList.toggle('btn-success', isParticipating); + participateBtn.classList.toggle('btn-outline-primary', !isParticipating); + const icon = participateBtn.querySelector('i'); + icon.classList.toggle('fa-check-circle', isParticipating); + icon.classList.toggle('fa-hand-pointer', !isParticipating); + participateBtn.lastChild.textContent = isParticipating ? ' Je participe' : ' Participer'; + + // Update count text + const card = participateBtn.closest('.card-body'); + const countText = card.querySelector('.small.text-muted'); // First small text-muted in reaction block + // Actually, let's be more specific with selectors in index.php if needed, + // but for now I'll use a better way to find it. + const reactionBlock = participateBtn.closest('.border-top'); + const countSpan = reactionBlock.querySelector('.small.text-muted'); + if (countSpan) { + const match = countSpan.textContent.match(/\d+/); + if (match) { + let count = parseInt(match[0]); + count = isParticipating ? count + 1 : count - 1; + countSpan.textContent = `Participants (${count})`; + } + } + + // Update avatars + const avatarContainer = reactionBlock.querySelector('.participant-avatars'); + if (avatarContainer) { + if (isParticipating) { + // Add avatar if not already there (though backend handles it, UI should reflect) + const myAvatar = document.createElement('div'); + myAvatar.className = 'message-avatar border border-dark user-participant-avatar'; + myAvatar.title = window.currentUsername; + myAvatar.style.width = '24px'; + myAvatar.style.height = '24px'; + if (window.currentUserAvatar) { + myAvatar.style.backgroundImage = `url('${window.currentUserAvatar}')`; + } + myAvatar.style.marginLeft = avatarContainer.children.length > 0 ? '-8px' : '0'; + myAvatar.style.position = 'relative'; + myAvatar.style.zIndex = '11'; + avatarContainer.prepend(myAvatar); + } else { + // Remove my avatar + const myAvatars = avatarContainer.querySelectorAll('.user-participant-avatar'); + myAvatars.forEach(av => av.remove()); + // Also check if any avatar matches current user display name (fallback) + const allAvatars = avatarContainer.querySelectorAll('.message-avatar'); + allAvatars.forEach(av => { + if (av.title === window.currentUsername) av.remove(); + }); + } + } + } + } catch (err) { console.error(err); } + } + }); }); diff --git a/db/migrations/20260220_create_channel_events.sql b/db/migrations/20260220_create_channel_events.sql new file mode 100644 index 0000000..b26e756 --- /dev/null +++ b/db/migrations/20260220_create_channel_events.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS channel_events ( + id INT AUTO_INCREMENT PRIMARY KEY, + channel_id INT NOT NULL, + user_id INT NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + banner_url VARCHAR(255) DEFAULT NULL, + banner_color VARCHAR(20) DEFAULT NULL, + start_date DATE NOT NULL, + start_time TIME NOT NULL, + end_date DATE NOT NULL, + end_time TIME NOT NULL, + frequency VARCHAR(50) DEFAULT NULL, -- Comma separated days like "1,3,5" (Mon, Wed, Fri) + is_permanent TINYINT(1) DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/index.php b/index.php index 9d9b577..84d4afe 100644 --- a/index.php +++ b/index.php @@ -297,18 +297,39 @@ if ($is_dm_view) { } $stmt = db()->prepare(" - SELECT t.*, u.display_name as username, u.username as login_name, u.avatar_url, + SELECT t.*, u.display_name as username, u.avatar_url, (SELECT COUNT(*) FROM messages m WHERE m.thread_id = t.id) as message_count, + (SELECT MAX(created_at) FROM messages m WHERE m.thread_id = t.id) as last_message_at, (SELECT r.color FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_color, - (SELECT r.icon_url FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_icon, - (SELECT GROUP_CONCAT(CONCAT(ft.name, ':', ft.color) SEPARATOR '|') FROM thread_tags tt JOIN forum_tags ft ON tt.tag_id = ft.id WHERE tt.thread_id = t.id) as tags - FROM forum_threads t - JOIN users u ON t.user_id = u.id - WHERE t.channel_id = ? " . $tag_where . " - ORDER BY t.is_pinned DESC, t.last_activity_at DESC, t.created_at DESC + (SELECT r.icon_url FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_icon + FROM forum_threads t + JOIN users u ON t.user_id = u.id + WHERE t.channel_id = ? $tag_where + ORDER BY t.is_pinned DESC, last_message_at DESC "); $stmt->execute($query_params); $threads = $stmt->fetchAll(); + } elseif ($channel_type === 'event') { + // Cleanup expired temporary events + try { + $now = date('Y-m-d H:i:s'); + $stmt_cleanup = db()->prepare("DELETE FROM channel_events WHERE channel_id = ? AND is_permanent = 0 AND CONCAT(end_date, ' ', end_time) < ?"); + $stmt_cleanup->execute([$active_channel_id, $now]); + + $stmt = db()->prepare(" + SELECT e.*, u.display_name as username, u.avatar_url, + (SELECT COUNT(*) FROM event_participations WHERE event_id = e.id) as participation_count, + (SELECT COUNT(*) FROM event_participations WHERE event_id = e.id AND user_id = ?) as is_participating + FROM channel_events e + JOIN users u ON e.user_id = u.id + WHERE e.channel_id = ? + ORDER BY e.is_permanent DESC, e.start_date ASC, e.start_time ASC + "); + $stmt->execute([$current_user_id, $active_channel_id]); + $events = $stmt->fetchAll(); + } catch (Exception $e) { + $events = []; + } } else { // Fetch messages for normal chat channels $display_limit = !empty($active_channel['message_limit']) ? (int)$active_channel['message_limit'] : 50; @@ -400,6 +421,7 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; window.currentUserId = ; window.activeServerId = ""; window.currentUsername = ""; + window.currentUserAvatar = ""; window.isServerOwner = ; window.canManageServer = ; window.canManageChannels = ; @@ -439,6 +461,26 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; transform: scale(1.2); transition: transform 0.1s; } + .event-card { + transition: transform 0.2s, box-shadow 0.2s; + border-radius: 12px !important; + } + .event-card:hover { + transform: translateY(-5px); + box-shadow: 0 8px 15px rgba(0,0,0,0.3) !important; + } + .event-banner { + background-size: cover; + background-position: center; + border-bottom: 1px solid var(--separator); + } + .event-description { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.5; + }
@@ -553,6 +595,7 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; elseif ($c['type'] === 'autorole') echo ''; elseif ($c['type'] === 'forum') echo ''; elseif ($c['type'] === 'voice') echo ''; + elseif ($c['type'] === 'event') echo ''; else echo ''; ?> @@ -825,6 +868,136 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; + +Découvrez et gérez les événements à venir.
+Cliquez sur "Ajouter un événement" pour commencer.
+ +