Merge pull request 'Implémentation des fonctionnalités Progressive Web App (PWA)' (#1) from feat-imrove-app into main

Reviewed-on: https://codeberg.org/Ka-Ubuntu/kaubuntu.re/pulls/1
This commit is contained in:
Cédric Famibelle-Pronzola
2025-07-17 18:22:22 +02:00
14 changed files with 692 additions and 130 deletions
+49 -4
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="img/android-chrome-192x192.png"/>
<TileColor>#FF0000</TileColor>
</tile>
</msapplication>
</browserconfig>
+68 -2
View File
@@ -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;
}
}
}
+1
View File
@@ -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');
+73 -74
View File
@@ -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'] ?? []);
}
?>
?>
+1
View File
@@ -66,6 +66,7 @@
</div>
<div class="footer-social">
<a target="_blank" rel="noreferrer" href="<?php echo MASTODON_URL; ?>"><i class="fab fa-mastodon icon-mastodon"></i></a>
<a target="_blank" rel="noreferrer" href="<?php echo FACEBOOK_URL; ?>"><i class="fab fa-facebook icon-facebook"></i></a>
<a target="_blank" rel="noreferrer" href="<?php echo YOUTUBE_URL; ?>"><i class="fab fa-youtube icon-youtube"></i></a>
<a target="_blank" rel="noreferrer" href="<?php echo INSTAGRAM_URL; ?>"><i class="fab fa-instagram icon-instagram"></i></a>
+4
View File
@@ -8,6 +8,7 @@
</div>
<div class="social-icons">
<a target="_blank" rel="noreferrer" href="<?php echo MASTODON_URL; ?>" class="icon-button"><i class="fab fa-mastodon icon-mastodon"></i></a>
<a target="_blank" rel="noreferrer" href="<?php echo INSTAGRAM_URL; ?>" class="icon-button"><i class="fab fa-instagram icon-instagram"></i></a>
<a target="_blank" rel="noreferrer" href="<?php echo TIKTOK_URL; ?>" class="icon-button"><i class="fab fa-tiktok icon-tiktok"></i></a>
<div class="more-social-container">
@@ -27,6 +28,9 @@
</div>
<div class="action-icons">
<button id="install-pwa" class="icon-button install-pwa-button" style="display: none;" title="Installer l'application">
<i class="fas fa-download"></i>
</button>
<button class="mobile-menu-toggle">
<i class="fas fa-bars"></i>
</button>
+82
View File
@@ -0,0 +1,82 @@
<?php
// Fichier d'initialisation PWA à inclure dans toutes les pages
function addPWAHeaders() {
// Meta tags PWA
echo '<meta name="mobile-web-app-capable" content="yes">' . "\n";
echo '<meta name="apple-mobile-web-app-capable" content="yes">' . "\n";
echo '<meta name="apple-mobile-web-app-status-bar-style" content="default">' . "\n";
echo '<meta name="apple-mobile-web-app-title" content="kaubuntu.re">' . "\n";
echo '<meta name="application-name" content="kaubuntu.re">' . "\n";
echo '<meta name="msapplication-TileColor" content="#FF0000">' . "\n";
echo '<meta name="msapplication-config" content="browserconfig.xml">' . "\n";
echo '<meta name="theme-color" content="#FF0000">' . "\n";
// Manifest
echo '<link rel="manifest" href="site.webmanifest">' . "\n";
}
function addPWAScripts() {
?>
<!-- PWA Service Worker -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js')
.then(function(registration) {
console.log('Service Worker enregistré avec succès:', registration.scope);
// Écouter les mises à jour
registration.addEventListener('updatefound', function() {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', function() {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// Nouvelle version disponible
console.log('Nouvelle version disponible');
if (confirm('Une nouvelle version est disponible. Voulez-vous recharger la page ?')) {
window.location.reload();
}
}
});
});
})
.catch(function(err) {
console.log('Échec de l\'enregistrement du Service Worker:', err);
});
});
}
// Gestion de l'installation PWA
let deferredPrompt;
const installButton = document.getElementById('install-pwa');
window.addEventListener('beforeinstallprompt', function(e) {
e.preventDefault();
deferredPrompt = e;
// Afficher le bouton d'installation s'il existe
if (installButton) {
installButton.style.display = 'block';
installButton.addEventListener('click', function() {
deferredPrompt.prompt();
deferredPrompt.userChoice.then(function(choiceResult) {
if (choiceResult.outcome === 'accepted') {
console.log('PWA installée');
}
deferredPrompt = null;
installButton.style.display = 'none';
});
});
}
});
// Masquer le bouton après installation
window.addEventListener('appinstalled', function() {
console.log('PWA installée avec succès');
if (installButton) {
installButton.style.display = 'none';
}
});
</script>
<?php
}
?>
+115 -45
View File
@@ -14,14 +14,23 @@ setSecurityHeaders();
<link rel="stylesheet" href="css/styles.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="css/mastodon-timeline.min.css">
<!-- Favicons -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="img/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png">
<link rel="manifest" href="site.webmanifest">
<link rel="icon" type="image/x-icon" href="img/favicon.ico">
<meta name="theme-color" content="#ffffff">
<meta name="theme-color" content="#FF0000">
<!-- PWA Meta Tags -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="kaubuntu.re">
<meta name="application-name" content="kaubuntu.re">
<meta name="msapplication-TileColor" content="#FF0000">
<meta name="msapplication-config" content="browserconfig.xml">
</head>
<body>
<?php include 'includes/sidebar.php'; ?>
@@ -32,10 +41,10 @@ setSecurityHeaders();
<div class="hero-mastodon-wrapper">
<!-- Hero Banner -->
<div class="hero">
<?php
<?php
// Vérifier s'il y a un direct en cours
$liveStream = getLiveStream();
if ($liveStream) {
// Afficher le direct en cours
?>
@@ -43,9 +52,9 @@ setSecurityHeaders();
<i class="fas fa-circle"></i> DIRECT
</div>
<div class="hero-video-container">
<iframe
src="<?php echo PEERTUBE_URL; ?>/videos/embed/<?php echo $liveStream['id']; ?>?autoplay=1&muted=1"
frameborder="0"
<iframe
src="<?php echo PEERTUBE_URL; ?>/videos/embed/<?php echo $liveStream['id']; ?>?autoplay=1&muted=1"
frameborder="0"
allowfullscreen="allowfullscreen"
allow="autoplay; fullscreen"
title="<?php echo htmlspecialchars($liveStream['title']); ?>">
@@ -64,7 +73,7 @@ setSecurityHeaders();
<span class="channel-name"><?php echo $liveStream['channel']; ?></span>
</div>
</div>
<?php
<?php
} else {
// Aucun direct en cours
?>
@@ -88,24 +97,24 @@ setSecurityHeaders();
</div>
</div>
<!-- Hashtags en ligne -->
<div class="tags-section">
<?php
<?php
if (defined('POPULAR_TAGS') && !empty(POPULAR_TAGS)):
foreach (POPULAR_TAGS as $tag):
$encodedTag = urlencode('#' . $tag);
?>
<a href="recherche.php?q=<?php echo $encodedTag; ?>" class="tag">#<?php echo htmlspecialchars($tag); ?></a>
<?php
<?php
endforeach;
endif;
?>
</div>
<!-- Séparateur stylisé -->
<hr class="section-divider">
<!-- Section Shorts -->
<div class="video-section">
<div class="section-header">
@@ -114,13 +123,13 @@ setSecurityHeaders();
</div>
<h2 class="section-title">Shorts</h2>
</div>
<div class="carousel">
<div class="carousel-container">
<?php
<?php
// Récupérer les shorts depuis l'API PeerTube
$shorts = getShorts();
// Traiter le cas où aucun short n'est trouvé
if (empty($shorts)) {
echo '<div class="no-results">Aucun short disponible pour le moment</div>';
@@ -137,12 +146,12 @@ setSecurityHeaders();
</div>
</div>
</div>
<?php
<?php
endforeach;
}
?>
</div>
<?php if (!empty($shorts) && count($shorts) > 1): ?>
<div class="carousel-controls">
<?php for ($i = 0; $i < count($shorts); $i++): ?>
@@ -152,10 +161,10 @@ setSecurityHeaders();
<?php endif; ?>
</div>
</div>
<!-- Séparateur stylisé -->
<hr class="section-divider">
<!-- Section Dernières vidéos -->
<div class="video-section">
<div class="section-header">
@@ -164,12 +173,12 @@ setSecurityHeaders();
</div>
<h2 class="section-title">Dernières vidéos</h2>
</div>
<div class="video-grid">
<?php
// Récupérer les vidéos récentes depuis l'API PeerTube
$recentVideos = getRecentVideos();
// Traiter le cas où aucune vidéo n'est trouvée
if (empty($recentVideos)) {
echo '<div class="no-results">Aucune vidéo disponible pour le moment</div>';
@@ -202,18 +211,18 @@ setSecurityHeaders();
</div>
</div>
</div>
<?php
<?php
endforeach;
}
?>
</div>
<button class="view-more">Voir plus</button>
</div>
<!-- Séparateur stylisé -->
<hr class="section-divider">
<!-- Section Tendances -->
<div class="video-section">
<div class="section-header">
@@ -222,12 +231,12 @@ setSecurityHeaders();
</div>
<h2 class="section-title">Tendances</h2>
</div>
<div class="video-grid">
<?php
// Récupérer les vidéos tendances depuis l'API PeerTube
$trendingVideos = getTrendingVideos();
// Traiter le cas où aucune vidéo n'est trouvée
if (empty($trendingVideos)) {
echo '<div class="no-results">Aucune vidéo disponible pour le moment</div>';
@@ -260,23 +269,23 @@ setSecurityHeaders();
</div>
</div>
</div>
<?php
<?php
endforeach;
}
?>
</div>
<button class="view-more">Voir plus</button>
</div>
<!-- Séparateur stylisé -->
<hr class="section-divider">
<!-- Sections par catégorie -->
<?php
// Récupérer les catégories avec leurs vidéos
$displayCategories = getDisplayCategories();
// Afficher chaque catégorie qui a des vidéos
foreach ($displayCategories as $category):
if (!empty($category['videos'])):
@@ -289,7 +298,7 @@ setSecurityHeaders();
</div>
<h2 class="section-title"><?php echo $category['name']; ?></h2>
</div>
<div class="video-grid">
<?php foreach ($category['videos'] as $video): ?>
<div class="video-card" data-video-id="<?php echo $video['id']; ?>">
@@ -320,25 +329,25 @@ setSecurityHeaders();
</div>
<?php endforeach; ?>
</div>
<button class="view-more">Voir plus</button>
</div>
<!-- Séparateur stylisé -->
<hr class="section-divider">
<?php
<?php
endif;
endforeach;
?>
<!-- Section Flexbox pour Informations et Tendances Hashtags -->
<div class="info-tags-container">
<?php if (defined('MOVEMENT_DESCRIPTION') && !empty(MOVEMENT_DESCRIPTION)): ?>
<!-- Section Informations -->
<div class="info-section">
<h2 class="info-header"><?php echo MOVEMENT_DESCRIPTION; ?></h2>
<?php if (defined('MOVEMENT_IMAGE') && !empty(MOVEMENT_IMAGE)): ?>
<figure class="movement-figure">
<img src="<?php echo MOVEMENT_IMAGE; ?>" alt="<?php echo defined('MOVEMENT_IMAGE_ALT') ? MOVEMENT_IMAGE_ALT : ''; ?>" class="info-image">
@@ -351,19 +360,19 @@ setSecurityHeaders();
<?php endif; ?>
</div>
<?php endif; ?>
<!-- Section Tendances Hashtags -->
<div class="tags-section-container">
<h2 class="section-title centered">Tendances</h2>
<div class="tags-section">
<?php
<?php
if (defined('POPULAR_TAGS') && !empty(POPULAR_TAGS)):
foreach (POPULAR_TAGS as $tag):
$encodedTag = urlencode('#' . $tag);
?>
<a href="recherche.php?q=<?php echo $encodedTag; ?>" class="tag">#<?php echo htmlspecialchars($tag); ?></a>
<?php
<?php
endforeach;
endif;
?>
@@ -377,5 +386,66 @@ setSecurityHeaders();
<script src="js/main.js"></script>
<script src="js/mastodon-timeline.umd.js"></script>
<script src="js/mastodon-config.php"></script>
<!-- PWA Service Worker -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js')
.then(function(registration) {
console.log('Service Worker enregistré avec succès:', registration.scope);
// Écouter les mises à jour
registration.addEventListener('updatefound', function() {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', function() {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// Nouvelle version disponible
console.log('Nouvelle version disponible');
if (confirm('Une nouvelle version est disponible. Voulez-vous recharger la page ?')) {
window.location.reload();
}
}
});
});
})
.catch(function(err) {
console.log('Échec de l\'enregistrement du Service Worker:', err);
});
});
}
// Gestion de l'installation PWA
let deferredPrompt;
const installButton = document.getElementById('install-pwa');
window.addEventListener('beforeinstallprompt', function(e) {
e.preventDefault();
deferredPrompt = e;
// Afficher le bouton d'installation s'il existe
if (installButton) {
installButton.style.display = 'block';
installButton.addEventListener('click', function() {
deferredPrompt.prompt();
deferredPrompt.userChoice.then(function(choiceResult) {
if (choiceResult.outcome === 'accepted') {
console.log('PWA installée');
}
deferredPrompt = null;
installButton.style.display = 'none';
});
});
}
});
// Masquer le bouton après installation
window.addEventListener('appinstalled', function() {
console.log('PWA installée avec succès');
if (installButton) {
installButton.style.display = 'none';
}
});
</script>
</body>
</html>
</html>
+24
View File
@@ -1,4 +1,28 @@
document.addEventListener('DOMContentLoaded', function() {
// Détection de l'état de connexion
function updateOnlineStatus() {
const offlineIndicator = document.querySelector('.offline-indicator');
if (!navigator.onLine) {
if (!offlineIndicator) {
const indicator = document.createElement('div');
indicator.className = 'offline-indicator show';
indicator.textContent = 'Mode hors ligne - Fonctionnalités limitées';
document.body.insertBefore(indicator, document.body.firstChild);
}
} else {
if (offlineIndicator) {
offlineIndicator.remove();
}
}
}
// Écouter les changements de connexion
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
// Vérifier l'état initial
updateOnlineStatus();
// Gestion du menu mobile
const mobileMenuToggle = document.querySelector('.mobile-menu-toggle');
const mobileMenu = document.querySelector('.mobile-menu');
+43
View File
@@ -0,0 +1,43 @@
{
"name": "kaubuntu.re - Plateforme Multimédia",
"short_name": "kaubuntu.re",
"description": "Plateforme multimédia alternative et indépendante",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#FF0000",
"orientation": "portrait-primary",
"icons": [
{
"src": "img/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "img/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "img/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png"
},
{
"src": "img/favicon-32x32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "img/favicon-16x16.png",
"sizes": "16x16",
"type": "image/png"
}
],
"categories": ["entertainment", "news", "social"],
"lang": "fr",
"scope": "/",
"prefer_related_applications": false
}
-1
View File
@@ -1 +0,0 @@
{"name":"VOTRE-DOMAINE","short_name":"VOTRE-DOMAINE","icons":[{"src":"img/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"img/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
+222
View File
@@ -0,0 +1,222 @@
const CACHE_NAME = 'kaubuntu-v1';
const STATIC_CACHE_NAME = 'kaubuntu-static-v1';
const DYNAMIC_CACHE_NAME = 'kaubuntu-dynamic-v1';
// Ressources à mettre en cache immédiatement
const STATIC_ASSETS = [
'/',
'/index.php',
'/css/styles.css',
'/css/categories.css',
'/css/search.css',
'/css/video-page.css',
'/css/mastodon-timeline.min.css',
'/js/main.js',
'/js/categories.js',
'/js/search.js',
'/js/mastodon-timeline.umd.js',
'/img/logo.png',
'/img/android-chrome-192x192.png',
'/img/android-chrome-512x512.png',
'/img/apple-touch-icon.png',
'/img/favicon-32x32.png',
'/img/favicon-16x16.png',
'/img/favicon.ico',
'/img/play-icon.svg',
'/site.webmanifest',
'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css',
'https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js'
];
// Pages à mettre en cache
const PAGES_TO_CACHE = [
'/',
'/index.php',
'/categories.php',
'/recherche.php',
'/mentions-legales.php'
];
// Installation du Service Worker
self.addEventListener('install', event => {
console.log('Service Worker: Installation');
event.waitUntil(
caches.open(STATIC_CACHE_NAME)
.then(cache => {
console.log('Service Worker: Mise en cache des assets statiques');
return cache.addAll(STATIC_ASSETS);
})
.then(() => {
return self.skipWaiting();
})
.catch(err => {
console.error('Service Worker: Erreur lors de la mise en cache:', err);
})
);
});
// Activation du Service Worker
self.addEventListener('activate', event => {
console.log('Service Worker: Activation');
event.waitUntil(
caches.keys()
.then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheName !== STATIC_CACHE_NAME && cacheName !== DYNAMIC_CACHE_NAME) {
console.log('Service Worker: Suppression du cache obsolète:', cacheName);
return caches.delete(cacheName);
}
})
);
})
.then(() => {
return self.clients.claim();
})
);
});
// Interception des requêtes
self.addEventListener('fetch', event => {
const { request } = event;
const url = new URL(request.url);
// Ignorer les requêtes non-GET
if (request.method !== 'GET') {
return;
}
// Ignorer les requêtes vers des domaines externes (sauf CDN)
if (url.origin !== location.origin &&
!url.hostname.includes('cdnjs.cloudflare.com')) {
return;
}
// Stratégie Cache First pour les assets statiques
if (isStaticAsset(request.url)) {
event.respondWith(
caches.match(request)
.then(response => {
if (response) {
return response;
}
return fetch(request)
.then(response => {
if (response.status === 200) {
const responseClone = response.clone();
caches.open(STATIC_CACHE_NAME)
.then(cache => cache.put(request, responseClone));
}
return response;
});
})
.catch(() => {
// Fallback pour les images
if (request.destination === 'image') {
return caches.match('/img/logo.png');
}
})
);
return;
}
// Stratégie Network First pour les pages dynamiques
if (isPageRequest(request.url)) {
event.respondWith(
fetch(request)
.then(response => {
if (response.status === 200) {
const responseClone = response.clone();
caches.open(DYNAMIC_CACHE_NAME)
.then(cache => cache.put(request, responseClone));
}
return response;
})
.catch(() => {
return caches.match(request)
.then(response => {
if (response) {
return response;
}
// Fallback vers la page d'accueil
return caches.match('/') || caches.match('/index.php');
});
})
);
return;
}
// Stratégie Network First pour les API et AJAX
if (isApiRequest(request.url)) {
event.respondWith(
fetch(request)
.catch(() => {
return new Response(
JSON.stringify({
error: 'Pas de connexion internet',
offline: true
}),
{
status: 503,
headers: {
'Content-Type': 'application/json'
}
}
);
})
);
return;
}
});
// Fonctions utilitaires
function isStaticAsset(url) {
return url.includes('/css/') ||
url.includes('/js/') ||
url.includes('/img/') ||
url.includes('cdnjs.cloudflare.com') ||
url.endsWith('.css') ||
url.endsWith('.js') ||
url.endsWith('.png') ||
url.endsWith('.jpg') ||
url.endsWith('.jpeg') ||
url.endsWith('.svg') ||
url.endsWith('.ico') ||
url.endsWith('.webmanifest');
}
function isPageRequest(url) {
return url.endsWith('/') ||
url.endsWith('.php') ||
url.includes('index.php') ||
url.includes('categories.php') ||
url.includes('recherche.php') ||
url.includes('video.php') ||
url.includes('mentions-legales.php');
}
function isApiRequest(url) {
return url.includes('/ajax/') ||
url.includes('api') ||
url.includes('mastodon-config.php');
}
// Gestion des messages du client
self.addEventListener('message', event => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// Notification de mise à jour
self.addEventListener('message', event => {
if (event.data && event.data.type === 'CHECK_UPDATE') {
// Vérifier s'il y a une mise à jour
event.ports[0].postMessage({
type: 'UPDATE_AVAILABLE',
version: CACHE_NAME
});
}
});
+1 -4
View File
@@ -176,10 +176,6 @@ if (empty($videoData) || isset($videoData['error'])) {
</div>
<div class="video-actions">
<button disabled class="action-button disabled-button" title="Fonctionnalité disponible uniquement sur <?php echo PEERTUBE_DISPLAY_NAME; ?>">
<i class="fas fa-thumbs-up"></i>
<span><?php echo formatViewCount($video['likes']); ?></span>
</button>
<button class="action-button" id="share-btn">
<i class="fas fa-share"></i>
<span>Partager</span>
@@ -621,5 +617,6 @@ if (empty($videoData) || isset($videoData['error'])) {
}
});
</script>
</body>
</html>