Compare commits

..

12 Commits
3.0.0 ... main

11 changed files with 1204 additions and 396 deletions

View File

@ -1,8 +1,4 @@
# Cyla
Site d'hébergement de fichiers simple, réduit au strict nécessaire.
Le projet se base sur la première version de [QuadFile](https://github.com/QuadPiece/QuadFile).
![image](https://concepts.esenjin.xyz/cyla/v2/file/CE513E.png)
![image](https://concepts.esenjin.xyz/cyla/fichiers/6787d3fe886e4_1736954878.png)

291
admin.php
View File

@ -240,128 +240,201 @@ ob_start(); ?>
}
</script>
<div class="card">
<h2>Changer le mot de passe</h2>
<form method="POST" class="password-form">
<input type="hidden" name="csrf_token" value="<?php echo Cyla::generateCSRFToken(); ?>">
<input type="hidden" name="action" value="change_password">
<div class="form-group">
<label for="current_password">Mot de passe actuel</label>
<input type="password" id="current_password" name="current_password" required>
</div>
<div class="form-group">
<label for="new_password">Nouveau mot de passe</label>
<input type="password" id="new_password" name="new_password" required>
</div>
<div class="form-group">
<label for="confirm_password">Confirmer le nouveau mot de passe</label>
<input type="password" id="confirm_password" name="confirm_password" required>
</div>
<button type="submit" class="btn">Changer le mot de passe</button>
</form>
</div>
<?php
// Traitement de la suppression de fichier
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'delete_file') {
if (!isset($_POST['csrf_token']) || !Cyla::verifyCSRFToken($_POST['csrf_token'])) {
header('Location: admin.php?error=' . urlencode('Token de sécurité invalide'));
exit;
} else if (!isset($_POST['filename'])) {
header('Location: admin.php?error=' . urlencode('Nom de fichier manquant'));
exit;
} else {
$result = Cyla::deleteFile($_POST['filename']);
if ($result['success']) {
header('Location: admin.php?success=' . urlencode('Fichier supprimé avec succès'));
exit;
} else {
header('Location: admin.php?error=' . urlencode($result['error']));
exit;
}
}
}
<div class="card">
<h2>Fichiers hébergés</h2>
<?php if (empty($files)): ?>
<p class="text-muted">Aucun fichier hébergé</p>
<?php else: ?>
<div class="file-list">
<?php foreach ($files as $file): ?>
<div class="file-item">
<div class="file-preview">
<?php if ($file['preview_type'] === 'image'): ?>
<img src="fichiers/<?php echo Cyla::escape($file['name']); ?>" alt="<?php echo Cyla::escape($file['name']); ?>">
<?php else: ?>
<div class="preview-placeholder">
<?php echo strtoupper($file['extension']); ?>
</div>
<?php endif; ?>
</div>
<div class="file-info">
<p class="file-name"><?php echo Cyla::escape($file['name']); ?></p>
<p class="file-meta">
<?php echo Cyla::escape(round($file['size'] / 1024, 2)); ?> Ko
· <?php echo date('d/m/Y H:i', $file['uploaded']); ?>
</p>
<div class="file-actions">
<a href="share.php?file=<?php echo urlencode($file['name']); ?>" class="btn btn-secondary" target="_blank">Voir</a>
<button class="btn" onclick="copyShareLink('<?php echo SITE_URL; ?>share.php?file=<?php echo urlencode($file['name']); ?>')">Copier le lien</button>
</div>
</div>
</div>
<?php endforeach; ?>
// Récupération de la page courante depuis l'URL
$currentPage = isset($_GET['page']) ? max(1, intval($_GET['page'])) : 1;
$filesPerPage = 20;
// Récupération des fichiers avec pagination
$fileData = Cyla::listFiles($currentPage, $filesPerPage);
$files = $fileData['files'];
?>
<div class="card">
<h2>Fichiers hébergés</h2>
<?php if (empty($files)): ?>
<p class="text-muted">Aucun fichier hébergé</p>
<?php else: ?>
<div class="files-header">
<p class="files-count">
<?php
$start = (($fileData['currentPage'] - 1) * $fileData['perPage']) + 1;
$end = min($start + count($files) - 1, $fileData['total']);
echo "Affichage de $start-$end sur {$fileData['total']} fichiers";
?>
</p>
</div>
<div class="file-list">
<?php foreach ($files as $file): ?>
<div class="file-item">
<div class="file-preview">
<?php if ($file['preview_type'] === 'image'): ?>
<img src="<?php echo $file['path'] . Cyla::escape($file['name']); ?>"
alt="<?php echo Cyla::escape($file['name']); ?>">
<?php else: ?>
<div class="preview-placeholder">
<?php echo strtoupper($file['extension']); ?>
</div>
<?php endif; ?>
</div>
<div class="file-info">
<p class="file-name"><?php echo Cyla::escape($file['name']); ?></p>
<p class="file-meta">
<?php echo Cyla::escape(round($file['size'] / 1024, 2)); ?> Ko
· <?php echo date('d/m/Y H:i', $file['uploaded']); ?>
· <?php echo $file['path']; ?>
</p>
<div class="file-actions">
<a href="share.php?file=<?php echo urlencode($file['name']); ?>&path=<?php echo urlencode($file['path']); ?>"
class="btn btn-secondary"
target="_blank">Voir</a>
<button class="btn"
onclick="copyShareLink('<?php echo SITE_URL; ?>share.php?file=<?php echo urlencode($file['name']); ?>&path=<?php echo urlencode($file['path']); ?>')">
Copier le lien
</button>
<button class="btn btn-danger"
onclick="confirmDelete('<?php echo Cyla::escape($file['name']); ?>', '<?php echo Cyla::escape($file['path']); ?>')">
×
</button>
</div>
</div>
</div>
<!-- Formulaire caché pour la suppression -->
<form id="deleteForm-<?php echo Cyla::escape($file['name']); ?>"
action="admin.php"
method="POST"
style="display: none;">
<input type="hidden" name="action" value="delete_file">
<input type="hidden" name="filename" value="<?php echo Cyla::escape($file['name']); ?>">
<input type="hidden" name="path" value="<?php echo Cyla::escape($file['path']); ?>">
<input type="hidden" name="csrf_token" value="<?php echo Cyla::generateCSRFToken(); ?>">
</form>
<?php endforeach; ?>
<?php if ($fileData['totalPages'] > 1): ?>
<div class="pagination">
<?php if ($fileData['currentPage'] > 1): ?>
<a href="?page=<?php echo $fileData['currentPage'] - 1; ?>"
class="pagination-link">&laquo;</a>
<?php endif; ?>
<?php
// Afficher max 5 pages autour de la page courante
$start = max(1, $fileData['currentPage'] - 2);
$end = min($fileData['totalPages'], $fileData['currentPage'] + 2);
if ($start > 1) {
echo '<a href="?page=1" class="pagination-link">1</a>';
if ($start > 2) echo '<span class="pagination-ellipsis">...</span>';
}
for ($i = $start; $i <= $end; $i++) {
$isActive = $i === $fileData['currentPage'];
echo '<a href="?page=' . $i . '" class="pagination-link ' .
($isActive ? 'pagination-active' : '') . '">' . $i . '</a>';
}
if ($end < $fileData['totalPages']) {
if ($end < $fileData['totalPages'] - 1) echo '<span class="pagination-ellipsis">...</span>';
echo '<a href="?page=' . $fileData['totalPages'] .
'" class="pagination-link">' . $fileData['totalPages'] . '</a>';
}
if ($fileData['currentPage'] < $fileData['totalPages']): ?>
<a href="?page=<?php echo $fileData['currentPage'] + 1; ?>"
class="pagination-link">&raquo;</a>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<style>
/* Styles spécifiques à la page d'administration */
.admin-container {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.admin-container h2 {
color: var(--color-primary);
margin-bottom: var(--spacing-md);
}
.help-text {
font-size: 0.9rem;
color: var(--color-text-muted);
margin-top: var(--spacing-xs);
}
.preview-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-bg);
color: var(--color-text-muted);
font-size: 2rem;
font-weight: bold;
}
.file-name {
font-weight: bold;
word-break: break-all;
}
.file-meta {
font-size: 0.9rem;
color: var(--color-text-muted);
margin: var(--spacing-xs) 0;
}
.file-actions {
display: flex;
gap: var(--spacing-sm);
margin-top: var(--spacing-sm);
}
</style>
<script>
// Fonction pour copier le lien de partage
function copyShareLink(url) {
navigator.clipboard.writeText(url).then(() => {
alert('Lien copié dans le presse-papier');
// Créer et afficher la notification
const notification = document.createElement('div');
notification.className = 'notification';
notification.textContent = 'Lien copié dans le presse-papier !';
document.body.appendChild(notification);
// Supprimer la notification après l'animation
setTimeout(() => {
notification.remove();
}, 3000);
}).catch(err => {
console.error('Erreur lors de la copie :', err);
alert('Erreur lors de la copie du lien');
// En cas d'erreur, afficher une notification d'erreur
const notification = document.createElement('div');
notification.className = 'notification error';
notification.textContent = 'Erreur lors de la copie';
document.body.appendChild(notification);
setTimeout(() => {
notification.remove();
}, 3000);
});
}
</script>
// Fonction de confirmation de suppression
function confirmDelete(filename) {
const overlay = document.createElement('div');
overlay.className = 'confirmation-overlay';
const dialog = document.createElement('div');
dialog.className = 'confirmation-dialog';
dialog.innerHTML = `
<h3>Confirmer la suppression</h3>
<p>Voulez-vous vraiment supprimer le fichier "${filename}" ?</p>
<div class="confirmation-actions">
<button class="btn btn-secondary" onclick="closeConfirmDialog()">Annuler</button>
<button class="btn" onclick="submitDelete('${filename}')">Supprimer</button>
</div>
`;
document.body.appendChild(overlay);
document.body.appendChild(dialog);
}
// Fonction pour fermer la modale
function closeConfirmDialog() {
document.querySelector('.confirmation-overlay')?.remove();
document.querySelector('.confirmation-dialog')?.remove();
}
// Fonction pour soumettre la suppression
function submitDelete(filename) {
document.getElementById('deleteForm-' + filename).submit();
closeConfirmDialog();
}
</script>
<script>
window.CSRF_TOKEN = "<?php echo Cyla::generateCSRFToken(); ?>";
</script>
<script src="js/password-modal.js"></script>
<?php
$content = ob_get_clean();
require 'layout.php';

