diff --git a/README.md b/README.md index e7a99a4..227e776 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,9 @@ kaubuntu.re est une interface web responsive qui permet de consulter et recherch - Recherche de contenu - Interface responsive (mobile et desktop) - Intégration avec une instance PeerTube +- **Progressive Web App (PWA)** avec installation native +- Mode hors ligne avec cache intelligent +- Détection automatique d'état de connexion ## Technologies utilisées @@ -21,6 +24,8 @@ kaubuntu.re est une interface web responsive qui permet de consulter et recherch - CSS3 avec Media Queries pour le responsive design - PHP pour le backend - JavaScript pour les interactions côté client +- **Service Worker** pour le cache offline et PWA +- **Web App Manifest** pour l'installation native - Bibliothèques externes via CDN: - Font Awesome (icônes) - jQuery @@ -45,11 +50,15 @@ kaubuntu.re est une interface web responsive qui permet de consulter et recherch │ ├── mobile-menu.php │ ├── featured-videos.php │ ├── recent-videos.php -│ └── categories.php +│ ├── categories.php +│ └── pwa-init.php ├── index.php ├── video.php ├── categories.php ├── search.php +├── sw.js # Service Worker pour PWA +├── site.webmanifest # Manifest PWA +├── browserconfig.xml # Configuration Windows └── README.md ``` @@ -57,6 +66,7 @@ kaubuntu.re est une interface web responsive qui permet de consulter et recherch 1. Clonez ce dépôt 2. Configurez votre serveur web (Apache, Nginx, etc.) pour pointer vers le répertoire racine +3. **Important :** Assurez-vous que votre serveur supporte HTTPS (requis pour PWA) ## Configuration @@ -120,14 +130,49 @@ Les fichiers `sitemap.xml`, `robots.txt` et `site.webmanifest` contiennent le no Ces fichiers sont listés dans le `.gitignore` afin que vos modifications ne soient pas suivies par Git, ce qui vous permet de personnaliser votre instance sans affecter le code source principal. +## Progressive Web App (PWA) + +Cette plateforme est une PWA complète offrant : + +### Fonctionnalités PWA + +- **Installation native** : Bouton d'installation automatique dans l'interface +- **Mode hors ligne** : Cache intelligent des pages et ressources visitées +- **Détection d'état** : Indicateur visuel en cas de perte de connexion +- **Performance** : Chargement instantané des ressources en cache +- **Responsive** : Interface adaptée pour l'utilisation en application mobile + +### Comment installer l'application + +1. **Automatique** : Un bouton "Installer" apparaît dans le header lors de la première visite +2. **Manuel** : + - **Chrome/Edge** : Menu → "Installer kaubuntu.re" + - **Safari iOS** : Partager → "Ajouter à l'écran d'accueil" + - **Firefox Android** : Menu → "Installer" + +### Compatibilité PWA + +- ✅ Chrome/Edge (Android/Desktop) +- ✅ Safari (iOS 11.3+) +- ✅ Firefox (Android) +- ✅ Samsung Internet + +### Fichiers PWA + +- `sw.js` : Service Worker gérant le cache et mode offline +- `site.webmanifest` : Configuration de l'application (nom, icônes, etc.) +- `browserconfig.xml` : Support des tuiles Windows + ## Déploiement Pour déployer sur un serveur mutualisé: 1. Assurez-vous que votre hébergeur supporte PHP (version 7.0 minimum recommandée) -2. Transférez tous les fichiers via FTP dans le répertoire racine de votre site -3. Vérifiez que les permissions des fichiers sont correctement définies (644 pour les fichiers, 755 pour les dossiers) -4. Configurez votre domaine pour pointer vers le dossier où vous avez installé l'application +2. **Configurez HTTPS** (obligatoire pour les fonctionnalités PWA) +3. Transférez tous les fichiers via FTP dans le répertoire racine de votre site +4. Vérifiez que les permissions des fichiers sont correctement définies (644 pour les fichiers, 755 pour les dossiers) +5. Configurez votre domaine pour pointer vers le dossier où vous avez installé l'application +6. Testez l'installation PWA via les outils de développement du navigateur ## Développement diff --git a/browserconfig.xml b/browserconfig.xml new file mode 100644 index 0000000..1a9e2af --- /dev/null +++ b/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #FF0000 + + + \ No newline at end of file diff --git a/css/styles.css b/css/styles.css index b53d80a..b22f8ce 100644 --- a/css/styles.css +++ b/css/styles.css @@ -98,6 +98,64 @@ img { background-color: rgba(0, 0, 0, 0.05); } +/* PWA Install Button */ +.install-pwa-button { + background: var(--primary-red); + color: white; + border: none; + padding: 8px 12px; + border-radius: 6px; + font-size: 16px; + cursor: pointer; + transition: background-color 0.3s ease; + margin-right: 10px; +} + +.install-pwa-button:hover { + background: #cc0000; +} + +.install-pwa-button:active { + transform: scale(0.95); +} + +/* PWA Styles */ +@media (display-mode: standalone) { + body { + padding-top: env(safe-area-inset-top); + } + + .header { + padding-top: env(safe-area-inset-top); + } +} + +/* Offline indicator */ +.offline-indicator { + position: fixed; + top: 0; + left: 0; + right: 0; + background: #ff4444; + color: white; + text-align: center; + padding: 10px; + z-index: 1000; + display: none; +} + +.offline-indicator.show { + display: block; +} + +/* Enhanced mobile experience */ +@media (max-width: 768px) { + .install-pwa-button { + padding: 6px 10px; + font-size: 14px; + } +} + /* Styles pour la page active dans la sidebar */ .nav-item.active { background-color: rgba(255, 0, 0, 0.08); @@ -307,7 +365,8 @@ img { .icon-button i.icon-youtube, .icon-button i.icon-instagram, .icon-button i.icon-tiktok, -.icon-button i.icon-twitter { +.icon-button i.icon-twitter, +.icon-button i.icon-mastodon { color: inherit; } @@ -1698,6 +1757,11 @@ i.icon-x, color: #000000 !important; /* Noir X (anciennement Twitter) */ } +i.icon-mastodon, +.fab.fa-mastodon.icon-mastodon { + color: #563ACC !important; /* Violet Mastodon */ +} + /* Maintenir la couleur par défaut pour les icônes dans le footer */ .footer-social a { margin: 0 10px; @@ -2297,4 +2361,6 @@ i.icon-x, width: 30px; height: 4px; } -} \ No newline at end of file +} + + \ No newline at end of file diff --git a/includes/config.default.php b/includes/config.default.php index e84bdc0..b25660c 100644 --- a/includes/config.default.php +++ b/includes/config.default.php @@ -60,6 +60,7 @@ if (!defined('X_URL')) define('X_URL', '#'); if (!defined('INSTAGRAM_URL')) define('INSTAGRAM_URL', '#'); if (!defined('YOUTUBE_URL')) define('YOUTUBE_URL', '#'); if (!defined('TIKTOK_URL')) define('TIKTOK_URL', '#'); +if (!defined('MASTODON_URL')) define('MASTODON_URL', 'https://koze.kaubuntu.re'); // Contacts if (!defined('CONTACT_EMAIL')) define('CONTACT_EMAIL', 'contact@kaubuntu.re'); diff --git a/includes/config.php b/includes/config.php index 9e4a6a9..2ec8a30 100644 --- a/includes/config.php +++ b/includes/config.php @@ -25,13 +25,13 @@ define('PEERTUBE_CATEGORIES', $peertube_categories); /** * Initialise et récupère les catégories depuis l'API PeerTube - * + * * @return array Liste des catégories */ function initCategories() { // Récupérer la liste des catégories depuis l'API $categories = callPeerTubeApi('videos/categories'); - + // Tableau de correspondance pour traduire les catégories en français $translations = [ 'Music' => 'Musique', @@ -53,7 +53,7 @@ function initCategories() { 'Kids' => 'Enfants', 'Food' => 'Cuisine', ]; - + // Si une constante PRIORITY_CATEGORIES est définie, utiliser ces traductions if (defined('PRIORITY_CATEGORIES')) { $priorityCategories = PRIORITY_CATEGORIES; @@ -65,20 +65,20 @@ function initCategories() { } } } - + $result = []; foreach ($categories as $key => $name) { // Utiliser la traduction si disponible, sinon garder le nom original $translatedName = isset($translations[$name]) ? $translations[$name] : $name; $result[$key] = $translatedName; } - + return $result; } /** * Fonction utilitaire pour appeler l'API PeerTube - * + * * @param string $endpoint Point de terminaison de l'API * @param array $params Paramètres optionnels pour la requête * @return array Données retournées par l'API @@ -89,21 +89,21 @@ function callPeerTubeApi($endpoint, $params = []) { error_log('SECURITY: Invalid PeerTube URL detected: ' . PEERTUBE_URL); return []; } - + // Nettoyer et valider l'endpoint $endpoint = ltrim($endpoint, '/'); if (!isValidApiEndpoint($endpoint)) { error_log('SECURITY: Invalid API endpoint detected: ' . $endpoint); return []; } - + $url = PEERTUBE_URL . '/api/v1/' . $endpoint; - + // Ajouter les paramètres à l'URL if (!empty($params)) { $url .= '?' . http_build_query($params); } - + // Initialiser cURL avec options de sécurité $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); @@ -115,40 +115,40 @@ function callPeerTubeApi($endpoint, $params = []) { curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); curl_setopt($ch, CURLOPT_USERAGENT, 'KaubuntuRe/1.0'); - + // Ajouter la clé API si définie if (defined('API_KEY') && !empty(API_KEY)) { curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Authorization: ApiKey ' . API_KEY ]); } - + // Exécuter la requête $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); - + // Traiter la réponse if ($response === false || !empty($error)) { error_log('PeerTube API error: ' . $error); return []; } - + if ($httpCode < 200 || $httpCode >= 300) { error_log('PeerTube API HTTP error: ' . $httpCode); return []; } - + // Décoder la réponse JSON $data = json_decode($response, true); - + return $data ?: []; } /** * Valide l'URL PeerTube pour prévenir les attaques SSRF - * + * * @param string $url URL à valider * @return bool True si l'URL est valide et sûre */ @@ -158,12 +158,12 @@ function isValidPeerTubeUrl($url) { if (!$parsed || !isset($parsed['scheme']) || !isset($parsed['host'])) { return false; } - + // Autoriser uniquement HTTPS (ou HTTP en développement) if (!in_array($parsed['scheme'], ['https', 'http'])) { return false; } - + // Bloquer les adresses IP privées et locales $host = $parsed['host']; if (filter_var($host, FILTER_VALIDATE_IP)) { @@ -171,19 +171,19 @@ function isValidPeerTubeUrl($url) { return false; } } - + // Bloquer localhost et autres domaines dangereux $blockedHosts = ['localhost', '127.0.0.1', '::1', '0.0.0.0', 'metadata.google.internal']; if (in_array(strtolower($host), $blockedHosts)) { return false; } - + return true; } /** * Valide l'endpoint API pour prévenir l'injection de chemins - * + * * @param string $endpoint Endpoint à valider * @return bool True si l'endpoint est valide */ @@ -192,12 +192,12 @@ function isValidApiEndpoint($endpoint) { if (strpos($endpoint, '..') !== false || strpos($endpoint, '//') !== false) { return false; } - + // Autoriser uniquement les caractères alphanumériques, tirets, underscores et slashes if (!preg_match('/^[a-zA-Z0-9\/_-]+$/', $endpoint)) { return false; } - + // Liste blanche des endpoints autorisés $allowedEndpoints = [ 'videos', @@ -208,7 +208,7 @@ function isValidApiEndpoint($endpoint) { 'accounts', 'accounts/.*/videos' // Pour les vidéos d'un compte spécifique ]; - + foreach ($allowedEndpoints as $pattern) { // Remplacer les .* par des marqueurs temporaires $tempPattern = str_replace('.*', '__WILDCARD__', $pattern); @@ -216,24 +216,24 @@ function isValidApiEndpoint($endpoint) { $escapedPattern = preg_quote($tempPattern, '/'); // Remettre les wildcards en place $regexPattern = str_replace('__WILDCARD__', '.*', $escapedPattern); - + if (preg_match('/^' . $regexPattern . '$/', $endpoint)) { return true; } } - + return false; } /** * Récupère les catégories depuis l'API PeerTube - * + * * @return array Liste des catégories */ function getCategories() { // Utiliser les catégories déjà récupérées $categories = PEERTUBE_CATEGORIES; - + $result = []; foreach ($categories as $key => $name) { $result[] = [ @@ -241,13 +241,13 @@ function getCategories() { 'name' => $name ]; } - + return $result; } /** * Récupère les vidéos récentes depuis l'API PeerTube - * + * * @param int $count Nombre de vidéos à récupérer * @return array Liste des vidéos récentes */ @@ -258,13 +258,13 @@ function getRecentVideos($count = RECENT_VIDEOS_COUNT) { 'count' => $count, 'isLocal' => true ]); - + return formatVideosData($data['data'] ?? []); } /** * Récupère les vidéos tendances depuis l'API PeerTube - * + * * @param int $count Nombre de vidéos à récupérer * @return array Liste des vidéos tendances */ @@ -275,13 +275,13 @@ function getTrendingVideos($count = TRENDING_VIDEOS_COUNT) { 'count' => $count, 'isLocal' => true ]); - + return formatVideosData($data['data'] ?? []); } /** * Récupère les vidéos avec un tag spécifique depuis l'API PeerTube - * + * * @param string $tag Tag à filtrer * @param int $count Nombre de vidéos à récupérer * @return array Liste des vidéos @@ -293,7 +293,7 @@ function getVideosByTag($tag, $count) { 'count' => $count, 'isLocal' => true ]); - + return formatVideosData($data['data'] ?? []); } @@ -311,10 +311,10 @@ function getShorts($count = SHORTS_COUNT) { 'count' => SHORTS_COUNT_SEARCH, 'isLocal' => true ]); - + // Formater les données $allVideos = formatVideosData($data['data'] ?? []); - + // Filtrer pour ne garder que les vidéos de moins de 2 minutes (120 secondes) et en mode portrait $shortVideos = array_filter($allVideos, function($video) { // Vérifier la durée (moins de 2 minutes) @@ -322,10 +322,10 @@ function getShorts($count = SHORTS_COUNT) { // Vérifier le ratio (mode portrait) $ratioOk = isset($video['aspectRatio']) && $video['aspectRatio'] <= 1; - + return $durationOk && $ratioOk; }); - + // Limiter au nombre demandé return array_slice($shortVideos, 0, $count); } @@ -343,7 +343,7 @@ function getIndependenceVideos($count = INDEPENDENCE_VIDEOS_COUNT) { /** * Vérifie s'il y a un direct en cours du compte LIVE_ACCOUNT_NAME sur l'instance PeerTube - * + * * @return array|null Informations sur le direct en cours ou null si aucun direct */ function getLiveStream() { @@ -355,44 +355,44 @@ function getLiveStream() { 'isLive' => true, // Filtrer uniquement les lives 'sort' => '-publishedAt' // Les plus récents en premier ]); - + // Vérifier si on a des résultats if (empty($data['data']) || count($data['data']) === 0) { return null; } - + // Formater les données du live $liveData = formatVideosData($data['data']); - + // Filtrer pour ne garder que les lives en cours $activeLives = array_filter($liveData, function($video) { return isset($video['isLive']) && $video['isLive'] === true; }); - + // Retourner le premier live trouvé return !empty($activeLives) ? reset($activeLives) : null; } /** * Formate les données brutes des vidéos venant de l'API - * + * * @param array $videosData Données brutes des vidéos * @return array Données formatées */ function formatVideosData($videosData) { $videos = []; - + foreach ($videosData as $video) { // Récupérer la vignette (thumbnail) - $thumbnail = isset($video['previewPath']) - ? PEERTUBE_URL . $video['previewPath'] + $thumbnail = isset($video['previewPath']) + ? PEERTUBE_URL . $video['previewPath'] : 'img/default-thumbnail.jpg'; - + // Récupérer l'avatar de la chaîne $channelAvatar = isset($video['channel']['avatars'][0]['path']) && isset($video['channel']['avatars'][0]['path']) ? PEERTUBE_URL . $video['channel']['avatars'][0]['path'] : 'img/default-avatar.png'; - + // Formater les données $videos[] = [ 'id' => $video['uuid'], @@ -409,7 +409,7 @@ function formatVideosData($videosData) { 'isLive' => isset($video['isLive']) ? $video['isLive'] : false ]; } - + return $videos; } @@ -418,7 +418,7 @@ function formatDuration($seconds) { $hours = floor($seconds / 3600); $minutes = floor(($seconds % 3600) / 60); $remainingSeconds = $seconds % 60; - + if ($hours > 0) { return sprintf('%d:%02d:%02d', $hours, $minutes, $remainingSeconds); } else { @@ -440,7 +440,7 @@ function formatDate($dateString) { $date = new DateTime($dateString); $now = new DateTime(); $interval = $now->diff($date); - + if ($interval->days == 0) { return 'Aujourd\'hui'; } elseif ($interval->days == 1) { @@ -474,7 +474,7 @@ function getVideosByCategory($categoryId, $count = CATEGORY_VIDEOS_COUNT) { 'sort' => '-publishedAt', // Les plus récentes d'abord 'isLocal' => true ]); - + return formatVideosData($data['data'] ?? []); } @@ -487,11 +487,11 @@ function getDisplayCategories() { $categories = []; $priorityCategories = PRIORITY_CATEGORIES; $allCategories = PEERTUBE_CATEGORIES; - + // Ajouter uniquement les catégories prioritaires dans l'ordre défini foreach ($priorityCategories as $catId => $categoryName) { $videos = getVideosByCategory($catId); - + // N'ajouter que les catégories qui ont des vidéos if (!empty($videos)) { $categories[] = [ @@ -501,7 +501,7 @@ function getDisplayCategories() { ]; } } - + return $categories; } @@ -513,11 +513,11 @@ function getDisplayCategories() { function getVideoComments($videoId) { $endpoint = "videos/{$videoId}/comment-threads"; $response = callPeerTubeApi($endpoint); - + if (!$response || !isset($response['data'])) { return []; } - + return $response['data']; } @@ -529,9 +529,9 @@ function getVideoComments($videoId) { function getVideoDownloadOptions($videoId) { // Récupérer les informations complètes de la vidéo $videoData = callPeerTubeApi('videos/' . $videoId); - + $downloadOptions = []; - + // Ajouter les fichiers directs s'ils existent if (isset($videoData['files']) && !empty($videoData['files'])) { foreach ($videoData['files'] as $file) { @@ -545,7 +545,7 @@ function getVideoDownloadOptions($videoId) { } } } - + // Ajouter les playlists de streaming s'ils existent if (isset($videoData['streamingPlaylists']) && !empty($videoData['streamingPlaylists'])) { foreach ($videoData['streamingPlaylists'] as $playlist) { @@ -563,7 +563,7 @@ function getVideoDownloadOptions($videoId) { } } } - + return $downloadOptions; } @@ -574,19 +574,19 @@ function getVideoDownloadOptions($videoId) { */ function formatFileSize($bytes) { $units = ['B', 'KB', 'MB', 'GB', 'TB']; - + $bytes = max($bytes, 0); $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); $pow = min($pow, count($units) - 1); - + $bytes /= (1 << (10 * $pow)); - + return round($bytes, 2) . ' ' . $units[$pow]; } /** * Recherche des vidéos selon un critère - * + * * @param string $query Terme de recherche * @param int $count Nombre de vidéos à récupérer * @param int $start Index de départ pour la pagination @@ -596,13 +596,13 @@ function searchVideos($query, $count = COUNT_VIDEO_SEARCH, $start = 0) { if (empty($query)) { return []; } - + // Vérifier si la recherche concerne un tag (commence par #) $isTagSearch = false; if (substr($query, 0, 1) === '#') { $isTagSearch = true; $tag = substr($query, 1); // Enlever le # du début - + // Récupérer les vidéos avec ce tag via l'API $data = callPeerTubeApi('videos', [ 'tagsOneOf' => $tag, @@ -611,10 +611,10 @@ function searchVideos($query, $count = COUNT_VIDEO_SEARCH, $start = 0) { 'isLocal' => true, // Uniquement les vidéos locales 'sort' => '-publishedAt' // Les plus récentes d'abord ]); - + return formatVideosData($data['data'] ?? []); } - + // Recherche normale (pas un tag) $data = callPeerTubeApi('search/videos', [ 'search' => $query, @@ -623,7 +623,6 @@ function searchVideos($query, $count = COUNT_VIDEO_SEARCH, $start = 0) { 'isLocal' => true, // Uniquement les vidéos locales 'sort' => '-publishedAt' // Les plus récentes d'abord ]); - return formatVideosData($data['data'] ?? []); } -?> \ No newline at end of file +?> diff --git a/includes/footer.php b/includes/footer.php index 23320c1..f8a02c7 100644 --- a/includes/footer.php +++ b/includes/footer.php @@ -66,6 +66,7 @@
+
@@ -27,6 +28,9 @@
+ diff --git a/includes/pwa-init.php b/includes/pwa-init.php new file mode 100644 index 0000000..8635105 --- /dev/null +++ b/includes/pwa-init.php @@ -0,0 +1,82 @@ +' . "\n"; + echo '' . "\n"; + echo '' . "\n"; + echo '' . "\n"; + echo '' . "\n"; + echo '' . "\n"; + echo '' . "\n"; + echo '' . "\n"; + + // Manifest + echo '' . "\n"; +} + +function addPWAScripts() { + ?> + + + \ No newline at end of file diff --git a/index.php b/index.php index 701264f..3651220 100644 --- a/index.php +++ b/index.php @@ -14,14 +14,23 @@ setSecurityHeaders(); - + - + + + + + + + + + + @@ -32,10 +41,10 @@ setSecurityHeaders();
- @@ -43,9 +52,9 @@ setSecurityHeaders(); DIRECT
-