51
api.php
View File

@ -2,8 +2,12 @@
define('CYLA_CORE', true);
require_once 'core.php';
// Augmenter la limite de temps d'exécution pour les uploads multiples
set_time_limit(300); // 5 minutes
// Vérifier si l'utilisateur est connecté
if (!Cyla::isLoggedIn()) {
header('Content-Type: application/json');
http_response_code(401);
echo json_encode(['error' => 'Non autorisé']);
exit;
@ -11,6 +15,7 @@ if (!Cyla::isLoggedIn()) {
// Vérifier le token CSRF
if (!isset($_POST['csrf_token']) || !Cyla::verifyCSRFToken($_POST['csrf_token'])) {
header('Content-Type: application/json');
http_response_code(403);
echo json_encode(['error' => 'Token CSRF invalide']);
exit;
@ -18,18 +23,35 @@ if (!isset($_POST['csrf_token']) || !Cyla::verifyCSRFToken($_POST['csrf_token'])
// Gérer l'upload de fichier
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['file'])) {
$validation = Cyla::validateUpload($_FILES['file']);
header('Content-Type: application/json');
if (!$validation['valid']) {
http_response_code(400);
echo json_encode(['error' => $validation['error']]);
exit;
}
try {
// Vérification de l'espace disque disponible
$uploadDir = UPLOAD_DIR;
$freeSpace = disk_free_space($uploadDir);
if ($freeSpace < $_FILES['file']['size']) {
throw new Exception('Espace disque insuffisant');
}
$validation = Cyla::validateUpload($_FILES['file']);
if (!$validation['valid']) {
throw new Exception($validation['error']);
}
$filename = Cyla::generateUniqueFilename($_FILES['file']['name']);
$destination = $uploadDir . $filename;
// Upload avec gestion de la mémoire
if (!move_uploaded_file($_FILES['file']['tmp_name'], $destination)) {
throw new Exception('Erreur lors du déplacement du fichier');
}
// Vérifier l'intégrité du fichier
if (!file_exists($destination) || filesize($destination) !== $_FILES['file']['size']) {
unlink($destination); // Nettoyer en cas d'erreur
throw new Exception('Erreur d\'intégrité du fichier');
}
$filename = Cyla::generateUniqueFilename($_FILES['file']['name']);
$destination = UPLOAD_DIR . $filename;
if (move_uploaded_file($_FILES['file']['tmp_name'], $destination)) {
echo json_encode([
'success' => true,
'file' => [
@ -38,9 +60,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['file'])) {
'url' => 'share.php?file=' . urlencode($filename)
]
]);
} else {
http_response_code(500);
echo json_encode(['error' => 'Erreur lors de l\'upload du fichier']);
} catch (Exception $e) {
http_response_code(400);
echo json_encode([
'error' => $e->getMessage(),
'details' => error_get_last()
]);
}
exit;
}

View File

@ -7,10 +7,14 @@ if (!defined('CYLA_CORE')) {
// Site configuration
define('SITE_NAME', 'Cyla');
define('SITE_VERSION', '3.0.0');
define('SITE_VERSION', '3.0.2');
define('SITE_URL', 'https://concepts.esenjin.xyz/cyla/');
// Files configuration
define('LEGACY_UPLOAD_DIRS', [
__DIR__ . '/v1/img/fichiers/',
__DIR__ . '/v2/file/'
]);
define('UPLOAD_DIR', __DIR__ . '/fichiers/');
define('MAX_FILE_SIZE', 100 * 1024 * 1024); // 100 Mo en octets
define('ALLOWED_EXTENSIONS', [

115
core.php
View File

@ -131,22 +131,123 @@ class Cyla {
}
/**
* Liste les fichiers uploadés
* @return array
* Liste les fichiers uploadés avec pagination
* @param int $page Numéro de la page (commence à 1)
* @param int $perPage Nombre d'éléments par page
* @return array ['files' => array, 'total' => int, 'totalPages' => int]
*/
public static function listFiles() {
public static function listFiles($page = 1, $perPage = 20) {
$files = [];
foreach (glob(UPLOAD_DIR . '*') as $file) {
$allFiles = [];
// Ajouter les fichiers du dossier principal
$allFiles = array_merge($allFiles, glob(UPLOAD_DIR . '*'));
// Ajouter les fichiers des dossiers hérités
foreach (LEGACY_UPLOAD_DIRS as $dir) {
if (is_dir($dir)) {
$allFiles = array_merge($allFiles, glob($dir . '*'));
}
}
// Trier les fichiers par date de modification (plus récent en premier)
usort($allFiles, function($a, $b) {
return filemtime($b) - filemtime($a);
});
// Calculer la pagination
$total = count($allFiles);
$totalPages = ceil($total / $perPage);
$page = max(1, min($page, $totalPages));
$offset = ($page - 1) * $perPage;
// Récupérer uniquement les fichiers de la page courante
$pageFiles = array_slice($allFiles, $offset, $perPage);
foreach ($pageFiles as $file) {
$info = pathinfo($file);
$relativePath = '';
// Déterminer le chemin relatif selon le dossier
if (strpos($file, UPLOAD_DIR) === 0) {
$relativePath = 'fichiers/';
} elseif (strpos($file, __DIR__ . '/v1/img/fichiers/') === 0) {
$relativePath = 'v1/img/fichiers/';
} elseif (strpos($file, __DIR__ . '/v2/file/') === 0) {
$relativePath = 'v2/file/';
}
$files[] = [
'name' => basename($file),
'size' => filesize($file),
'extension' => strtolower($info['extension']),
'extension' => strtolower($info['extension'] ?? ''),
'uploaded' => filemtime($file),
'preview_type' => getPreviewType($info['extension'])
'preview_type' => getPreviewType($info['extension'] ?? ''),
'path' => $relativePath // Ajout du chemin relatif
];
}
return $files;
return [
'files' => $files,
'total' => $total,
'totalPages' => $totalPages,
'currentPage' => $page,
'perPage' => $perPage
];
}
/**
* Supprime un fichier
* @param string $filename Nom du fichier à supprimer
* @param string $path Chemin relatif du fichier
* @return array ['success' => bool, 'error' => string|null]
*/
public static function deleteFile($filename, $path = 'fichiers/') {
// Déterminer le chemin complet selon le dossier
$basePath = '';
switch ($path) {
case 'v1/img/fichiers/':
$basePath = __DIR__ . '/v1/img/fichiers/';
break;
case 'v2/file/':
$basePath = __DIR__ . '/v2/file/';
break;
default:
$basePath = UPLOAD_DIR;
}
$filepath = $basePath . $filename;
// Vérifier que le fichier existe et est dans le bon dossier
if (!file_exists($filepath) || !is_file($filepath)) {
return ['success' => false, 'error' => 'Fichier introuvable'];
}
// Vérifier que le fichier est bien dans un des dossiers autorisés
$realpath = realpath($filepath);
$allowed = false;
if (strpos($realpath, realpath(UPLOAD_DIR)) === 0) {
$allowed = true;
} else {
foreach (LEGACY_UPLOAD_DIRS as $dir) {
if (strpos($realpath, realpath($dir)) === 0) {
$allowed = true;
break;
}
}
}
if (!$allowed) {
return ['success' => false, 'error' => 'Chemin de fichier non autorisé'];
}
// Supprimer le fichier
if (unlink($filepath)) {
return ['success' => true];
} else {
return ['success' => false, 'error' => 'Erreur lors de la suppression du fichier'];
}
}
}

View File

@ -69,15 +69,23 @@ nav {
gap: var(--spacing-md);
}
.nav-link {
/* Navigation */
.nav-link,
button.nav-link {
color: var(--color-text);
text-decoration: none;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--border-radius);
transition: background-color 0.3s ease;
font: inherit; /* Pour que le bouton utilise la même police */
border: none;
background: none;
cursor: pointer;
display: inline-block;
}
.nav-link:hover {
.nav-link:hover,
button.nav-link:hover {
background-color: var(--color-border);
}
@ -342,4 +350,371 @@ footer {
.file-list {
grid-template-columns: 1fr;
}
}
/* Styles pour la modale */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
backdrop-filter: blur(4px);
}
.modal-container {
background-color: var(--color-bg-alt);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
width: 100%;
max-width: 500px;
margin: 1rem;
animation: modalAppear 0.3s ease-out;
}
@keyframes modalAppear {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* Styles spécifiques à la page d'administration */
.admin-container {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.admin-container h2 {
color: var(--color-primary);
margin-bottom: var(--spacing-md);
}
.help-text {
font-size: 0.9rem;
color: var(--color-text-muted);
margin-top: var(--spacing-xs);
}
.preview-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-bg);
color: var(--color-text-muted);
font-size: 2rem;
font-weight: bold;
}
.file-name {
font-weight: bold;
word-break: break-all;
}
.file-meta {
font-size: 0.9rem;
color: var(--color-text-muted);
margin: var(--spacing-xs) 0;
}
.file-actions {
display: flex;
gap: var(--spacing-sm);
margin-top: var(--spacing-sm);
}
/* Styles pour la pagination et l'affichage des fichiers */
.files-header {
margin-bottom: var(--spacing-md);
color: var(--color-text-muted);
}
.files-count {
font-size: 0.9rem;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: var(--spacing-xs);
margin: var(--spacing-lg) auto;
padding-top: var(--spacing-md);
border-top: 1px solid var(--color-border);
max-width: 100%;
}
.pagination-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
height: 2rem;
padding: 0 0.5rem;
color: var(--color-text);
text-decoration: none;
background-color: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
transition: all 0.2s ease;
white-space: nowrap;
font-size: 0.9rem;
}
.pagination-link:hover {
background-color: var(--color-border);
}
.pagination-active {
background-color: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-text);
}
.pagination-ellipsis {
color: var(--color-text-muted);
padding: 0 0.25rem;
user-select: none;
}
/* Bouton de suppression */
.btn-danger {
background-color: var(--color-accent);
width: 2.5rem;
height: 2.5rem;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
line-height: 1;
}
.btn-danger:hover {
background-color: #a85651;
}
/* Boîte de dialogue de confirmation */
.confirmation-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: var(--color-bg-alt);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
padding: var(--spacing-lg);
max-width: 400px;
width: 90%;
z-index: 1000;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.confirmation-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 999;
}
.confirmation-dialog h3 {
color: var(--color-primary);
margin-bottom: var(--spacing-md);
}
.confirmation-dialog p {
margin-bottom: var(--spacing-lg);
}
.confirmation-actions {
display: flex;
gap: var(--spacing-sm);
justify-content: flex-end;
}
.confirmation-actions .btn {
min-width: 100px;
padding: var(--spacing-sm) var(--spacing-md);
}
/* Responsive */
@media (max-width: 768px) {
.pagination {
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.pagination-link {
min-width: 2.25rem;
height: 2.25rem;
padding: 0 var(--spacing-sm);
font-size: 0.9rem;
}
.file-actions {
flex-wrap: wrap;
}
.btn-danger {
width: 100%;
}
}
/* Styles de notification */
.notification {
position: fixed;
bottom: var(--spacing-lg);
right: var(--spacing-lg);
background-color: var(--color-success);
color: white;
padding: var(--spacing-md) var(--spacing-lg);
border-radius: var(--border-radius);
animation: slideIn 0.3s ease-out, fadeOut 0.3s ease-in 2.7s forwards;
z-index: 1000;
pointer-events: none;
}
.notification.error {
background-color: var(--color-error);
}
@keyframes slideIn {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
.share-container {
max-width: 800px;
margin: 0 auto;
}
.share-container h1 {
color: var(--color-primary);
margin-bottom: var(--spacing-sm);
word-break: break-all;
}
.file-meta {
color: var(--color-text-muted);
margin-bottom: var(--spacing-lg);
}
.preview-container {
background-color: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
margin-bottom: var(--spacing-lg);
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.preview-content {
max-width: 100%;
max-height: 600px;
}
.text-preview {
width: 100%;
max-height: 600px;
overflow: auto;
padding: var(--spacing-md);
background-color: var(--color-bg);
color: var(--color-text);
font-family: monospace;
white-space: pre-wrap;
}
.no-preview {
text-align: center;
padding: var(--spacing-lg);
color: var(--color-text-muted);
}
.extension-badge {
background-color: var(--color-bg-alt);
color: var(--color-primary);
padding: var(--spacing-md) var(--spacing-lg);
border-radius: var(--border-radius);
font-size: 2rem;
font-weight: bold;
margin-bottom: var(--spacing-md);
}
.share-actions {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.share-link {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.input-group {
display: flex;
gap: var(--spacing-sm);
}
.input-group input {
flex: 1;
}
/* Styles responsifs */
@media (max-width: 768px) {
.input-group {
flex-direction: column;
}
.input-group .btn {
width: 100%;
}
}
/* Animation de notification */
.notification {
position: fixed;
bottom: var(--spacing-lg);
right: var(--spacing-lg);
background-color: var(--color-success);
color: white;
padding: var(--spacing-md) var(--spacing-lg);
border-radius: var(--border-radius);
animation: slideIn 0.3s ease-out, fadeOut 0.3s ease-in 2.7s forwards;
z-index: 1000;
}
@keyframes slideIn {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}

121
index.php Normal file
View File

@ -0,0 +1,121 @@
<?php
define('CYLA_CORE', true);
require_once 'core.php';
// Rediriger vers l'admin si déjà connecté
if (Cyla::isLoggedIn()) {
header('Location: admin.php');
exit;
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?php echo SITE_NAME; ?> - Accueil</title>
<link rel="icon" type="image/png" href="favicon.png">
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div class="home-container">
<main class="home-hero">
<div class="home-content">
<h1>Cyla</h1>
<p class="home-description">Le disque nuagique personnel d'Esenjin.</p>
<a href="admin.php" class="home-button">
Accéder à l'administration
<svg class="home-button-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
</div>
</main>
<footer class="home-footer">
<p>&copy; <?php echo date('Y'); ?> <?php echo SITE_NAME; ?> - Version <?php echo SITE_VERSION; ?></p>
</footer>
</div>
</body>
</html>
<style>
/* Styles spécifiques à la page d'accueil */
.home-container {
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: var(--color-bg);
}
.home-hero {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-lg);
}
.home-content {
text-align: center;
}
.home-content h1 {
font-size: 4rem;
font-weight: bold;
color: var(--color-primary);
margin-bottom: var(--spacing-md);
}
.home-description {
font-size: 1.5rem;
color: var(--color-text-muted);
margin-bottom: var(--spacing-lg);
}
.home-button {
display: inline-flex;
align-items: center;
padding: var(--spacing-md) var(--spacing-lg);
background-color: var(--color-primary);
color: var(--color-text);
text-decoration: none;
border-radius: var(--border-radius);
font-weight: 500;
transition: background-color 0.2s ease;
}
.home-button:hover {
background-color: var(--color-primary-hover);
}
.home-button-icon {
width: 1.25rem;
height: 1.25rem;
margin-left: var(--spacing-sm);
}
.home-footer {
text-align: center;
padding: var(--spacing-lg);
color: var(--color-text-muted);
background-color: var(--color-bg-alt);
border-top: 1px solid var(--color-border);
}
/* Animation d'entrée */
.home-content {
animation: fadeInUp 0.8s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@ -1,25 +1,16 @@
import React, { useState, useRef } from 'react';
import { X, Upload, CheckCircle, AlertCircle } from 'lucide-react';
import React, { useState, useRef, useCallback } from 'react';
import { X, Upload, CheckCircle, AlertCircle, Loader } from 'lucide-react';
const UploadZone = () => {
const [isDragging, setIsDragging] = useState(false);
const [files, setFiles] = useState([]);
const fileInputRef = useRef(null);
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 Mo
const ALLOWED_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webm', 'mp4', 'wmv', 'mp3', 'flac', 'ogg', 'zip', 'css', 'pdf', 'rar', 'm3u', 'm3u8', 'txt'];
const MAX_FILES = 10; // Limite stricte à 10 fichiers
const MAX_FILE_SIZE = 100 * 1024 * 1024;
const ALLOWED_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'webm', 'mp4', 'wmv', 'mp3', 'flac', 'ogg', 'zip', 'css', 'pdf', 'rar', 'm3u', 'm3u8', 'txt'];
const handleDragOver = (e) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e) => {
e.preventDefault();
setIsDragging(false);
};
const validateFile = (file) => {
const validateFile = useCallback((file) => {
const extension = file.name.split('.').pop().toLowerCase();
if (!ALLOWED_EXTENSIONS.includes(extension)) {
return { valid: false, error: `Extension .${extension} non autorisée` };
@ -28,95 +19,86 @@ const UploadZone = () => {
return { valid: false, error: 'Fichier trop volumineux (max 100 Mo)' };
}
return { valid: true, error: null };
};
const handleDrop = (e) => {
e.preventDefault();
setIsDragging(false);
const droppedFiles = Array.from(e.dataTransfer.files);
addFiles(droppedFiles);
};
const handleFileSelect = (e) => {
const selectedFiles = Array.from(e.target.files);
addFiles(selectedFiles);
};
const addFiles = (newFiles) => {
const processedFiles = newFiles.map(file => {
const validation = validateFile(file);
return {
file,
id: Math.random().toString(36).substring(7),
status: validation.valid ? 'pending' : 'error',
error: validation.error,
progress: 0
};
});
setFiles(currentFiles => [...currentFiles, ...processedFiles]);
};
const removeFile = (fileId) => {
setFiles(files => files.filter(f => f.id !== fileId));
};
}, []);
const uploadFile = async (fileInfo) => {
const formData = new FormData();
formData.append('file', fileInfo.file);
formData.append('action', 'upload');
try {
// Simuler un upload progressif pour la démo
await new Promise(resolve => {
let progress = 0;
const interval = setInterval(() => {
progress += 10;
setFiles(files =>
files.map(f =>
f.id === fileInfo.id
? { ...f, progress, status: progress === 100 ? 'complete' : 'uploading' }
: f
)
);
if (progress >= 100) {
clearInterval(interval);
resolve();
}
}, 500);
const formData = new FormData();
formData.append('file', fileInfo.file);
formData.append('csrf_token', document.querySelector('input[name="csrf_token"]')?.value || '');
// Mettre le statut en uploading
setFiles(files =>
files.map(f =>
f.id === fileInfo.id ? { ...f, status: 'uploading' } : f
)
);
const response = await fetch('api.php', {
method: 'POST',
body: formData
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Erreur lors du téléversement');
}
setFiles(files =>
files.map(f =>
f.id === fileInfo.id ? { ...f, status: 'complete' } : f
)
);
} catch (error) {
setFiles(files =>
files.map(f =>
f.id === fileInfo.id
? { ...f, status: 'error', error: 'Erreur lors du téléversement' }
? { ...f, status: 'error', error: error.message }
: f
)
);
}
};
const uploadAllFiles = () => {
const pendingFiles = files.filter(f => f.status === 'pending');
pendingFiles.forEach(uploadFile);
const handleFiles = (newFiles) => {
// Vérifier si on n'a pas déjà atteint la limite
if (files.length >= MAX_FILES) {
alert(`Vous pouvez téléverser uniquement ${MAX_FILES} fichiers à la fois`);
return;
}
// Calculer combien de fichiers on peut encore ajouter
const remainingSlots = MAX_FILES - files.length;
const filesToAdd = Array.from(newFiles).slice(0, remainingSlots);
if (filesToAdd.length < newFiles.length) {
alert(`Seuls les ${remainingSlots} premiers fichiers seront traités. Maximum ${MAX_FILES} fichiers à la fois.`);
}
const processedFiles = filesToAdd.map(file => ({
file,
id: Math.random().toString(36).substring(7),
status: validateFile(file).valid ? 'pending' : 'error',
error: validateFile(file).error
}));
setFiles(current => [...current, ...processedFiles]);
// Lancer l'upload pour chaque fichier valide
processedFiles
.filter(f => f.status === 'pending')
.forEach(uploadFile);
};
const getStatusColor = (status) => {
switch (status) {
case 'complete': return 'text-green-500';
case 'error': return 'text-red-500';
case 'uploading': return 'text-blue-500';
default: return 'text-gray-500';
}
};
const handleDrop = useCallback((e) => {
e.preventDefault();
setIsDragging(false);
handleFiles(e.dataTransfer.files);
}, []);
const getStatusIcon = (status) => {
switch (status) {
case 'complete': return <CheckCircle className="w-5 h-5" />;
case 'error': return <AlertCircle className="w-5 h-5" />;
default: return null;
}
const removeFile = (id) => {
setFiles(files => files.filter(f => f.id !== id));
};
return (
@ -125,8 +107,14 @@ const UploadZone = () => {
className={`relative border-2 border-dashed rounded-lg p-8 text-center mb-4 transition-colors
${isDragging ? 'border-primary-500 bg-primary-50' : 'border-gray-600'}
hover:border-primary-500`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDragOver={e => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={e => {
e.preventDefault();
setIsDragging(false);
}}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
@ -134,7 +122,7 @@ const UploadZone = () => {
ref={fileInputRef}
type="file"
multiple
onChange={handleFileSelect}
onChange={e => handleFiles(e.target.files)}
className="hidden"
accept={ALLOWED_EXTENSIONS.map(ext => `.${ext}`).join(',')}
/>
@ -147,20 +135,16 @@ const UploadZone = () => {
ou cliquez pour sélectionner des fichiers
</p>
<p className="text-xs text-gray-400 mt-2">
Max 100 Mo par fichier · {ALLOWED_EXTENSIONS.join(', ')}
Maximum {MAX_FILES} fichiers à la fois · 100 Mo par fichier
<br />
Extensions : {ALLOWED_EXTENSIONS.join(', ')}
</p>
</div>
{files.length > 0 && (
<div className="border border-gray-700 rounded-lg overflow-hidden">
<div className="p-4 bg-gray-800 border-b border-gray-700 flex justify-between items-center">
<h3 className="font-medium">Files ({files.length})</h3>
<button
onClick={uploadAllFiles}
className="btn btn-sm bg-primary-500 hover:bg-primary-600 text-white px-4 py-2 rounded"
>
Tout téléverser
</button>
<div className="p-4 bg-gray-800 border-b border-gray-700">
<h3 className="font-medium">Fichiers ({files.length}/{MAX_FILES})</h3>
</div>
<div className="divide-y divide-gray-700">
@ -176,22 +160,20 @@ const UploadZone = () => {
)}
</div>
{fileInfo.status === 'uploading' && (
<div className="w-32 h-2 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-300"
style={{ width: `${fileInfo.progress}%` }}
/>
</div>
)}
<div className={getStatusColor(fileInfo.status)}>
{getStatusIcon(fileInfo.status)}
<div className={`
${fileInfo.status === 'complete' ? 'text-green-500' : ''}
${fileInfo.status === 'error' ? 'text-red-500' : ''}
${fileInfo.status === 'uploading' ? 'text-blue-500' : ''}
`}>
{fileInfo.status === 'complete' && <CheckCircle className="w-5 h-5" />}
{fileInfo.status === 'error' && <AlertCircle className="w-5 h-5" />}
{fileInfo.status === 'uploading' && <Loader className="w-5 h-5 animate-spin" />}
</div>
<button
onClick={() => removeFile(fileInfo.id)}
className="p-1 hover:bg-gray-700 rounded"
title={fileInfo.status === 'uploading' ? 'Annuler' : 'Supprimer'}
>
<X className="w-5 h-5" />
</button>

200
js/password-modal.js Normal file
View File

@ -0,0 +1,200 @@
const ChangePasswordModal = ({ isOpen, onClose, onSubmit, csrfToken }) => {
if (!isOpen) return null;
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const data = Object.fromEntries(formData.entries());
onSubmit(data);
};
return React.createElement('div', {
className: 'modal-overlay',
onClick: (e) => {
if (e.target === e.currentTarget) onClose();
}
},
React.createElement('div', {
className: 'modal-container'
}, [
React.createElement('div', {
key: 'header',
className: 'flex justify-between items-center p-4 border-b border-neutral-700'
}, [
React.createElement('h2', {
className: 'text-xl font-semibold text-rose-400'
}, 'Changer le mot de passe'),
React.createElement('button', {
onClick: onClose,
className: 'btn btn-secondary'
}, '×')
]),
React.createElement('form', {
key: 'form',
onSubmit: handleSubmit,
className: 'p-4 space-y-4'
}, [
React.createElement('input', {
type: 'hidden',
name: 'csrf_token',
value: csrfToken
}),
React.createElement('input', {
type: 'hidden',
name: 'action',
value: 'change_password'
}),
React.createElement('div', {
className: 'space-y-2'
}, [
React.createElement('label', {
className: 'block text-sm text-neutral-400'
}, 'Mot de passe actuel'),
React.createElement('input', {
type: 'password',
name: 'current_password',
required: true,
className: 'w-full p-2 bg-neutral-800 border border-neutral-700 rounded focus:border-rose-500 focus:outline-none'
})
]),
React.createElement('div', {
className: 'space-y-2'
}, [
React.createElement('label', {
className: 'block text-sm text-neutral-400'
}, 'Nouveau mot de passe'),
React.createElement('input', {
type: 'password',
name: 'new_password',
required: true,
className: 'w-full p-2 bg-neutral-800 border border-neutral-700 rounded focus:border-rose-500 focus:outline-none'
})
]),
React.createElement('div', {
className: 'space-y-2'
}, [
React.createElement('label', {
className: 'block text-sm text-neutral-400'
}, 'Confirmer le nouveau mot de passe'),
React.createElement('input', {
type: 'password',
name: 'confirm_password',
required: true,
className: 'w-full p-2 bg-neutral-800 border border-neutral-700 rounded focus:border-rose-500 focus:outline-none'
})
]),
React.createElement('div', {
className: 'flex justify-end gap-2 pt-4'
}, [
React.createElement('button', {
type: 'button',
onClick: onClose,
className: 'btn btn-secondary'
}, 'Annuler'),
React.createElement('button', {
type: 'submit',
className: 'btn'
}, 'Changer le mot de passe')
])
])
])
);
};
// App wrapper
function PasswordModalApp({ csrfToken }) {
const [isOpen, setIsOpen] = React.useState(false);
React.useEffect(() => {
const btn = document.getElementById('changePasswordBtn');
if (btn) {
btn.addEventListener('click', () => {
setIsOpen(true);
document.body.classList.add('modal-open');
});
}
}, []);
const handleClose = () => {
setIsOpen(false);
document.body.classList.remove('modal-open');
};
// Gérer la fermeture avec la touche Echap
React.useEffect(() => {
const handleEscape = (e) => {
if (e.key === 'Escape' && isOpen) {
handleClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen]);
// Ajouter la gestion du clic en dehors de la modale
const handleOutsideClick = (e) => {
if (e.target === e.currentTarget) {
handleClose();
}
};
return React.createElement(ChangePasswordModal, {
isOpen,
onClose: handleClose,
onSubmit: handleSubmit,
csrfToken: csrfToken,
onOverlayClick: handleOutsideClick
});
}
// Initialisation
document.addEventListener('DOMContentLoaded', () => {
const root = ReactDOM.createRoot(document.getElementById('passwordModalRoot'));
root.render(React.createElement(PasswordModalApp, {
csrfToken: window.CSRF_TOKEN
}));
});
// Modifier la fonction PasswordModalApp pour accepter le token en props
function PasswordModalApp({ csrfToken }) {
const [isOpen, setIsOpen] = React.useState(false);
React.useEffect(() => {
const btn = document.getElementById('changePasswordBtn');
if (btn) {
btn.addEventListener('click', () => setIsOpen(true));
}
}, []);
const handleSubmit = async (formData) => {
try {
const form = new FormData();
Object.entries(formData).forEach(([key, value]) => {
form.append(key, value);
});
const response = await fetch('admin.php', {
method: 'POST',
body: form
});
if (response.ok) {
alert('Mot de passe modifié avec succès');
setIsOpen(false);
} else {
alert('Erreur lors du changement de mot de passe');
}
} catch (error) {
console.error('Error:', error);
alert('Erreur lors du changement de mot de passe');
}
};
return React.createElement(ChangePasswordModal, {
isOpen,
onClose: () => setIsOpen(false),
onSubmit: handleSubmit,
csrfToken: csrfToken
});
}

View File

@ -14,19 +14,27 @@ if (!defined('CYLA_CORE')) {
<link rel="stylesheet" href="css/style.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel/7.22.5/babel.min.js"></script>
</head>
<body>
<div id="passwordModalRoot"></div>
<header>
<nav>
<div class="nav-brand">
<a href="index.php"><?php echo SITE_NAME; ?></a>
</div>
<?php if (Cyla::isLoggedIn()): ?>
<div class="nav-menu">
<a href="admin.php" class="nav-link">Administration</a>
<a href="logout.php?csrf_token=<?php echo Cyla::generateCSRFToken(); ?>" class="nav-link">Déconnexion</a>
</div>
<div class="nav-menu">
<a href="admin.php" class="nav-link">Administration</a>
<button
type="button"
id="changePasswordBtn"
class="nav-link"
onclick="console.log('Clic natif détecté')"
>
Changer le mot de passe
</button>
<a href="logout.php?csrf_token=<?php echo Cyla::generateCSRFToken(); ?>" class="nav-link">Déconnexion</a>
</div>
<?php endif; ?>
</nav>
</header>

211
share.php
View File

@ -10,25 +10,60 @@ if (!isset($_GET['file'])) {
$error = 'Aucun fichier spécifié';
} else {
$filename = $_GET['file'];
$filepath = UPLOAD_DIR . $filename;
$path = $_GET['path'] ?? 'fichiers/';
// Vérifier si le fichier existe
if (!file_exists($filepath)) {
// Déterminer le chemin complet selon le dossier
$basePath = '';
switch ($path) {
case 'v1/img/fichiers/':
$basePath = __DIR__ . '/v1/img/fichiers/';
break;
case 'v2/file/':
$basePath = __DIR__ . '/v2/file/';
break;
default:
$basePath = UPLOAD_DIR;
$path = 'fichiers/'; // Assurer que le path par défaut est correct
}
$filepath = $basePath . $filename;
// Vérifier que le fichier existe et est un fichier régulier
if (!file_exists($filepath) || !is_file($filepath)) {
$error = 'Fichier introuvable';
} else {
// Récupérer les informations du fichier
$file_info = [
'name' => $filename,
'size' => filesize($filepath),
'extension' => strtolower(pathinfo($filename, PATHINFO_EXTENSION)),
'preview_type' => getPreviewType(pathinfo($filename, PATHINFO_EXTENSION))
];
// Vérifier que le chemin est sécurisé
$realpath = realpath($filepath);
$allowed = false;
if (strpos($realpath, realpath(UPLOAD_DIR)) === 0) {
$allowed = true;
} else {
foreach (LEGACY_UPLOAD_DIRS as $dir) {
if (strpos($realpath, realpath($dir)) === 0) {
$allowed = true;
break;
}
}
}
if (!$allowed) {
$error = 'Accès non autorisé';
} else {
// Récupérer les informations du fichier
$file_info = [
'name' => $filename,
'size' => filesize($filepath),
'extension' => strtolower(pathinfo($filename, PATHINFO_EXTENSION)),
'preview_type' => getPreviewType(pathinfo($filename, PATHINFO_EXTENSION))
];
}
}
}
// Construction de l'URL absolue du fichier
$file_url = SITE_URL . 'fichiers/' . ($file_info ? rawurlencode($file_info['name']) : '');
$share_url = SITE_URL . 'share.php?file=' . ($file_info ? rawurlencode($file_info['name']) : '');
// Construction des URLs
$file_url = SITE_URL . $path . ($file_info ? rawurlencode($file_info['name']) : '');
$share_url = SITE_URL . 'share.php?file=' . ($file_info ? rawurlencode($file_info['name']) : '') . '&path=' . urlencode($path);
// Contenu de la page
$pageTitle = $file_info ? $file_info['name'] : 'Fichier introuvable';
@ -71,8 +106,13 @@ ob_start(); ?>
</audio>
<?php elseif ($file_info['preview_type'] === 'text'): ?>
<pre class="text-preview"><?php
// Lire et afficher le contenu du fichier texte de manière sécurisée
$content = file_get_contents($filepath);
echo Cyla::escape($content);
if ($content !== false) {
echo Cyla::escape($content);
} else {
echo "Erreur lors de la lecture du fichier";
}
?></pre>
<?php else: ?>
<div class="no-preview">
@ -92,7 +132,7 @@ ob_start(); ?>
id="share-url"
value="<?php echo Cyla::escape($share_url); ?>"
readonly>
<button class="btn" onclick="copyToClipboard('share-url', 'Lien de partage copié !')">
<button class="btn" onclick="handleCopy('share-url', 'Lien de partage copié !')">
Copier
</button>
</div>
@ -105,7 +145,7 @@ ob_start(); ?>
id="direct-url"
value="<?php echo Cyla::escape($file_url); ?>"
readonly>
<button class="btn" onclick="copyToClipboard('direct-url', 'Lien direct copié !')">
<button class="btn" onclick="handleCopy('direct-url', 'Lien direct copié !')">
Copier
</button>
</div>
@ -126,7 +166,7 @@ ob_start(); ?>
}
?>"
readonly>
<button class="btn" onclick="copyToClipboard('embed-code', 'Code d\'intégration copié !')">
<button class="btn" onclick="handleCopy('embed-code', 'Code d\'intégration copié !')">
Copier
</button>
</div>
@ -142,132 +182,18 @@ ob_start(); ?>
</div>
</div>
<style>
/* Styles spécifiques à la page de partage */
.share-container {
max-width: 800px;
margin: 0 auto;
}
.share-container h1 {
color: var(--color-primary);
margin-bottom: var(--spacing-sm);
word-break: break-all;
}
.file-meta {
color: var(--color-text-muted);
margin-bottom: var(--spacing-lg);
}
.preview-container {
background-color: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
margin-bottom: var(--spacing-lg);
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.preview-content {
max-width: 100%;
max-height: 600px;
}
.text-preview {
width: 100%;
max-height: 600px;
overflow: auto;
padding: var(--spacing-md);
background-color: var(--color-bg);
color: var(--color-text);
font-family: monospace;
white-space: pre-wrap;
}
.no-preview {
text-align: center;
padding: var(--spacing-lg);
color: var(--color-text-muted);
}
.extension-badge {
background-color: var(--color-bg-alt);
color: var(--color-primary);
padding: var(--spacing-md) var(--spacing-lg);
border-radius: var(--border-radius);
font-size: 2rem;
font-weight: bold;
margin-bottom: var(--spacing-md);
}
.share-actions {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.share-link {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.input-group {
display: flex;
gap: var(--spacing-sm);
}
.input-group input {
flex: 1;
}
/* Styles responsifs */
@media (max-width: 768px) {
.input-group {
flex-direction: column;
}
.input-group .btn {
width: 100%;
}
}
/* Animation de notification */
.notification {
position: fixed;
bottom: var(--spacing-lg);
right: var(--spacing-lg);
background-color: var(--color-success);
color: white;
padding: var(--spacing-md) var(--spacing-lg);
border-radius: var(--border-radius);
animation: slideIn 0.3s ease-out, fadeOut 0.3s ease-in 2.7s forwards;
z-index: 1000;
}
@keyframes slideIn {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
</style>
<script>
// Fonction pour copier dans le presse-papier avec notification
function copyToClipboard(elementId, message) {
const element = document.getElementById(elementId);
element.select();
// Fonction pour gérer la copie
async function handleCopy(elementId, message) {
const input = document.getElementById(elementId);
if (!input) {
console.error(`Element with id ${elementId} not found`);
return;
}
try {
document.execCommand('copy');
// Utiliser l'API Clipboard moderne
await navigator.clipboard.writeText(input.value);
// Créer et afficher la notification
const notification = document.createElement('div');
@ -283,9 +209,6 @@ function copyToClipboard(elementId, message) {
console.error('Erreur lors de la copie :', err);
alert('Erreur lors de la copie');
}
// Désélectionner le texte
element.blur();
}
</script>
<?php endif; ?>