Compare commits
23 Commits
Author | SHA1 | Date | |
---|---|---|---|
dd2913337f | |||
6ec572ef0c | |||
d5754b0277 | |||
a625ee63d3 | |||
dd154c632d | |||
31d611267a | |||
313edb9c28 | |||
b7e7c54ecf | |||
3660e796ee | |||
2a6ae05837 | |||
e14b0a3478 | |||
85d5c28a1b | |||
c99146c38a | |||
fd134917f0 | |||
a15d2260d1 | |||
dced7ae0db | |||
5727c54b1c | |||
f9001122d9 | |||
6887b41fcc | |||
38b7596569 | |||
27076900bb | |||
117999c60a | |||
f2acf10fab |
@ -12,6 +12,8 @@ Une plateforme web simple et élégante pour la publication et la lecture de rom
|
||||
- Stockage JSON pour une maintenance simplifiée
|
||||
- Système d'upload d'images avec redimensionnement automatique
|
||||
- Gestion des métadonnées (date de création, mise à jour, etc.)
|
||||
- Import/Export des romans
|
||||
- Gestion du profil et des options du site
|
||||
|
||||

|
||||

|
||||
|
161
about.php
Normal file
161
about.php
Normal file
@ -0,0 +1,161 @@
|
||||
<?php
|
||||
require_once 'includes/config.php';
|
||||
require_once 'includes/Stats.php';
|
||||
|
||||
$config = Config::load();
|
||||
$about = $config['about'] ?? [
|
||||
'title' => 'À propos',
|
||||
'content' => '',
|
||||
'background' => ''
|
||||
];
|
||||
|
||||
// Charger les statistiques
|
||||
$stats = new Stats();
|
||||
$siteStats = $stats->getStats();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars($about['title']) ?> - <?= htmlspecialchars($config['site']['name']) ?></title>
|
||||
|
||||
<?php if (file_exists(__DIR__ . '/assets/images/site/favicon.png')): ?>
|
||||
<link rel="icon" type="image/png" href="assets/images/site/favicon.png">
|
||||
<?php endif; ?>
|
||||
|
||||
<link rel="stylesheet" href="assets/css/public.css">
|
||||
<link rel="stylesheet" href="assets/css/content.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- En-tête avec image de fond -->
|
||||
<header class="novel-header">
|
||||
<?php if (!empty($about['background'])): ?>
|
||||
<div class="novel-header-background" style="background-image: url('<?= htmlspecialchars($about['background']) ?>');"></div>
|
||||
<?php endif; ?>
|
||||
<h1><?= htmlspecialchars($about['title']) ?></h1>
|
||||
</header>
|
||||
|
||||
<!-- Contenu principal -->
|
||||
<div class="about-content">
|
||||
<div class="about-description">
|
||||
<?= $about['content'] = Config::fixImagePaths($about['content']); ?>
|
||||
</div>
|
||||
|
||||
<aside class="sidebar">
|
||||
<!-- Liens personnalisés -->
|
||||
<?php if (!empty($about['links'])): ?>
|
||||
<section class="sidebar-section links-section">
|
||||
<h2>Liens</h2>
|
||||
<ul class="custom-links-list">
|
||||
<?php foreach ($about['links'] as $link): ?>
|
||||
<li>
|
||||
<a href="<?= htmlspecialchars($link['url']) ?>"
|
||||
<?= !empty($link['target']) ? 'target="' . htmlspecialchars($link['target']) . '"' : '' ?>>
|
||||
<?= htmlspecialchars($link['title']) ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Statistiques -->
|
||||
<section class="sidebar-section stats-section">
|
||||
<h2>Statistiques</h2>
|
||||
<ul class="stats-list">
|
||||
<!-- Nombre total de romans -->
|
||||
<li class="stats-item">
|
||||
<div class="stats-label">Romans publiés</div>
|
||||
<div class="stats-value"><?= Stats::formatNumber($siteStats['total_stories']) ?></div>
|
||||
</li>
|
||||
|
||||
<!-- Nombre total de chapitres -->
|
||||
<li class="stats-item">
|
||||
<div class="stats-label">Chapitres écrits</div>
|
||||
<div class="stats-value"><?= Stats::formatNumber($siteStats['total_chapters']) ?></div>
|
||||
<div class="stats-detail">
|
||||
Moyenne de <?= $siteStats['avg_chapters_per_story'] ?> chapitres par roman
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- Nombre total de mots -->
|
||||
<li class="stats-item">
|
||||
<div class="stats-label">Mots écrits</div>
|
||||
<div class="stats-value"><?= Stats::formatNumber($siteStats['total_words']) ?></div>
|
||||
<div class="stats-detail">
|
||||
Moyenne de <?= Stats::formatNumber($siteStats['avg_words_per_chapter']) ?> mots par chapitre
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- Roman avec le plus de chapitres -->
|
||||
<?php if ($siteStats['most_chapters']['story']): ?>
|
||||
<li class="stats-item">
|
||||
<div class="stats-label">Plus long roman</div>
|
||||
<div class="stats-value"><?= Stats::formatNumber($siteStats['most_chapters']['count']) ?> chapitres</div>
|
||||
<div class="stats-detail">
|
||||
<a href="roman.php?id=<?= htmlspecialchars($siteStats['most_chapters']['story']['id']) ?>">
|
||||
<?= htmlspecialchars($siteStats['most_chapters']['story']['title']) ?>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Plus long chapitre -->
|
||||
<?php if ($siteStats['longest_chapter']['story']): ?>
|
||||
<li class="stats-item">
|
||||
<div class="stats-label">Plus long chapitre</div>
|
||||
<div class="stats-value"><?= Stats::formatNumber($siteStats['longest_chapter']['words']) ?> mots</div>
|
||||
<div class="stats-detail">
|
||||
<a href="roman.php?id=<?= htmlspecialchars($siteStats['longest_chapter']['story']['id']) ?>">
|
||||
<?= htmlspecialchars($siteStats['longest_chapter']['story']['title']) ?>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Dernière mise à jour -->
|
||||
<?php if ($siteStats['latest_update']['story']): ?>
|
||||
<li class="stats-item">
|
||||
<div class="stats-label">Dernière mise à jour</div>
|
||||
<div class="stats-value"><?= Stats::formatDate($siteStats['latest_update']['date']) ?></div>
|
||||
<div class="stats-detail">
|
||||
<a href="roman.php?id=<?= htmlspecialchars($siteStats['latest_update']['story']['id']) ?>">
|
||||
<?= htmlspecialchars($siteStats['latest_update']['story']['title']) ?>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="back-to-home">
|
||||
<a href="index.php">← Retour à l'accueil</a>
|
||||
</div>
|
||||
|
||||
<button class="scroll-top" aria-label="Retour en haut de page">↑</button>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const scrollTopBtn = document.querySelector('.scroll-top');
|
||||
|
||||
window.addEventListener('scroll', function() {
|
||||
if (window.pageYOffset > 300) {
|
||||
scrollTopBtn.classList.add('visible');
|
||||
} else {
|
||||
scrollTopBtn.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
scrollTopBtn.addEventListener('click', function() {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
194
admin/api/about-image-upload.php
Normal file
194
admin/api/about-image-upload.php
Normal file
@ -0,0 +1,194 @@
|
||||
<?php
|
||||
require_once '../../includes/config.php';
|
||||
require_once '../../includes/auth.php';
|
||||
|
||||
class AboutImageUploadHandler {
|
||||
private $uploadDir;
|
||||
private $allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
private $maxFileSize = 5242880; // 5MB
|
||||
private $maxWidth = 1200;
|
||||
private $maxHeight = 1200;
|
||||
|
||||
public function __construct() {
|
||||
$this->uploadDir = __DIR__ . '/../../assets/images/about/';
|
||||
$this->ensureUploadDirectory();
|
||||
}
|
||||
|
||||
public function handleUpload($file) {
|
||||
try {
|
||||
// Vérifications de base
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
throw new Exception($this->getUploadErrorMessage($file['error']));
|
||||
}
|
||||
|
||||
// Vérification du type MIME
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mimeType = $finfo->file($file['tmp_name']);
|
||||
if (!in_array($mimeType, $this->allowedTypes)) {
|
||||
throw new Exception('Type de fichier non autorisé. Types acceptés : JPG, PNG, GIF, WEBP');
|
||||
}
|
||||
|
||||
// Vérification de la taille
|
||||
if ($file['size'] > $this->maxFileSize) {
|
||||
throw new Exception('Fichier trop volumineux. Taille maximum : 5MB');
|
||||
}
|
||||
|
||||
// Vérification et redimensionnement de l'image
|
||||
[$width, $height, $type] = getimagesize($file['tmp_name']);
|
||||
$needsResize = $width > $this->maxWidth || $height > $this->maxHeight;
|
||||
|
||||
// Génération d'un nom de fichier unique
|
||||
$extension = $this->getExtensionFromMimeType($mimeType);
|
||||
$filename = uniqid() . '.' . $extension;
|
||||
$targetPath = $this->uploadDir . $filename;
|
||||
|
||||
if ($needsResize) {
|
||||
// Calcul des nouvelles dimensions en conservant le ratio
|
||||
$ratio = min($this->maxWidth / $width, $this->maxHeight / $height);
|
||||
$newWidth = round($width * $ratio);
|
||||
$newHeight = round($height * $ratio);
|
||||
|
||||
// Création de la nouvelle image
|
||||
$sourceImage = $this->createImageFromFile($file['tmp_name'], $mimeType);
|
||||
$newImage = imagecreatetruecolor($newWidth, $newHeight);
|
||||
|
||||
// Préservation de la transparence pour PNG
|
||||
if ($mimeType === 'image/png') {
|
||||
imagealphablending($newImage, false);
|
||||
imagesavealpha($newImage, true);
|
||||
}
|
||||
|
||||
// Redimensionnement
|
||||
imagecopyresampled(
|
||||
$newImage, $sourceImage,
|
||||
0, 0, 0, 0,
|
||||
$newWidth, $newHeight,
|
||||
$width, $height
|
||||
);
|
||||
|
||||
// Sauvegarde de l'image redimensionnée
|
||||
$this->saveImage($newImage, $targetPath, $mimeType);
|
||||
|
||||
// Libération de la mémoire
|
||||
imagedestroy($sourceImage);
|
||||
imagedestroy($newImage);
|
||||
} else {
|
||||
// Déplacement du fichier original si pas besoin de redimensionnement
|
||||
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
|
||||
throw new Exception('Erreur lors du déplacement du fichier uploadé');
|
||||
}
|
||||
}
|
||||
|
||||
// Retourner le chemin relatif pour l'éditeur
|
||||
return [
|
||||
'success' => true,
|
||||
'url' => $filename,
|
||||
'storage_url' => 'assets/images/about/' . $filename,
|
||||
'width' => $needsResize ? $newWidth : $width,
|
||||
'height' => $needsResize ? $newHeight : $height
|
||||
];
|
||||
|
||||
} catch (Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureUploadDirectory() {
|
||||
if (!file_exists($this->uploadDir)) {
|
||||
if (!mkdir($this->uploadDir, 0755, true)) {
|
||||
throw new Exception('Impossible de créer le dossier d\'upload');
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_writable($this->uploadDir)) {
|
||||
throw new Exception('Le dossier d\'upload n\'est pas accessible en écriture');
|
||||
}
|
||||
}
|
||||
|
||||
private function createImageFromFile($file, $mimeType) {
|
||||
switch ($mimeType) {
|
||||
case 'image/jpeg':
|
||||
return imagecreatefromjpeg($file);
|
||||
case 'image/png':
|
||||
return imagecreatefrompng($file);
|
||||
case 'image/gif':
|
||||
return imagecreatefromgif($file);
|
||||
case 'image/webp':
|
||||
return imagecreatefromwebp($file);
|
||||
default:
|
||||
throw new Exception('Type d\'image non supporté');
|
||||
}
|
||||
}
|
||||
|
||||
private function saveImage($image, $path, $mimeType) {
|
||||
switch ($mimeType) {
|
||||
case 'image/jpeg':
|
||||
return imagejpeg($image, $path, 85);
|
||||
case 'image/png':
|
||||
return imagepng($image, $path, 8);
|
||||
case 'image/gif':
|
||||
return imagegif($image, $path);
|
||||
case 'image/webp':
|
||||
return imagewebp($image, $path, 85);
|
||||
default:
|
||||
throw new Exception('Type d\'image non supporté pour la sauvegarde');
|
||||
}
|
||||
}
|
||||
|
||||
private function getExtensionFromMimeType($mimeType) {
|
||||
$map = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp'
|
||||
];
|
||||
return $map[$mimeType] ?? 'jpg';
|
||||
}
|
||||
|
||||
private function getUploadErrorMessage($error) {
|
||||
$errors = [
|
||||
UPLOAD_ERR_INI_SIZE => 'Le fichier dépasse la taille maximale autorisée par PHP',
|
||||
UPLOAD_ERR_FORM_SIZE => 'Le fichier dépasse la taille maximale autorisée par le formulaire',
|
||||
UPLOAD_ERR_PARTIAL => 'Le fichier n\'a été que partiellement uploadé',
|
||||
UPLOAD_ERR_NO_FILE => 'Aucun fichier n\'a été uploadé',
|
||||
UPLOAD_ERR_NO_TMP_DIR => 'Dossier temporaire manquant',
|
||||
UPLOAD_ERR_CANT_WRITE => 'Échec de l\'écriture du fichier sur le disque',
|
||||
UPLOAD_ERR_EXTENSION => 'Une extension PHP a arrêté l\'upload'
|
||||
];
|
||||
return $errors[$error] ?? 'Erreur inconnue lors de l\'upload';
|
||||
}
|
||||
}
|
||||
|
||||
// Point d'entrée du script
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
exit(json_encode(['error' => 'Méthode non autorisée']));
|
||||
}
|
||||
|
||||
// Vérification de l'authentification
|
||||
if (!Auth::check()) {
|
||||
http_response_code(401);
|
||||
exit(json_encode(['error' => 'Non autorisé']));
|
||||
}
|
||||
|
||||
// Traitement de l'upload
|
||||
try {
|
||||
$handler = new AboutImageUploadHandler();
|
||||
$result = $handler->handleUpload($_FILES['image']);
|
||||
|
||||
if (!$result['success']) {
|
||||
http_response_code(400);
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($result);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Erreur serveur : ' . $e->getMessage()
|
||||
]);
|
||||
}
|
125
admin/api/clean-media.php
Normal file
125
admin/api/clean-media.php
Normal file
@ -0,0 +1,125 @@
|
||||
<?php
|
||||
require_once '../../includes/config.php';
|
||||
require_once '../../includes/auth.php';
|
||||
require_once '../../includes/stories.php';
|
||||
|
||||
// Vérification de l'authentification
|
||||
if (!Auth::check()) {
|
||||
http_response_code(401);
|
||||
exit(json_encode(['error' => 'Non autorisé']));
|
||||
}
|
||||
|
||||
function extractImagePaths($content) {
|
||||
$paths = [];
|
||||
|
||||
// Si le contenu est du JSON (format Delta de Quill)
|
||||
if (is_string($content) && isJson($content)) {
|
||||
$delta = json_decode($content, true);
|
||||
if (isset($delta['ops'])) {
|
||||
foreach ($delta['ops'] as $op) {
|
||||
if (isset($op['insert']['image'])) {
|
||||
$paths[] = normalizeImagePath($op['insert']['image']);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Si le contenu est du HTML
|
||||
preg_match_all('/src=["\']([^"\']+)["\']/', $content, $matches);
|
||||
if (!empty($matches[1])) {
|
||||
foreach ($matches[1] as $path) {
|
||||
$paths[] = normalizeImagePath($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $paths;
|
||||
}
|
||||
|
||||
function normalizeImagePath($path) {
|
||||
// Supprimer les "../" au début du chemin
|
||||
$path = preg_replace('/^(?:\.\.\/)+/', '', $path);
|
||||
return $path;
|
||||
}
|
||||
|
||||
function isJson($string) {
|
||||
json_decode($string);
|
||||
return json_last_error() === JSON_ERROR_NONE;
|
||||
}
|
||||
|
||||
try {
|
||||
$unusedFiles = [];
|
||||
$usedFiles = [];
|
||||
$totalSpace = 0;
|
||||
$freedSpace = 0;
|
||||
|
||||
// Collecter tous les fichiers dans le dossier chapters
|
||||
$chaptersDir = __DIR__ . '/../../assets/images/chapters/';
|
||||
$allFiles = [];
|
||||
|
||||
foreach (glob($chaptersDir . '*', GLOB_ONLYDIR) as $storyDir) {
|
||||
$storyId = basename($storyDir);
|
||||
foreach (glob($storyDir . '/*') as $file) {
|
||||
if (is_file($file)) {
|
||||
$relativePath = 'assets/images/chapters/' . $storyId . '/' . basename($file);
|
||||
$allFiles[$relativePath] = $file;
|
||||
$totalSpace += filesize($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parcourir tous les romans et leurs chapitres
|
||||
$stories = Stories::getAll();
|
||||
foreach ($stories as $story) {
|
||||
// Vérifier la description du roman
|
||||
if (!empty($story['description'])) {
|
||||
$usedFiles = array_merge($usedFiles, extractImagePaths($story['description']));
|
||||
}
|
||||
|
||||
// Vérifier les chapitres
|
||||
if (!empty($story['chapters'])) {
|
||||
foreach ($story['chapters'] as $chapter) {
|
||||
if (!empty($chapter['content'])) {
|
||||
$usedFiles = array_merge($usedFiles, extractImagePaths($chapter['content']));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Identifier les fichiers non utilisés
|
||||
foreach ($allFiles as $relativePath => $fullPath) {
|
||||
if (!in_array($relativePath, $usedFiles)) {
|
||||
$unusedFiles[] = [
|
||||
'path' => $relativePath,
|
||||
'size' => filesize($fullPath)
|
||||
];
|
||||
$freedSpace += filesize($fullPath);
|
||||
|
||||
// Supprimer le fichier
|
||||
unlink($fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Nettoyer les dossiers vides
|
||||
foreach (glob($chaptersDir . '*', GLOB_ONLYDIR) as $storyDir) {
|
||||
if (count(glob("$storyDir/*")) === 0) {
|
||||
rmdir($storyDir);
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'stats' => [
|
||||
'filesRemoved' => count($unusedFiles),
|
||||
'totalSpace' => $totalSpace,
|
||||
'freedSpace' => $freedSpace,
|
||||
'details' => $unusedFiles
|
||||
]
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
}
|
@ -37,18 +37,32 @@ try {
|
||||
$storyDir = $tempDir . '/' . $storyId;
|
||||
mkdir($storyDir);
|
||||
mkdir($storyDir . '/images');
|
||||
mkdir($storyDir . '/chapter_covers');
|
||||
|
||||
// Copier l'image de couverture si elle existe
|
||||
// Copier l'image de couverture du roman si elle existe
|
||||
if (!empty($story['cover'])) {
|
||||
$coverPath = __DIR__ . '/../../' . $story['cover'];
|
||||
if (file_exists($coverPath)) {
|
||||
copy($coverPath, $storyDir . '/cover' . pathinfo($coverPath, PATHINFO_EXTENSION));
|
||||
$story['cover'] = 'cover' . pathinfo($coverPath, PATHINFO_EXTENSION);
|
||||
$extension = '.' . pathinfo($coverPath, PATHINFO_EXTENSION);
|
||||
copy($coverPath, $storyDir . '/cover' . $extension);
|
||||
$story['cover'] = 'cover' . $extension;
|
||||
}
|
||||
}
|
||||
|
||||
// Extraire et copier les images des chapitres
|
||||
foreach ($story['chapters'] as &$chapter) {
|
||||
// Gestion des images de couverture des chapitres
|
||||
if (!empty($chapter['cover'])) {
|
||||
$chapterCoverPath = __DIR__ . '/../../' . $chapter['cover'];
|
||||
if (file_exists($chapterCoverPath)) {
|
||||
$extension = '.' . pathinfo($chapterCoverPath, PATHINFO_EXTENSION);
|
||||
$newCoverName = 'chapter_' . $chapter['id'] . '_cover' . $extension;
|
||||
copy($chapterCoverPath, $storyDir . '/chapter_covers/' . $newCoverName);
|
||||
$chapter['cover'] = 'chapter_covers/' . $newCoverName;
|
||||
}
|
||||
}
|
||||
|
||||
// Gestion du contenu et des images intégrées
|
||||
if (!empty($chapter['content'])) {
|
||||
$content = $chapter['content'];
|
||||
if (is_string($content) && isJson($content)) {
|
||||
@ -62,7 +76,8 @@ try {
|
||||
$imgUrl = $op['insert']['image'];
|
||||
$imgPath = __DIR__ . '/../../' . preg_replace('/^(?:\.\.\/)+/', '', $imgUrl);
|
||||
if (file_exists($imgPath)) {
|
||||
$newImgName = 'image_' . uniqid() . pathinfo($imgPath, PATHINFO_EXTENSION);
|
||||
$extension = '.' . pathinfo($imgPath, PATHINFO_EXTENSION);
|
||||
$newImgName = 'image_' . uniqid() . $extension;
|
||||
copy($imgPath, $storyDir . '/images/' . $newImgName);
|
||||
$op['insert']['image'] = 'images/' . $newImgName;
|
||||
}
|
||||
|
@ -41,6 +41,11 @@ try {
|
||||
$chapter['title'] = '';
|
||||
}
|
||||
|
||||
// S'assurer que le champ cover existe
|
||||
if (!isset($chapter['cover'])) {
|
||||
$chapter['cover'] = null;
|
||||
}
|
||||
|
||||
// Conversion du contenu HTML en format Delta de Quill si nécessaire
|
||||
if (!isset($chapter['content'])) {
|
||||
$chapter['content'] = '{"ops":[{"insert":"\n"}]}';
|
||||
|
@ -66,25 +66,65 @@ try {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Créer les dossiers nécessaires
|
||||
$coverDir = __DIR__ . '/../../assets/images/covers/';
|
||||
$chaptersImgDir = __DIR__ . '/../../assets/images/chapters/' . $storyId;
|
||||
// Créer les dossiers nécessaires avec les permissions correctes
|
||||
$baseDir = __DIR__ . '/../../';
|
||||
$coverDir = $baseDir . 'assets/images/covers';
|
||||
$chaptersImgDir = $baseDir . 'assets/images/chapters/' . $storyId;
|
||||
$chaptersCoverDir = $baseDir . 'assets/images/chapters/' . $storyId . '/covers';
|
||||
|
||||
if (!file_exists($coverDir)) mkdir($coverDir, 0755, true);
|
||||
if (!file_exists($chaptersImgDir)) mkdir($chaptersImgDir, 0755, true);
|
||||
foreach ([$coverDir, $chaptersImgDir, $chaptersCoverDir] as $dir) {
|
||||
if (!file_exists($dir)) {
|
||||
if (!mkdir($dir, 0777, true)) {
|
||||
throw new Exception("Impossible de créer le dossier: $dir");
|
||||
}
|
||||
chmod($dir, 0777);
|
||||
}
|
||||
}
|
||||
|
||||
// Gérer l'image de couverture
|
||||
// Gérer l'image de couverture du roman
|
||||
if (!empty($story['cover'])) {
|
||||
$coverFile = $storyDir . '/' . $story['cover'];
|
||||
if (file_exists($coverFile)) {
|
||||
$newCoverPath = 'assets/images/covers/' . $storyId . '.' . pathinfo($coverFile, PATHINFO_EXTENSION);
|
||||
copy($coverFile, __DIR__ . '/../../' . $newCoverPath);
|
||||
$story['cover'] = $newCoverPath;
|
||||
// Extraire l'extension correctement
|
||||
$originalName = basename($coverFile);
|
||||
if (preg_match('/\.([^.]+)$/', $originalName, $matches)) {
|
||||
$extension = $matches[1];
|
||||
$newCoverPath = 'assets/images/covers/' . $storyId . '.' . $extension;
|
||||
$targetPath = $baseDir . $newCoverPath;
|
||||
|
||||
if (!copy($coverFile, $targetPath)) {
|
||||
throw new Exception("Impossible de copier la couverture du roman");
|
||||
}
|
||||
chmod($targetPath, 0777);
|
||||
$story['cover'] = $newCoverPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gérer les images des chapitres
|
||||
foreach ($story['chapters'] as &$chapter) {
|
||||
// Gérer les images de couverture des chapitres
|
||||
if (!empty($chapter['cover'])) {
|
||||
$chapterCoverFile = $storyDir . '/' . $chapter['cover'];
|
||||
if (file_exists($chapterCoverFile)) {
|
||||
// Extraire l'extension correctement
|
||||
$originalName = basename($chapterCoverFile);
|
||||
if (preg_match('/\.([^.]+)$/', $originalName, $matches)) {
|
||||
$extension = $matches[1];
|
||||
$newChapterCoverName = $chapter['id'] . '-cover.' . $extension;
|
||||
$newChapterCoverPath = 'assets/images/chapters/' . $storyId . '/covers/' . $newChapterCoverName;
|
||||
$targetPath = $baseDir . $newChapterCoverPath;
|
||||
|
||||
if (!copy($chapterCoverFile, $targetPath)) {
|
||||
throw new Exception("Impossible de copier la couverture du chapitre");
|
||||
}
|
||||
chmod($targetPath, 0777);
|
||||
$chapter['cover'] = $newChapterCoverPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gérer le contenu et les images intégrées
|
||||
if (!empty($chapter['content'])) {
|
||||
$content = $chapter['content'];
|
||||
if (is_string($content) && isJson($content)) {
|
||||
@ -96,9 +136,18 @@ try {
|
||||
if (is_array($op['insert']) && isset($op['insert']['image'])) {
|
||||
$imgPath = $storyDir . '/' . $op['insert']['image'];
|
||||
if (file_exists($imgPath)) {
|
||||
$newImgName = 'image_' . uniqid() . '.' . pathinfo($imgPath, PATHINFO_EXTENSION);
|
||||
copy($imgPath, $chaptersImgDir . '/' . $newImgName);
|
||||
$op['insert']['image'] = 'assets/images/chapters/' . $storyId . '/' . $newImgName;
|
||||
$originalName = basename($imgPath);
|
||||
if (preg_match('/\.([^.]+)$/', $originalName, $matches)) {
|
||||
$extension = $matches[1];
|
||||
$newImgName = 'image_' . uniqid() . '.' . $extension;
|
||||
$targetPath = $chaptersImgDir . '/' . $newImgName;
|
||||
|
||||
if (!copy($imgPath, $targetPath)) {
|
||||
throw new Exception("Impossible de copier l'image intégrée");
|
||||
}
|
||||
chmod($targetPath, 0777);
|
||||
$op['insert']['image'] = 'assets/images/chapters/' . $storyId . '/' . $newImgName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -108,10 +157,14 @@ try {
|
||||
}
|
||||
|
||||
// Sauvegarder le roman
|
||||
Stories::save($story);
|
||||
if (!Stories::save($story)) {
|
||||
throw new Exception("Erreur lors de la sauvegarde de {$story['title']}");
|
||||
}
|
||||
$importedCount++;
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Erreur d'import pour {$storyInfo['title']}: " . $e->getMessage());
|
||||
error_log("Trace complète: " . print_r($e->getTraceAsString(), true));
|
||||
$errors[] = "Erreur pour {$storyInfo['title']}: " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
@ -140,6 +193,9 @@ try {
|
||||
exit;
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Erreur générale d'import: " . $e->getMessage());
|
||||
error_log("Trace complète: " . print_r($e->getTraceAsString(), true));
|
||||
|
||||
if (isset($tempDir) && file_exists($tempDir)) {
|
||||
deleteDir($tempDir);
|
||||
}
|
||||
|
@ -12,7 +12,7 @@ $input = json_decode(file_get_contents('php://input'), true);
|
||||
$storyId = $input['storyId'] ?? null;
|
||||
$chapterId = $input['chapterId'] ?? null;
|
||||
$title = $input['title'] ?? '';
|
||||
$content = $input['content'] ?? '';
|
||||
$content = $input['content'];
|
||||
|
||||
try {
|
||||
$story = Stories::get($storyId);
|
||||
|
171
admin/api/update-chapter-cover.php
Normal file
171
admin/api/update-chapter-cover.php
Normal file
@ -0,0 +1,171 @@
|
||||
<?php
|
||||
require_once '../../includes/config.php';
|
||||
require_once '../../includes/auth.php';
|
||||
require_once '../../includes/stories.php';
|
||||
|
||||
class ChapterCoverHandler {
|
||||
private $allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
private $maxFileSize = 5242880; // 5MB
|
||||
private $uploadDir;
|
||||
|
||||
public function __construct($storyId) {
|
||||
$this->uploadDir = __DIR__ . '/../../assets/images/chapters/' . $storyId . '/covers/';
|
||||
$this->ensureUploadDirectory();
|
||||
}
|
||||
|
||||
public function handleUpload($file, $chapterId) {
|
||||
try {
|
||||
// Vérifications de base
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
throw new Exception($this->getUploadErrorMessage($file['error']));
|
||||
}
|
||||
|
||||
// Vérification du type MIME
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mimeType = $finfo->file($file['tmp_name']);
|
||||
if (!in_array($mimeType, $this->allowedTypes)) {
|
||||
throw new Exception('Type de fichier non autorisé. Types acceptés : JPG, PNG, GIF, WEBP');
|
||||
}
|
||||
|
||||
// Vérification de la taille
|
||||
if ($file['size'] > $this->maxFileSize) {
|
||||
throw new Exception('Fichier trop volumineux. Taille maximum : 5MB');
|
||||
}
|
||||
|
||||
// Génération du nom de fichier
|
||||
$extension = $this->getExtensionFromMimeType($mimeType);
|
||||
$filename = $chapterId . '-cover.' . $extension;
|
||||
$targetPath = $this->uploadDir . $filename;
|
||||
|
||||
// Suppression de l'ancienne image si elle existe
|
||||
$this->removeOldCover($chapterId);
|
||||
|
||||
// Déplacement du fichier
|
||||
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
|
||||
throw new Exception('Erreur lors du déplacement du fichier uploadé');
|
||||
}
|
||||
|
||||
// Retourner le chemin relatif pour stockage en BDD
|
||||
return 'assets/images/chapters/' . basename(dirname($this->uploadDir)) . '/covers/' . $filename;
|
||||
|
||||
} catch (Exception $e) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function removeCover($chapterId) {
|
||||
foreach (glob($this->uploadDir . $chapterId . '-cover.*') as $file) {
|
||||
unlink($file);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private function ensureUploadDirectory() {
|
||||
if (!file_exists($this->uploadDir)) {
|
||||
if (!mkdir($this->uploadDir, 0755, true)) {
|
||||
throw new Exception('Impossible de créer le dossier d\'upload');
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_writable($this->uploadDir)) {
|
||||
throw new Exception('Le dossier d\'upload n\'est pas accessible en écriture');
|
||||
}
|
||||
}
|
||||
|
||||
private function removeOldCover($chapterId) {
|
||||
foreach (glob($this->uploadDir . $chapterId . '-cover.*') as $file) {
|
||||
unlink($file);
|
||||
}
|
||||
}
|
||||
|
||||
private function getExtensionFromMimeType($mimeType) {
|
||||
$map = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp'
|
||||
];
|
||||
return $map[$mimeType] ?? 'jpg';
|
||||
}
|
||||
|
||||
private function getUploadErrorMessage($error) {
|
||||
$errors = [
|
||||
UPLOAD_ERR_INI_SIZE => 'Le fichier dépasse la taille maximale autorisée par PHP',
|
||||
UPLOAD_ERR_FORM_SIZE => 'Le fichier dépasse la taille maximale autorisée par le formulaire',
|
||||
UPLOAD_ERR_PARTIAL => 'Le fichier n\'a été que partiellement uploadé',
|
||||
UPLOAD_ERR_NO_FILE => 'Aucun fichier n\'a été uploadé',
|
||||
UPLOAD_ERR_NO_TMP_DIR => 'Dossier temporaire manquant',
|
||||
UPLOAD_ERR_CANT_WRITE => 'Échec de l\'écriture du fichier sur le disque',
|
||||
UPLOAD_ERR_EXTENSION => 'Une extension PHP a arrêté l\'upload'
|
||||
];
|
||||
return $errors[$error] ?? 'Erreur inconnue lors de l\'upload';
|
||||
}
|
||||
}
|
||||
|
||||
// Vérification de l'authentification
|
||||
if (!Auth::check()) {
|
||||
http_response_code(401);
|
||||
exit(json_encode(['success' => false, 'error' => 'Non autorisé']));
|
||||
}
|
||||
|
||||
// Traitement de la requête
|
||||
try {
|
||||
// Récupérer les données selon la méthode
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
if ($input) {
|
||||
// Cas d'une requête JSON (suppression)
|
||||
$storyId = $input['storyId'] ?? null;
|
||||
$chapterId = $input['chapterId'] ?? null;
|
||||
$isDelete = $input['delete'] ?? false;
|
||||
} else {
|
||||
// Cas d'un upload de fichier
|
||||
$storyId = $_POST['storyId'] ?? null;
|
||||
$chapterId = $_POST['chapterId'] ?? null;
|
||||
$isDelete = false;
|
||||
}
|
||||
|
||||
if (!$storyId || !$chapterId) {
|
||||
throw new Exception('Paramètres manquants');
|
||||
}
|
||||
|
||||
// Récupération du roman
|
||||
$story = Stories::get($storyId);
|
||||
if (!$story) {
|
||||
throw new Exception('Roman non trouvé');
|
||||
}
|
||||
|
||||
// Trouver le chapitre concerné
|
||||
$chapterFound = false;
|
||||
foreach ($story['chapters'] as &$chapter) {
|
||||
if ($chapter['id'] === $chapterId) {
|
||||
$chapterFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$chapterFound) {
|
||||
throw new Exception('Chapitre non trouvé');
|
||||
}
|
||||
|
||||
$handler = new ChapterCoverHandler($storyId);
|
||||
|
||||
// Traitement selon le type de requête
|
||||
if ($isDelete) {
|
||||
$handler->removeCover($chapterId);
|
||||
$chapter['cover'] = null;
|
||||
} else if (isset($_FILES['cover'])) {
|
||||
$chapter['cover'] = $handler->handleUpload($_FILES['cover'], $chapterId);
|
||||
}
|
||||
|
||||
// Sauvegarde des modifications
|
||||
Stories::save($story);
|
||||
|
||||
echo json_encode(['success' => true]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
}
|
@ -81,9 +81,12 @@ class ImageUploadHandler {
|
||||
}
|
||||
|
||||
// Retourner le chemin relatif pour l'éditeur
|
||||
$relativePath = $this->getRelativePath($targetPath);
|
||||
$adminPreviewPath = '../' . $relativePath;
|
||||
return [
|
||||
'success' => true,
|
||||
'url' => $this->getRelativePath($targetPath),
|
||||
'url' => $adminPreviewPath,
|
||||
'storage_url' => $relativePath,
|
||||
'width' => $needsResize ? $newWidth : $width,
|
||||
'height' => $needsResize ? $newHeight : $height
|
||||
];
|
||||
@ -149,9 +152,7 @@ class ImageUploadHandler {
|
||||
}
|
||||
|
||||
private function getRelativePath($absolutePath) {
|
||||
$relativePath = str_replace(__DIR__ . '/../../', '', $absolutePath);
|
||||
// Ajout de '../' car on est dans admin/api/
|
||||
return '../' . str_replace('\\', '/', $relativePath); // Pour la compatibilité Windows
|
||||
return str_replace(__DIR__ . '/../../', '', $absolutePath);
|
||||
}
|
||||
|
||||
private function getUploadErrorMessage($error) {
|
||||
|
@ -92,7 +92,7 @@ $stories = Stories::getAll();
|
||||
: 'version inconnue';
|
||||
?>
|
||||
<div class="version-banner">
|
||||
Lectures d'Esenjin - v.<?= htmlspecialchars($version) ?>
|
||||
Lectures d'Esenjin - <a href="https://git.crystalyx.net/Esenjin_Asakha/Lectures" target="_blank" style="color: inherit; text-decoration: underline;">v.<?= htmlspecialchars($version) ?></a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
@ -17,10 +17,28 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$config = Config::load();
|
||||
|
||||
// Mise à jour des valeurs textuelles
|
||||
// Mise à jour des valeurs textuelles du site
|
||||
$config['site']['name'] = trim($_POST['site_name'] ?? '');
|
||||
$config['site']['description'] = trim($_POST['site_description'] ?? '');
|
||||
|
||||
// Mise à jour des valeurs de la page À propos
|
||||
$config['about']['title'] = trim($_POST['about_title'] ?? 'À propos');
|
||||
$config['about']['content'] = $_POST['about_content'] ?? '';
|
||||
|
||||
// Traitement des liens personnalisés
|
||||
$config['about']['links'] = [];
|
||||
if (!empty($_POST['links'])) {
|
||||
foreach ($_POST['links'] as $link) {
|
||||
if (!empty($link['title']) && !empty($link['url'])) {
|
||||
$config['about']['links'][] = [
|
||||
'title' => trim($link['title']),
|
||||
'url' => trim($link['url']),
|
||||
'target' => isset($link['target']) ? '_blank' : ''
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validation
|
||||
if (empty($config['site']['name'])) {
|
||||
throw new Exception('Le nom du site est requis');
|
||||
@ -31,6 +49,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$uploadHandler = new SiteUploadHandler();
|
||||
$config['site']['logo'] = $uploadHandler->handleLogoUpload($_FILES['site_logo']);
|
||||
}
|
||||
|
||||
// Gestion de l'upload du background de la page À propos
|
||||
if (isset($_FILES['about_background']) && $_FILES['about_background']['error'] !== UPLOAD_ERR_NO_FILE) {
|
||||
$uploadHandler = new SiteUploadHandler();
|
||||
$config['about']['background'] = $uploadHandler->handleBackgroundUpload($_FILES['about_background']);
|
||||
}
|
||||
|
||||
// Sauvegarde
|
||||
Config::save($config);
|
||||
@ -54,13 +78,12 @@ $config = Config::load();
|
||||
<link rel="icon" type="image/png" href="../assets/images/site/favicon.png">
|
||||
<?php endif; ?>
|
||||
<link rel="stylesheet" href="../assets/css/main.css">
|
||||
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="admin-nav">
|
||||
<div class="nav-brand">
|
||||
<?php
|
||||
$config = Config::load();
|
||||
if (!empty($config['site']['logo'])): ?>
|
||||
<?php if (!empty($config['site']['logo'])): ?>
|
||||
<img src="<?= htmlspecialchars('../' . $config['site']['logo']) ?>"
|
||||
alt="<?= htmlspecialchars($config['site']['name']) ?>">
|
||||
<?php endif; ?>
|
||||
@ -82,79 +105,128 @@ $config = Config::load();
|
||||
<div class="error-message"><?= htmlspecialchars($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="POST" class="options-form" enctype="multipart/form-data">
|
||||
<div class="form-group">
|
||||
<label for="site_name">Nom du site</label>
|
||||
<input type="text"
|
||||
id="site_name"
|
||||
name="site_name"
|
||||
value="<?= htmlspecialchars($config['site']['name']) ?>"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="site_description">Description du site</label>
|
||||
<textarea id="site_description"
|
||||
name="site_description"
|
||||
rows="4"><?= htmlspecialchars($config['site']['description']) ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="site_logo">Logo du site</label>
|
||||
<?php if (!empty($config['site']['logo'])): ?>
|
||||
<div class="current-logo">
|
||||
<img src="<?= htmlspecialchars('../' . $config['site']['logo']) ?>"
|
||||
alt="Logo actuel"
|
||||
style="max-height: 100px; margin: 10px 0;">
|
||||
<div class="options-container">
|
||||
<section class="options-section">
|
||||
<h2>Options générales</h2>
|
||||
<form method="POST" class="options-form" enctype="multipart/form-data">
|
||||
<!-- Options du site -->
|
||||
<div class="form-group">
|
||||
<label for="site_name">Nom du site</label>
|
||||
<input type="text"
|
||||
id="site_name"
|
||||
name="site_name"
|
||||
value="<?= htmlspecialchars($config['site']['name']) ?>"
|
||||
required>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<input type="file"
|
||||
id="site_logo"
|
||||
name="site_logo"
|
||||
accept="image/jpeg,image/png,image/gif,image/svg+xml">
|
||||
<small>Formats acceptés : JPG, PNG, GIF, SVG. Taille maximum : 2MB</small>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="button">Enregistrer les modifications</button>
|
||||
</form>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="site_description">Description du site</label>
|
||||
<textarea id="site_description"
|
||||
name="site_description"
|
||||
rows="4"><?= htmlspecialchars($config['site']['description']) ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="site_logo">Logo du site</label>
|
||||
<?php if (!empty($config['site']['logo'])): ?>
|
||||
<div class="current-logo">
|
||||
<img src="<?= htmlspecialchars('../' . $config['site']['logo']) ?>"
|
||||
alt="Logo actuel">
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<input type="file"
|
||||
id="site_logo"
|
||||
name="site_logo"
|
||||
accept="image/jpeg,image/png,image/gif,image/svg+xml">
|
||||
<small>Formats acceptés : JPG, PNG, GIF, SVG. Taille maximum : 2MB</small>
|
||||
</div>
|
||||
|
||||
<!-- Section À Propos -->
|
||||
<h2>Page "À propos"</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="about_title">Titre de la page</label>
|
||||
<input type="text"
|
||||
id="about_title"
|
||||
name="about_title"
|
||||
value="<?= htmlspecialchars($config['about']['title'] ?? 'À propos') ?>">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="about_background">Image de fond</label>
|
||||
<?php if (!empty($config['about']['background'])): ?>
|
||||
<div class="current-background">
|
||||
<img src="<?= htmlspecialchars('../' . $config['about']['background']) ?>"
|
||||
alt="Image de fond actuelle">
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<input type="file"
|
||||
id="about_background"
|
||||
name="about_background"
|
||||
accept="image/*">
|
||||
<small>Format recommandé : 1920x250px, JPG/PNG</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="about_content">Contenu de la page</label>
|
||||
<input type="hidden" id="about_content" name="about_content">
|
||||
<div id="aboutEditor" data-initial-content="<?= htmlspecialchars($config['about']['content'] ?? '') ?>"></div>
|
||||
</div>
|
||||
|
||||
<!-- Section Liens personnalisés -->
|
||||
<h2>Liens personnalisés</h2>
|
||||
<div id="customLinks" class="custom-links">
|
||||
<?php
|
||||
if (!empty($config['about']['links'])) {
|
||||
foreach ($config['about']['links'] as $index => $link) {
|
||||
?>
|
||||
<div class="link-item">
|
||||
<div class="form-group">
|
||||
<label>Titre du lien</label>
|
||||
<input type="text"
|
||||
name="links[<?= $index ?>][title]"
|
||||
value="<?= htmlspecialchars($link['title']) ?>"
|
||||
required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>URL</label>
|
||||
<input type="text"
|
||||
name="links[<?= $index ?>][url]"
|
||||
value="<?= htmlspecialchars($link['url']) ?>"
|
||||
required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
name="links[<?= $index ?>][target]"
|
||||
value="_blank"
|
||||
<?= (!empty($link['target']) && $link['target'] === '_blank') ? 'checked' : '' ?>>
|
||||
Ouvrir dans un nouvel onglet
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" class="button delete-story">Supprimer ce lien</button>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
<button type="button" id="addLink" class="button">Ajouter un lien</button>
|
||||
<br />
|
||||
<button type="submit" class="button submit-button">Enregistrer les modifications</button>
|
||||
<!-- Section Nettoyage des médias -->
|
||||
<h2>Maintenance</h2>
|
||||
<div class="maintenance-actions">
|
||||
<button type="button" id="cleanMedia" class="button">Nettoyer les médias inutilisés</button>
|
||||
<small>Supprime les images qui ne sont plus utilisées dans les romans et chapitres.</small>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Prévisualisation du logo
|
||||
document.getElementById('site_logo').addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
let currentLogo = document.querySelector('.current-logo');
|
||||
if (!currentLogo) {
|
||||
currentLogo = document.createElement('div');
|
||||
currentLogo.className = 'current-logo';
|
||||
e.target.parentElement.insertBefore(currentLogo, e.target.nextSibling);
|
||||
}
|
||||
currentLogo.innerHTML = `
|
||||
<img src="${e.target.result}"
|
||||
alt="Aperçu du logo"
|
||||
style="max-height: 100px; margin: 10px 0;">
|
||||
`;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
});
|
||||
|
||||
// Détection des changements non sauvegardés
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.querySelector('form');
|
||||
const initialState = new FormData(form).toString();
|
||||
|
||||
window.addEventListener('beforeunload', (e) => {
|
||||
if (new FormData(form).toString() !== initialState) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script src="https://cdn.quilljs.com/1.3.6/quill.min.js"></script>
|
||||
<script src="../assets/js/options.js"></script>
|
||||
<link rel="stylesheet" href="../assets/css/dialog.css">
|
||||
<script src="../assets/js/dialog.js"></script>
|
||||
</body>
|
||||
|
@ -136,6 +136,7 @@ function generateSlug($title) {
|
||||
<h3 class="chapter-title"><?= htmlspecialchars($chapter['title']) ?></h3>
|
||||
<div class="chapter-actions">
|
||||
<button type="button" class="button edit-chapter">Éditer</button>
|
||||
<button type="button" class="button edit-cover">Couverture</button>
|
||||
<button type="button" class="button delete-chapter">Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -143,18 +144,49 @@ function generateSlug($title) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="chapterEditor" class="modal" style="display: none;">
|
||||
<div id="chapterEditor" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Éditer le chapitre</h2>
|
||||
<input type="text" id="chapterTitle" placeholder="Titre du chapitre">
|
||||
<div id="editor"></div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="button" id="saveChapter">Enregistrer</button>
|
||||
<button type="button" class="button" id="cancelEdit">Annuler</button>
|
||||
<div class="modal-header">
|
||||
<h2>Éditer un chapitre</h2>
|
||||
<input type="text" id="chapterTitle" placeholder="Titre du chapitre">
|
||||
</div>
|
||||
|
||||
<div class="editor-container">
|
||||
<div id="editor"></div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" id="cancelEdit" class="button dark">Annuler</button>
|
||||
<button type="button" id="saveChapter" class="button">Enregistrer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Modale pour la couverture de chapitre -->
|
||||
<div id="chapterCoverEditor" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Couverture du chapitre</h2>
|
||||
<p class="chapter-title-display"></p>
|
||||
</div>
|
||||
|
||||
<div class="cover-preview-container">
|
||||
<div class="current-cover-preview"></div>
|
||||
<div class="form-group">
|
||||
<label for="chapterCover">Nouvelle image de couverture</label>
|
||||
<input type="file" id="chapterCover" accept="image/*">
|
||||
<small>Formats acceptés : JPG, PNG, GIF. Taille maximum : 5MB</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" id="cancelCoverEdit" class="button dark">Annuler</button>
|
||||
<button type="button" id="deleteCover" class="button delete-story" style="display: none;">Supprimer</button>
|
||||
<button type="button" id="saveCover" class="button">Enregistrer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.quilljs.com/1.3.6/quill.min.js"></script>
|
||||
|
@ -185,38 +185,43 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
/* Styles pour la modal et l'éditeur */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--bg-tertiary);
|
||||
padding: var(--spacing-xl);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
width: 90%;
|
||||
max-width: 800px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
color: var(--text-primary);
|
||||
/* En-tête de la modal */
|
||||
.modal-header {
|
||||
padding: var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
}
|
||||
|
||||
#chapterTitle {
|
||||
@ -227,14 +232,55 @@
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
margin-bottom: var(--spacing-md);
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
#chapterTitle:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 2px rgba(139, 69, 19, 0.2);
|
||||
/* Container de l'éditeur */
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Barre d'outils Quill */
|
||||
.ql-toolbar.ql-snow {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--border-color);
|
||||
padding: var(--spacing-sm);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Container principal de Quill */
|
||||
.ql-container.ql-snow {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
border-color: var(--border-color);
|
||||
background: var(--input-bg);
|
||||
}
|
||||
|
||||
/* Zone d'édition */
|
||||
.ql-editor {
|
||||
min-height: 100%;
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* Pied de la modal */
|
||||
.modal-footer {
|
||||
padding: var(--spacing-lg);
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-md);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
@ -260,6 +306,28 @@
|
||||
animation: modalFadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.cover-preview-container {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.current-cover-preview {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.current-cover-preview img {
|
||||
max-width: 100%;
|
||||
max-height: 300px;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.chapter-title-display {
|
||||
color: var(--text-secondary);
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Media queries */
|
||||
@media (max-width: 768px) {
|
||||
.story-cover {
|
||||
@ -274,14 +342,19 @@
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: var(--spacing-md);
|
||||
width: 95%;
|
||||
height: 95vh;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
|
||||
#chapterTitle {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-actions button {
|
||||
|
||||
.modal-footer button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
280
assets/css/content.css
Normal file
280
assets/css/content.css
Normal file
@ -0,0 +1,280 @@
|
||||
/* content.css - Styles pour le contenu de l'éditeur */
|
||||
|
||||
/* Conteneur principal du contenu */
|
||||
.chapter-content,
|
||||
.novel-description,
|
||||
.about-description {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.8;
|
||||
color: var(--text-primary);
|
||||
font-weight: normal;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Titres */
|
||||
.chapter-content h1,
|
||||
.chapter-content h2,
|
||||
.chapter-content h3,
|
||||
.chapter-content h4,
|
||||
.chapter-content h5,
|
||||
.chapter-content h6,
|
||||
.novel-description h1,
|
||||
.novel-description h2,
|
||||
.novel-description h3,
|
||||
.novel-description h4,
|
||||
.novel-description h5,
|
||||
.novel-description h6,
|
||||
.about-description h1,
|
||||
.about-description h2,
|
||||
.about-description h3,
|
||||
.about-description h4,
|
||||
.about-description h5,
|
||||
.about-description h6 {
|
||||
margin: 1.5em 0 0.8em;
|
||||
line-height: 1.3;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.chapter-content h1,
|
||||
.novel-description h1,
|
||||
.about-description h1 { font-size: 2em; }
|
||||
|
||||
.chapter-content h2,
|
||||
.novel-description h2,
|
||||
.about-description h2 { font-size: 1.75em; }
|
||||
|
||||
.chapter-content h3,
|
||||
.novel-description h3,
|
||||
.about-description h3 { font-size: 1.5em; }
|
||||
|
||||
.chapter-content h4,
|
||||
.novel-description h4,
|
||||
.about-description h4 { font-size: 1.25em; }
|
||||
|
||||
.chapter-content h5,
|
||||
.novel-description h5,
|
||||
.about-description h5 { font-size: 1.1em; }
|
||||
|
||||
.chapter-content h6,
|
||||
.novel-description h6,
|
||||
.about-description h6 { font-size: 1em; }
|
||||
|
||||
/* Paragraphes et espacement */
|
||||
.chapter-content p,
|
||||
.novel-description p,
|
||||
.about-description p {
|
||||
margin: 0em 0;
|
||||
min-height: 1.5em;
|
||||
}
|
||||
|
||||
/* Listes */
|
||||
.chapter-content ul,
|
||||
.chapter-content ol,
|
||||
.novel-description ul,
|
||||
.novel-description ol,
|
||||
.about-description ul,
|
||||
.about-description ol {
|
||||
margin: 1em 0;
|
||||
padding-left: 2em;
|
||||
list-style-position: outside;
|
||||
}
|
||||
|
||||
.chapter-content li,
|
||||
.novel-description li,
|
||||
.about-description li {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 0.5em;
|
||||
}
|
||||
|
||||
/* Citations */
|
||||
.chapter-content blockquote,
|
||||
.novel-description blockquote,
|
||||
.about-description blockquote {
|
||||
margin: 1.5em 0;
|
||||
padding: 1em 1.5em;
|
||||
border-left: 4px solid var(--accent-primary);
|
||||
background-color: var(--bg-secondary);
|
||||
font-style: italic;
|
||||
color: var(--text-secondary);
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Blocs de code */
|
||||
.chapter-content pre,
|
||||
.novel-description pre,
|
||||
.about-description pre {
|
||||
margin: 1.5em 0;
|
||||
padding: 1em;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chapter-content code,
|
||||
.novel-description code,
|
||||
.about-description code {
|
||||
font-family: "Consolas", "Monaco", monospace;
|
||||
font-size: 0.9em;
|
||||
padding: 0.2em 0.4em;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Images et alignements */
|
||||
.chapter-content .ql-align-left,
|
||||
.novel-description .ql-align-left,
|
||||
.about-description .ql-align-left {
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
.chapter-content .ql-align-center,
|
||||
.novel-description .ql-align-center,
|
||||
.about-description .ql-align-center {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.chapter-content .ql-align-right,
|
||||
.novel-description .ql-align-right,
|
||||
.about-description .ql-align-right {
|
||||
text-align: right !important;
|
||||
}
|
||||
|
||||
.chapter-content .ql-align-justify,
|
||||
.novel-description .ql-align-justify,
|
||||
.about-description .ql-align-justify {
|
||||
text-align: justify !important;
|
||||
}
|
||||
|
||||
/* Images */
|
||||
.chapter-content p img,
|
||||
.novel-description p img,
|
||||
.about-description p img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 1.5em auto;
|
||||
border-radius: var(--radius-sm);
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Ajustements spécifiques pour les images selon l'alignement */
|
||||
.ql-align-left p img {
|
||||
margin-left: 0;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.ql-align-center p img {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ql-align-right p img {
|
||||
margin-left: auto;
|
||||
margin-right: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Support des tailles d'images */
|
||||
.ql-size-small img {
|
||||
max-width: 50% !important;
|
||||
}
|
||||
|
||||
.ql-size-large img {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
/* Styles pour les liens */
|
||||
.chapter-content a,
|
||||
.novel-description a,
|
||||
.about-description a {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.chapter-content a:hover,
|
||||
.novel-description a:hover,
|
||||
.about-description a:hover {
|
||||
color: var(--accent-secondary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Styles pour les indices et exposants */
|
||||
.chapter-content sub,
|
||||
.novel-description sub,
|
||||
.about-description sub {
|
||||
vertical-align: sub;
|
||||
font-size: smaller;
|
||||
}
|
||||
|
||||
.chapter-content sup,
|
||||
.novel-description sup,
|
||||
.about-description sup {
|
||||
vertical-align: super;
|
||||
font-size: smaller;
|
||||
}
|
||||
|
||||
/* Barre de séparation */
|
||||
.chapter-divider {
|
||||
margin: 2em auto;
|
||||
border: none;
|
||||
border-top: 2px solid var(--accent-primary);
|
||||
opacity: 0.5;
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Polices */
|
||||
.font-serif {
|
||||
font-family: Georgia, "Times New Roman", serif !important;
|
||||
}
|
||||
|
||||
.font-sans {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: "Consolas", "Monaco", monospace !important;
|
||||
}
|
||||
|
||||
/* Media queries pour le responsive */
|
||||
@media (max-width: 768px) {
|
||||
.chapter-content,
|
||||
.novel-description,
|
||||
.about-description {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.chapter-content blockquote,
|
||||
.novel-description blockquote,
|
||||
.about-description blockquote {
|
||||
margin: 1em 0;
|
||||
padding: 0.8em 1em;
|
||||
}
|
||||
|
||||
.chapter-content pre,
|
||||
.novel-description pre,
|
||||
.about-description pre {
|
||||
padding: 0.8em;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.chapter-content h1,
|
||||
.novel-description h1,
|
||||
.about-description h1 { font-size: 1.75em; }
|
||||
|
||||
.chapter-content h2,
|
||||
.novel-description h2,
|
||||
.about-description h2 { font-size: 1.5em; }
|
||||
|
||||
.chapter-content h3,
|
||||
.novel-description h3,
|
||||
.about-description h3 { font-size: 1.25em; }
|
||||
}
|
@ -104,4 +104,113 @@
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: var(--spacing-md) 0;
|
||||
}
|
||||
|
||||
/* Section Options */
|
||||
/* Conteneur des options */
|
||||
.options-container {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.options-section {
|
||||
background: var(--bg-tertiary);
|
||||
padding: var(--spacing-xl);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.options-section h2 {
|
||||
margin: var(--spacing-xl) 0 var(--spacing-lg);
|
||||
padding-bottom: var(--spacing-sm);
|
||||
border-bottom: 2px solid var(--accent-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.options-section h2:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Aperçus des images */
|
||||
.current-logo img,
|
||||
.current-background img {
|
||||
max-height: 100px;
|
||||
margin: var(--spacing-sm) 0;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Section des liens personnalisés */
|
||||
.custom-links {
|
||||
margin: var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.link-item {
|
||||
background: var(--bg-secondary);
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: var(--spacing-md);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.link-item .form-group {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.link-item .form-group:last-of-type {
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.link-item label {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.link-item label:has(input[type="checkbox"]) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link-item input[type="checkbox"] {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Bouton d'ajout de lien */
|
||||
#addLink {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
/* Bouton de soumission */
|
||||
.submit-button {
|
||||
margin-top: var(--spacing-xl);
|
||||
}
|
||||
|
||||
/* Nettoyage des médias */
|
||||
.maintenance-actions {
|
||||
margin: var(--spacing-lg) 0;
|
||||
padding: var(--spacing-md);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.maintenance-actions button {
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.maintenance-actions small {
|
||||
display: block;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.options-section {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.link-item {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
}
|
@ -1,3 +1,6 @@
|
||||
/* Import d'autres fichiers CSS */
|
||||
@import 'stats.css';
|
||||
|
||||
/* Import Google Fonts */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Parisienne&display=swap');
|
||||
|
||||
@ -101,6 +104,26 @@ body {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
position: absolute;
|
||||
top: var(--spacing-md);
|
||||
right: var(--spacing-md);
|
||||
}
|
||||
|
||||
.about-button {
|
||||
display: inline-block;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--accent-primary);
|
||||
color: var(--text-tertiary);
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.about-button:hover {
|
||||
background-color: var(--accent-secondary);
|
||||
}
|
||||
|
||||
/* Conteneur principal */
|
||||
.main-content {
|
||||
max-width: var(--content-width);
|
||||
@ -467,6 +490,161 @@ body {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Bouton retour en haut */
|
||||
.scroll-top {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--border-color);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.scroll-top.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.scroll-top:hover {
|
||||
background-color: var(--accent-primary);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Styles pour la page À propos */
|
||||
.about-content {
|
||||
max-width: var(--content-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--spacing-md);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 300px;
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.about-description {
|
||||
background: var(--bg-tertiary);
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.about-description img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: var(--spacing-md) 0;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xl);
|
||||
position: sticky;
|
||||
top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
background: var(--bg-tertiary);
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sidebar-section h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: var(--spacing-md);
|
||||
color: var(--text-primary);
|
||||
padding-bottom: var(--spacing-sm);
|
||||
border-bottom: 2px solid var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Liens personnalisés */
|
||||
.custom-links-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.custom-links-list li {
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.custom-links-list a {
|
||||
display: block;
|
||||
padding: var(--spacing-sm);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.custom-links-list a:hover {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Statistiques */
|
||||
.stats-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.stats-item {
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding-bottom: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.stats-item:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.stats-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.stats-value {
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.stats-detail {
|
||||
font-size: 0.85rem;
|
||||
color: var(--accent-primary);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.stats-detail a {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.stats-detail a:hover {
|
||||
color: var(--accent-secondary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1200px) {
|
||||
:root {
|
||||
@ -487,6 +665,18 @@ body {
|
||||
.novel-header h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.about-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@ -555,4 +745,17 @@ body {
|
||||
.site-header h1 {
|
||||
font-size: 2.4rem;
|
||||
}
|
||||
|
||||
.about-content {
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.about-description,
|
||||
.sidebar-section {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.stats-value {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
73
assets/css/stats.css
Normal file
73
assets/css/stats.css
Normal file
@ -0,0 +1,73 @@
|
||||
.stats-menu {
|
||||
background: var(--bg-tertiary);
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
position: sticky;
|
||||
top: var(--spacing-lg);
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.stats-menu h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: var(--spacing-md);
|
||||
color: var(--text-primary);
|
||||
padding-bottom: var(--spacing-sm);
|
||||
border-bottom: 2px solid var(--accent-primary);
|
||||
}
|
||||
|
||||
.stats-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.stats-item {
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding-bottom: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.stats-item:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.stats-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.stats-value {
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.stats-detail {
|
||||
font-size: 0.85rem;
|
||||
color: var(--accent-primary);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
/* Style pour les liens dans les stats */
|
||||
.stats-detail a {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.stats-detail a:hover {
|
||||
color: var(--accent-secondary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Media queries */
|
||||
@media (max-width: 900px) {
|
||||
.stats-menu {
|
||||
position: static;
|
||||
margin-top: var(--spacing-xl);
|
||||
}
|
||||
}
|
227
assets/js/options.js
Normal file
227
assets/js/options.js
Normal file
@ -0,0 +1,227 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Configuration de l'éditeur Quill
|
||||
const aboutEditor = new Quill('#aboutEditor', {
|
||||
theme: 'snow',
|
||||
modules: {
|
||||
toolbar: {
|
||||
container: [
|
||||
[{ 'header': [1, 2, 3, false] }],
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
[{ 'color': [] }, { 'background': [] }],
|
||||
[{ 'font': [] }],
|
||||
[{ 'align': [] }],
|
||||
['blockquote', 'code-block'],
|
||||
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
||||
[{ 'script': 'sub'}, { 'script': 'super' }],
|
||||
[{ 'indent': '-1'}, { 'indent': '+1' }],
|
||||
[{ 'direction': 'rtl' }],
|
||||
['link', 'image', 'video'],
|
||||
['clean']
|
||||
],
|
||||
handlers: {
|
||||
image: function() {
|
||||
const input = document.createElement('input');
|
||||
input.setAttribute('type', 'file');
|
||||
input.setAttribute('accept', 'image/*');
|
||||
input.click();
|
||||
|
||||
input.onchange = async () => {
|
||||
const file = input.files[0];
|
||||
if (file) {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('api/about-image-upload.php', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Upload failed');
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const range = aboutEditor.getSelection(true);
|
||||
// Utiliser le chemin complet pour l'affichage
|
||||
aboutEditor.insertEmbed(range.index, 'image', '../' + result.storage_url);
|
||||
|
||||
// Mettre à jour le contenu Delta si nécessaire
|
||||
const insertOp = aboutEditor.getContents().ops.find(op =>
|
||||
op.insert && op.insert.image === '../' + result.storage_url
|
||||
);
|
||||
if (insertOp) {
|
||||
// Stocker le chemin relatif
|
||||
insertOp.insert.image = result.storage_url;
|
||||
}
|
||||
|
||||
aboutEditor.setSelection(range.index + 1);
|
||||
} else {
|
||||
showNotification(result.error || 'Erreur lors de l\'upload', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
showNotification('Erreur lors de l\'upload de l\'image', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
placeholder: 'Commencez à écrire le contenu de la page À propos...'
|
||||
});
|
||||
|
||||
// Initialisation du contenu si existant
|
||||
const editorElement = document.getElementById('aboutEditor');
|
||||
const initialContent = editorElement.getAttribute('data-initial-content');
|
||||
if (initialContent) {
|
||||
aboutEditor.root.innerHTML = initialContent;
|
||||
}
|
||||
|
||||
// Gestion des prévisualisations d'images
|
||||
function handleImagePreview(inputId, previewClass) {
|
||||
const input = document.getElementById(inputId);
|
||||
if (!input) return;
|
||||
|
||||
input.addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
let previewContainer = input.parentElement.querySelector('.' + previewClass);
|
||||
if (!previewContainer) {
|
||||
previewContainer = document.createElement('div');
|
||||
previewContainer.className = previewClass;
|
||||
input.parentElement.insertBefore(previewContainer, input.nextSibling);
|
||||
}
|
||||
previewContainer.innerHTML = `<img src="${e.target.result}" alt="Aperçu">`;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialisation des prévisualisations
|
||||
handleImagePreview('site_logo', 'current-logo');
|
||||
handleImagePreview('about_background', 'current-background');
|
||||
|
||||
// Gestion des liens personnalisés
|
||||
const customLinks = document.getElementById('customLinks');
|
||||
const addLinkBtn = document.getElementById('addLink');
|
||||
|
||||
// Ajout d'un nouveau lien
|
||||
addLinkBtn.addEventListener('click', function() {
|
||||
const index = customLinks.children.length;
|
||||
const linkItem = document.createElement('div');
|
||||
linkItem.className = 'link-item';
|
||||
linkItem.innerHTML = `
|
||||
<div class="form-group">
|
||||
<label>Titre du lien</label>
|
||||
<input type="text" name="links[${index}][title]" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>URL</label>
|
||||
<input type="text" name="links[${index}][url]" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="links[${index}][target]" value="_blank">
|
||||
Ouvrir dans un nouvel onglet
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" class="button dark remove-link">Supprimer ce lien</button>
|
||||
`;
|
||||
customLinks.appendChild(linkItem);
|
||||
});
|
||||
|
||||
// Suppression d'un lien
|
||||
customLinks.addEventListener('click', function(e) {
|
||||
if (e.target.matches('.remove-link')) {
|
||||
const linkItem = e.target.closest('.link-item');
|
||||
confirmDialog.show({
|
||||
title: 'Supprimer le lien',
|
||||
message: 'Êtes-vous sûr de vouloir supprimer ce lien ?',
|
||||
confirmText: 'Supprimer',
|
||||
confirmClass: 'danger',
|
||||
onConfirm: () => {
|
||||
linkItem.remove();
|
||||
// Réindexer les champs
|
||||
customLinks.querySelectorAll('.link-item').forEach((item, index) => {
|
||||
item.querySelectorAll('input').forEach(input => {
|
||||
const name = input.getAttribute('name');
|
||||
input.setAttribute('name', name.replace(/\[\d+\]/, `[${index}]`));
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Mise à jour du champ caché avant la soumission
|
||||
document.querySelector('form').addEventListener('submit', function() {
|
||||
document.querySelector('#about_content').value = aboutEditor.root.innerHTML;
|
||||
});
|
||||
|
||||
// Détection des changements non sauvegardés
|
||||
const form = document.querySelector('form');
|
||||
const initialState = new FormData(form).toString();
|
||||
|
||||
window.addEventListener('beforeunload', (e) => {
|
||||
if (new FormData(form).toString() !== initialState) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Fonction utilitaire pour les notifications
|
||||
function showNotification(message, type = 'success') {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification ${type}`;
|
||||
notification.textContent = message;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.style.opacity = '1';
|
||||
notification.style.transform = 'translateY(0)';
|
||||
}, 10);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.style.opacity = '0';
|
||||
notification.style.transform = 'translateY(-100%)';
|
||||
setTimeout(() => notification.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Gestion du nettoyage des médias
|
||||
const cleanMediaBtn = document.getElementById('cleanMedia');
|
||||
if (cleanMediaBtn) {
|
||||
cleanMediaBtn.addEventListener('click', async () => {
|
||||
confirmDialog.show({
|
||||
title: 'Nettoyage des médias',
|
||||
message: 'Voulez-vous vraiment supprimer toutes les images qui ne sont plus utilisées ? Cette action est irréversible.',
|
||||
confirmText: 'Nettoyer',
|
||||
confirmClass: 'danger',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
const response = await fetch('api/clean-media.php');
|
||||
if (!response.ok) throw new Error('Erreur réseau');
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const stats = result.stats;
|
||||
const mbFreed = (stats.freedSpace / (1024 * 1024)).toFixed(2);
|
||||
const message = `${stats.filesRemoved} fichier(s) supprimé(s).\n${mbFreed} Mo d'espace libéré.`;
|
||||
showNotification(message);
|
||||
} else {
|
||||
throw new Error(result.error || 'Une erreur est survenue');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error);
|
||||
showNotification(error.message, 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
@ -1,6 +1,7 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const storyId = document.querySelector('input[name="id"]')?.value || urlParams.get('id');
|
||||
let currentChapterId = null;
|
||||
const storyId = document.querySelector('input[name="id"]')?.value;
|
||||
|
||||
// Fonction de notification
|
||||
function showNotification(message, type = 'success') {
|
||||
@ -96,6 +97,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
if (result.success) {
|
||||
const range = quill.getSelection(true);
|
||||
quill.insertEmbed(range.index, 'image', result.url);
|
||||
const insertOp = quill.getContents().ops.find(op =>
|
||||
op.insert && op.insert.image === result.url
|
||||
);
|
||||
if (insertOp) {
|
||||
insertOp.insert.image = result.storage_url;
|
||||
}
|
||||
quill.setSelection(range.index + 1);
|
||||
} else {
|
||||
showNotification(result.error || 'Erreur lors de l\'upload', 'error');
|
||||
@ -127,6 +134,154 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const chapterTitleInput = document.getElementById('chapterTitle');
|
||||
const chaptersList = document.getElementById('chaptersList');
|
||||
|
||||
// Éléments de la modale de couverture
|
||||
const coverModal = document.getElementById('chapterCoverEditor');
|
||||
const coverPreview = document.querySelector('.current-cover-preview');
|
||||
const chapterCoverInput = document.getElementById('chapterCover');
|
||||
const saveCoverBtn = document.getElementById('saveCover');
|
||||
const cancelCoverBtn = document.getElementById('cancelCoverEdit');
|
||||
const deleteCoverBtn = document.getElementById('deleteCover');
|
||||
let currentChapterCover = null;
|
||||
|
||||
// Gestionnaire pour le bouton de couverture
|
||||
if (chaptersList) {
|
||||
chaptersList.addEventListener('click', async (e) => {
|
||||
if (e.target.matches('.edit-cover')) {
|
||||
const chapterItem = e.target.closest('.chapter-item');
|
||||
const chapterId = chapterItem.dataset.id;
|
||||
const chapterTitle = chapterItem.querySelector('.chapter-title').textContent;
|
||||
|
||||
// Mise à jour du titre dans la modale
|
||||
coverModal.querySelector('.chapter-title-display').textContent = chapterTitle;
|
||||
currentChapterCover = chapterId;
|
||||
|
||||
try {
|
||||
const response = await fetch(`api/get-chapter.php?storyId=${storyId}&chapterId=${chapterId}`);
|
||||
if (!response.ok) throw new Error('Erreur réseau');
|
||||
|
||||
const chapter = await response.json();
|
||||
|
||||
// Afficher l'image existante si elle existe
|
||||
if (chapter.cover) {
|
||||
coverPreview.innerHTML = `<img src="../${chapter.cover}" alt="Couverture actuelle">`;
|
||||
deleteCoverBtn.style.display = 'block';
|
||||
} else {
|
||||
coverPreview.innerHTML = '';
|
||||
deleteCoverBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
coverModal.style.display = 'block';
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error);
|
||||
showNotification('Erreur lors du chargement de la couverture', 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Prévisualisation de l'image
|
||||
if (chapterCoverInput) {
|
||||
chapterCoverInput.addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
coverPreview.innerHTML = `<img src="${e.target.result}" alt="Prévisualisation">`;
|
||||
deleteCoverBtn.style.display = 'none';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fermeture de la modale de couverture
|
||||
if (cancelCoverBtn) {
|
||||
cancelCoverBtn.addEventListener('click', () => {
|
||||
coverModal.style.display = 'none';
|
||||
chapterCoverInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
// Sauvegarde de la couverture
|
||||
if (saveCoverBtn) {
|
||||
saveCoverBtn.addEventListener('click', async () => {
|
||||
const file = chapterCoverInput.files[0];
|
||||
if (!file && !coverPreview.querySelector('img')) {
|
||||
showNotification('Veuillez sélectionner une image', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('storyId', storyId);
|
||||
formData.append('chapterId', currentChapterCover);
|
||||
if (file) {
|
||||
formData.append('cover', file);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('api/update-chapter-cover.php', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Erreur lors de l\'upload');
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
showNotification('Couverture mise à jour avec succès');
|
||||
coverModal.style.display = 'none';
|
||||
chapterCoverInput.value = '';
|
||||
} else {
|
||||
throw new Error(result.error || 'Erreur lors de la mise à jour');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error);
|
||||
showNotification(error.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Suppression de la couverture
|
||||
if (deleteCoverBtn) {
|
||||
deleteCoverBtn.addEventListener('click', () => {
|
||||
confirmDialog.show({
|
||||
title: 'Supprimer la couverture',
|
||||
message: 'Voulez-vous vraiment supprimer la couverture de ce chapitre ?',
|
||||
confirmText: 'Supprimer',
|
||||
confirmClass: 'danger',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
const response = await fetch('api/update-chapter-cover.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
storyId: storyId,
|
||||
chapterId: currentChapterCover,
|
||||
delete: true
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Erreur réseau');
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
showNotification('Couverture supprimée avec succès');
|
||||
coverPreview.innerHTML = '';
|
||||
coverModal.style.display = 'none';
|
||||
} else {
|
||||
throw new Error(result.error || 'Erreur lors de la suppression');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error);
|
||||
showNotification(error.message, 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Configuration de Sortable pour la réorganisation des chapitres
|
||||
if (chaptersList) {
|
||||
new Sortable(chaptersList, {
|
||||
@ -308,7 +463,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
storyId: storyId,
|
||||
chapterId: currentChapterId,
|
||||
title: title,
|
||||
content: JSON.stringify(quill.getContents())
|
||||
content: quill.root.innerHTML
|
||||
})
|
||||
});
|
||||
|
||||
|
112
chapitre.php
112
chapitre.php
@ -34,80 +34,6 @@ if (!$currentChapter) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Fonction pour convertir le contenu Delta en HTML
|
||||
function deltaToHtml($content) {
|
||||
if (empty($content)) return '';
|
||||
|
||||
// Si le contenu est déjà en HTML (ancien format)
|
||||
if (is_string($content) && !isJson($content)) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
// Convertir la chaîne JSON en tableau si nécessaire
|
||||
if (is_string($content)) {
|
||||
$delta = json_decode($content, true);
|
||||
} else {
|
||||
$delta = $content;
|
||||
}
|
||||
|
||||
if (!isset($delta['ops'])) return '';
|
||||
|
||||
$html = '';
|
||||
foreach ($delta['ops'] as $op) {
|
||||
if (is_string($op['insert'])) {
|
||||
$text = htmlspecialchars($op['insert']);
|
||||
|
||||
// Gérer les styles de texte
|
||||
if (isset($op['attributes'])) {
|
||||
if (!empty($op['attributes']['bold'])) {
|
||||
$text = "<strong>{$text}</strong>";
|
||||
}
|
||||
if (!empty($op['attributes']['italic'])) {
|
||||
$text = "<em>{$text}</em>";
|
||||
}
|
||||
if (!empty($op['attributes']['underline'])) {
|
||||
$text = "<u>{$text}</u>";
|
||||
}
|
||||
// Ajouter d'autres styles si nécessaire
|
||||
}
|
||||
|
||||
// Convertir les retours à la ligne en paragraphes
|
||||
if ($text === "\n") {
|
||||
$html .= "<br>";
|
||||
} else {
|
||||
$html .= $text;
|
||||
}
|
||||
}
|
||||
// Gérer les images
|
||||
elseif (is_array($op['insert']) && isset($op['insert']['image'])) {
|
||||
$imageUrl = $op['insert']['image'];
|
||||
error_log('URL originale: ' . $imageUrl);
|
||||
// Retirer tous les "../" au début de l'URL
|
||||
$imageUrl = preg_replace('/^(?:\.\.\/)+/', '', $imageUrl);
|
||||
error_log('URL nettoyée: ' . $imageUrl);
|
||||
$html .= "<img src=\"{$imageUrl}\" alt=\"Image du chapitre\">";
|
||||
}
|
||||
}
|
||||
|
||||
// Envelopper le contenu dans des balises p si nécessaire
|
||||
if (!empty($html)) {
|
||||
$paragraphs = explode("\n\n", $html);
|
||||
$html = '';
|
||||
foreach ($paragraphs as $p) {
|
||||
if (trim($p) !== '') {
|
||||
$html .= "<p>{$p}</p>";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
function isJson($string) {
|
||||
json_decode($string);
|
||||
return json_last_error() === JSON_ERROR_NONE;
|
||||
}
|
||||
|
||||
// Récupération des chapitres précédent et suivant
|
||||
$prevChapter = $currentIndex > 0 ? $story['chapters'][$currentIndex - 1] : null;
|
||||
$nextChapter = $currentIndex < count($story['chapters']) - 1 ? $story['chapters'][$currentIndex + 1] : null;
|
||||
@ -126,19 +52,23 @@ $config = Config::load();
|
||||
<?php endif; ?>
|
||||
|
||||
<link rel="stylesheet" href="assets/css/public.css">
|
||||
<link rel="stylesheet" href="assets/css/content.css">
|
||||
|
||||
<meta name="description" content="<?= htmlspecialchars($story['title']) ?> - <?= htmlspecialchars($currentChapter['title']) ?>">
|
||||
</head>
|
||||
<body>
|
||||
<!-- En-tête simple -->
|
||||
<header class="novel-header" style="height: 180px;">
|
||||
<!-- En-tête avec image de couverture si disponible -->
|
||||
<header class="novel-header">
|
||||
<?php if (!empty($currentChapter['cover'])): ?>
|
||||
<div class="novel-header-background" style="background-image: url('<?= htmlspecialchars($currentChapter['cover']) ?>');"></div>
|
||||
<?php endif; ?>
|
||||
<h1><?= htmlspecialchars($currentChapter['title']) ?></h1>
|
||||
</header>
|
||||
|
||||
<!-- Contenu principal -->
|
||||
<div class="novel-content">
|
||||
<div class="novel-description chapter-content">
|
||||
<?= deltaToHtml($currentChapter['content']) ?>
|
||||
<?= $currentChapter['content'] = Config::fixImagePaths($currentChapter['content']); ?>
|
||||
|
||||
<!-- Navigation entre chapitres -->
|
||||
<div class="chapter-navigation">
|
||||
@ -174,7 +104,33 @@ $config = Config::load();
|
||||
</div>
|
||||
|
||||
<div class="back-to-home">
|
||||
<a href="index.php">← Retour à l'accueil</a> | <a href="roman.php?id=<?= urlencode($storyId) ?>">← Retour au roman</a>
|
||||
<a href="index.php">← Retour à l'accueil</a> |
|
||||
<a href="roman.php?id=<?= urlencode($storyId) ?>">← Retour au roman</a>
|
||||
</div>
|
||||
|
||||
<button class="scroll-top" aria-label="Retour en haut de page">↑</button>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const scrollTopBtn = document.querySelector('.scroll-top');
|
||||
|
||||
// Afficher/masquer le bouton de retour en haut
|
||||
window.addEventListener('scroll', function() {
|
||||
if (window.pageYOffset > 300) {
|
||||
scrollTopBtn.classList.add('visible');
|
||||
} else {
|
||||
scrollTopBtn.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
// Action de retour en haut
|
||||
scrollTopBtn.addEventListener('click', function() {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
12
config.json
12
config.json
@ -3,6 +3,18 @@
|
||||
"name": "Nom du Site",
|
||||
"description": "Description du site"
|
||||
},
|
||||
"about": {
|
||||
"title": "À propos",
|
||||
"content": "",
|
||||
"background": "assets/images/site/about-bg.jpg",
|
||||
"links": [
|
||||
{
|
||||
"title": "Mon Blog",
|
||||
"url": "https://blog.example.com",
|
||||
"target": "_blank"
|
||||
}
|
||||
]
|
||||
},
|
||||
"users": [
|
||||
{
|
||||
"id": "admin",
|
||||
|
118
includes/Stats.php
Normal file
118
includes/Stats.php
Normal file
@ -0,0 +1,118 @@
|
||||
<?php
|
||||
class Stats {
|
||||
private static $storiesDir = __DIR__ . '/../stories/';
|
||||
private $stories = [];
|
||||
private $stats = [];
|
||||
|
||||
public function __construct() {
|
||||
$this->loadStories();
|
||||
$this->calculateStats();
|
||||
}
|
||||
|
||||
private function loadStories() {
|
||||
foreach (glob(self::$storiesDir . '*.json') as $file) {
|
||||
$this->stories[] = json_decode(file_get_contents($file), true);
|
||||
}
|
||||
}
|
||||
|
||||
private function calculateStats() {
|
||||
$totalChapters = 0;
|
||||
$totalWords = 0;
|
||||
$maxChapters = 0;
|
||||
$storyWithMostChapters = null;
|
||||
$lastUpdate = null;
|
||||
$mostRecentStory = null;
|
||||
$longestChapter = [
|
||||
'words' => 0,
|
||||
'story' => null,
|
||||
'chapter' => null
|
||||
];
|
||||
|
||||
foreach ($this->stories as $story) {
|
||||
// Compter les chapitres
|
||||
$chapterCount = count($story['chapters'] ?? []);
|
||||
$totalChapters += $chapterCount;
|
||||
|
||||
// Trouver le roman avec le plus de chapitres
|
||||
if ($chapterCount > $maxChapters) {
|
||||
$maxChapters = $chapterCount;
|
||||
$storyWithMostChapters = $story;
|
||||
}
|
||||
|
||||
// Trouver la dernière mise à jour
|
||||
$storyUpdate = strtotime($story['updated']);
|
||||
if (!$lastUpdate || $storyUpdate > $lastUpdate) {
|
||||
$lastUpdate = $storyUpdate;
|
||||
$mostRecentStory = $story;
|
||||
}
|
||||
|
||||
// Compter les mots et trouver le plus long chapitre
|
||||
foreach ($story['chapters'] ?? [] as $chapter) {
|
||||
$content = $chapter['content'];
|
||||
|
||||
// Si le contenu est au format JSON (Delta)
|
||||
if (is_string($content) && $this->isJson($content)) {
|
||||
$content = json_decode($content, true);
|
||||
$text = '';
|
||||
if (isset($content['ops'])) {
|
||||
foreach ($content['ops'] as $op) {
|
||||
if (is_string($op['insert'])) {
|
||||
$text .= $op['insert'];
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Si le contenu est en HTML
|
||||
$text = strip_tags($content);
|
||||
}
|
||||
|
||||
$wordCount = str_word_count(strip_tags($text));
|
||||
$totalWords += $wordCount;
|
||||
|
||||
if ($wordCount > $longestChapter['words']) {
|
||||
$longestChapter = [
|
||||
'words' => $wordCount,
|
||||
'story' => $story,
|
||||
'chapter' => $chapter
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->stats = [
|
||||
'total_stories' => count($this->stories),
|
||||
'total_chapters' => $totalChapters,
|
||||
'total_words' => $totalWords,
|
||||
'avg_chapters_per_story' => $this->stories ? round($totalChapters / count($this->stories), 1) : 0,
|
||||
'avg_words_per_chapter' => $totalChapters ? round($totalWords / $totalChapters) : 0,
|
||||
'most_chapters' => [
|
||||
'story' => $storyWithMostChapters,
|
||||
'count' => $maxChapters
|
||||
],
|
||||
'longest_chapter' => $longestChapter,
|
||||
'latest_update' => [
|
||||
'story' => $mostRecentStory,
|
||||
'date' => $lastUpdate
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function getStats() {
|
||||
return $this->stats;
|
||||
}
|
||||
|
||||
private function isJson($string) {
|
||||
json_decode($string);
|
||||
return json_last_error() === JSON_ERROR_NONE;
|
||||
}
|
||||
|
||||
// Méthodes utilitaires pour le formatage
|
||||
public static function formatNumber($number) {
|
||||
return number_format($number, 0, ',', ' ');
|
||||
}
|
||||
|
||||
public static function formatDate($timestamp) {
|
||||
if (!$timestamp) return 'Jamais';
|
||||
return date('d/m/Y', $timestamp);
|
||||
}
|
||||
}
|
@ -31,6 +31,10 @@ class Config {
|
||||
return $config[$key] ?? $default;
|
||||
}
|
||||
|
||||
public static function fixImagePaths($content) {
|
||||
return preg_replace('/(src|url)=(["\'])\.\.\//i', '$1=$2', $content);
|
||||
}
|
||||
|
||||
public static function save($newConfig) {
|
||||
$configFile = __DIR__ . '/../config.json';
|
||||
|
||||
|
@ -1,8 +1,8 @@
|
||||
<?php
|
||||
class SiteUploadHandler {
|
||||
private $uploadDir;
|
||||
private $allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
|
||||
private $maxFileSize = 2097152; // 2MB
|
||||
private $allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
private $maxFileSize = 5242880; // 5MB
|
||||
|
||||
public function __construct() {
|
||||
$this->uploadDir = __DIR__ . '/../assets/images/site/';
|
||||
@ -18,11 +18,11 @@ class SiteUploadHandler {
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mimeType = $finfo->file($file['tmp_name']);
|
||||
if (!in_array($mimeType, $this->allowedTypes)) {
|
||||
throw new Exception('Type de fichier non autorisé. Types acceptés : JPG, PNG, GIF, SVG');
|
||||
throw new Exception('Type de fichier non autorisé. Types acceptés : JPG, PNG, GIF, WEBP');
|
||||
}
|
||||
|
||||
if ($file['size'] > $this->maxFileSize) {
|
||||
throw new Exception('Fichier trop volumineux. Taille maximum : 2MB');
|
||||
throw new Exception('Fichier trop volumineux. Taille maximum : 5MB');
|
||||
}
|
||||
|
||||
$extension = $this->getExtensionFromMimeType($mimeType);
|
||||
@ -46,6 +46,103 @@ class SiteUploadHandler {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function handleBackgroundUpload($file) {
|
||||
try {
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
throw new Exception($this->getUploadErrorMessage($file['error']));
|
||||
}
|
||||
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mimeType = $finfo->file($file['tmp_name']);
|
||||
if (!in_array($mimeType, $this->allowedTypes)) {
|
||||
throw new Exception('Type de fichier non autorisé. Types acceptés : JPG, PNG, GIF, WEBP');
|
||||
}
|
||||
|
||||
if ($file['size'] > $this->maxFileSize) {
|
||||
throw new Exception('Fichier trop volumineux. Taille maximum : 5MB');
|
||||
}
|
||||
|
||||
// Vérifier les dimensions de l'image
|
||||
list($width, $height) = getimagesize($file['tmp_name']);
|
||||
$minWidth = 1200;
|
||||
$minHeight = 250;
|
||||
|
||||
if ($width < $minWidth || $height < $minHeight) {
|
||||
throw new Exception("L'image doit faire au moins {$minWidth}x{$minHeight} pixels");
|
||||
}
|
||||
|
||||
$extension = $this->getExtensionFromMimeType($mimeType);
|
||||
$filename = 'about-bg.' . $extension;
|
||||
$targetPath = $this->uploadDir . $filename;
|
||||
|
||||
// Supprimer l'ancien background s'il existe
|
||||
$this->removeOldBackground();
|
||||
|
||||
// Redimensionner l'image si nécessaire
|
||||
$maxWidth = 1920;
|
||||
$maxHeight = 1080;
|
||||
|
||||
if ($width > $maxWidth || $height > $maxHeight) {
|
||||
$this->resizeImage(
|
||||
$file['tmp_name'],
|
||||
$targetPath,
|
||||
$mimeType,
|
||||
$maxWidth,
|
||||
$maxHeight
|
||||
);
|
||||
} else {
|
||||
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
|
||||
throw new Exception('Erreur lors du déplacement du fichier uploadé');
|
||||
}
|
||||
}
|
||||
|
||||
return 'assets/images/site/' . $filename;
|
||||
|
||||
} catch (Exception $e) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function removeOldBackground() {
|
||||
foreach (glob($this->uploadDir . 'about-bg.*') as $file) {
|
||||
unlink($file);
|
||||
}
|
||||
}
|
||||
|
||||
private function resizeImage($sourcePath, $targetPath, $mimeType, $maxWidth, $maxHeight) {
|
||||
list($width, $height) = getimagesize($sourcePath);
|
||||
|
||||
// Calculer les nouvelles dimensions en conservant le ratio
|
||||
$ratio = min($maxWidth / $width, $maxHeight / $height);
|
||||
$newWidth = round($width * $ratio);
|
||||
$newHeight = round($height * $ratio);
|
||||
|
||||
// Créer la nouvelle image
|
||||
$sourceImage = $this->createImageFromFile($sourcePath, $mimeType);
|
||||
$newImage = imagecreatetruecolor($newWidth, $newHeight);
|
||||
|
||||
// Préserver la transparence pour PNG
|
||||
if ($mimeType === 'image/png') {
|
||||
imagealphablending($newImage, false);
|
||||
imagesavealpha($newImage, true);
|
||||
}
|
||||
|
||||
// Redimensionner
|
||||
imagecopyresampled(
|
||||
$newImage, $sourceImage,
|
||||
0, 0, 0, 0,
|
||||
$newWidth, $newHeight,
|
||||
$width, $height
|
||||
);
|
||||
|
||||
// Sauvegarder l'image redimensionnée
|
||||
$this->saveImage($newImage, $targetPath, $mimeType);
|
||||
|
||||
// Libérer la mémoire
|
||||
imagedestroy($sourceImage);
|
||||
imagedestroy($newImage);
|
||||
}
|
||||
|
||||
private function ensureUploadDirectory() {
|
||||
if (!file_exists($this->uploadDir)) {
|
||||
@ -113,8 +210,25 @@ class SiteUploadHandler {
|
||||
return imagecreatefrompng($file);
|
||||
case 'image/gif':
|
||||
return imagecreatefromgif($file);
|
||||
case 'image/webp':
|
||||
return imagecreatefromwebp($file);
|
||||
default:
|
||||
throw new Exception('Type d\'image non supporté pour le favicon');
|
||||
throw new Exception('Type d\'image non supporté');
|
||||
}
|
||||
}
|
||||
|
||||
private function saveImage($image, $path, $mimeType) {
|
||||
switch ($mimeType) {
|
||||
case 'image/jpeg':
|
||||
return imagejpeg($image, $path, 85);
|
||||
case 'image/png':
|
||||
return imagepng($image, $path, 9);
|
||||
case 'image/gif':
|
||||
return imagegif($image, $path);
|
||||
case 'image/webp':
|
||||
return imagewebp($image, $path, 85);
|
||||
default:
|
||||
throw new Exception('Type d\'image non supporté');
|
||||
}
|
||||
}
|
||||
|
||||
@ -123,9 +237,9 @@ class SiteUploadHandler {
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/svg+xml' => 'svg'
|
||||
'image/webp' => 'webp'
|
||||
];
|
||||
return $map[$mimeType] ?? 'png';
|
||||
return $map[$mimeType] ?? 'jpg';
|
||||
}
|
||||
|
||||
private function getUploadErrorMessage($error) {
|
||||
|
29
index.php
29
index.php
@ -61,6 +61,10 @@ function formatDate($date) {
|
||||
<h1><?= htmlspecialchars($config['site']['name']) ?></h1>
|
||||
<p><?= nl2br(htmlspecialchars($config['site']['description'])) ?></p>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
<a href="about.php" class="about-button">À propos</a>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
@ -87,5 +91,30 @@ function formatDate($date) {
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<button class="scroll-top" aria-label="Retour en haut de page">↑</button>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const scrollTopBtn = document.querySelector('.scroll-top');
|
||||
|
||||
// Afficher/masquer le bouton
|
||||
window.addEventListener('scroll', function() {
|
||||
if (window.pageYOffset > 300) {
|
||||
scrollTopBtn.classList.add('visible');
|
||||
} else {
|
||||
scrollTopBtn.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
// Action de retour en haut
|
||||
scrollTopBtn.addEventListener('click', function() {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
109
roman.php
109
roman.php
@ -16,89 +16,6 @@ if (!$story) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Fonction pour convertir le contenu Delta en HTML
|
||||
function deltaToHtml($content) {
|
||||
if (empty($content)) return '';
|
||||
|
||||
// Si le contenu est déjà en HTML
|
||||
if (is_string($content) && !isJson($content)) {
|
||||
// Nettoyer les URLs des images dans le HTML
|
||||
return preg_replace_callback(
|
||||
'/<img[^>]+src=([\'"])((?:\.\.\/)*(?:assets\/[^"\']+))\1[^>]*>/',
|
||||
function($matches) {
|
||||
// $matches[2] contient l'URL
|
||||
$cleanUrl = preg_replace('/^(?:\.\.\/)+/', '', $matches[2]);
|
||||
return str_replace($matches[2], $cleanUrl, $matches[0]);
|
||||
},
|
||||
$content
|
||||
);
|
||||
}
|
||||
|
||||
// Convertir la chaîne JSON en tableau si nécessaire
|
||||
if (is_string($content)) {
|
||||
$delta = json_decode($content, true);
|
||||
} else {
|
||||
$delta = $content;
|
||||
}
|
||||
|
||||
if (!isset($delta['ops'])) return '';
|
||||
|
||||
$html = '';
|
||||
foreach ($delta['ops'] as $op) {
|
||||
if (is_string($op['insert'])) {
|
||||
$text = htmlspecialchars($op['insert']);
|
||||
|
||||
// Gérer les styles de texte
|
||||
if (isset($op['attributes'])) {
|
||||
if (!empty($op['attributes']['bold'])) {
|
||||
$text = "<strong>{$text}</strong>";
|
||||
}
|
||||
if (!empty($op['attributes']['italic'])) {
|
||||
$text = "<em>{$text}</em>";
|
||||
}
|
||||
if (!empty($op['attributes']['underline'])) {
|
||||
$text = "<u>{$text}</u>";
|
||||
}
|
||||
// Ajouter d'autres styles si nécessaire
|
||||
}
|
||||
|
||||
// Convertir les retours à la ligne en paragraphes
|
||||
if ($text === "\n") {
|
||||
$html .= "<br>";
|
||||
} else {
|
||||
$html .= $text;
|
||||
}
|
||||
}
|
||||
// Gérer les images
|
||||
elseif (is_array($op['insert']) && isset($op['insert']['image'])) {
|
||||
$imageUrl = $op['insert']['image'];
|
||||
error_log('URL originale: ' . $imageUrl);
|
||||
// Retirer tous les "../" au début de l'URL
|
||||
$imageUrl = preg_replace('/^(?:\.\.\/)+/', '', $imageUrl);
|
||||
error_log('URL nettoyée: ' . $imageUrl);
|
||||
$html .= "<img src=\"{$imageUrl}\" alt=\"Image du chapitre\">";
|
||||
}
|
||||
}
|
||||
|
||||
// Envelopper le contenu dans des balises p si nécessaire
|
||||
if (!empty($html)) {
|
||||
$paragraphs = explode("\n\n", $html);
|
||||
$html = '';
|
||||
foreach ($paragraphs as $p) {
|
||||
if (trim($p) !== '') {
|
||||
$html .= "<p>{$p}</p>";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
function isJson($string) {
|
||||
json_decode($string);
|
||||
return json_last_error() === JSON_ERROR_NONE;
|
||||
}
|
||||
|
||||
$config = Config::load();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
@ -113,6 +30,7 @@ $config = Config::load();
|
||||
<?php endif; ?>
|
||||
|
||||
<link rel="stylesheet" href="assets/css/public.css">
|
||||
<link rel="stylesheet" href="assets/css/content.css">
|
||||
|
||||
<meta name="description" content="<?= htmlspecialchars(strip_tags($story['description'])) ?>">
|
||||
</head>
|
||||
@ -128,7 +46,7 @@ $config = Config::load();
|
||||
<!-- Contenu principal -->
|
||||
<div class="novel-content">
|
||||
<div class="novel-description">
|
||||
<?= deltaToHtml($story['description']) ?>
|
||||
<?= $story['description'] = Config::fixImagePaths($story['description']); ?>
|
||||
</div>
|
||||
|
||||
<aside class="chapters-menu">
|
||||
@ -154,5 +72,28 @@ $config = Config::load();
|
||||
<a href="index.php">← Retour à l'accueil</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<button class="scroll-top" aria-label="Retour en haut de page">↑</button>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const scrollTopBtn = document.querySelector('.scroll-top');
|
||||
|
||||
window.addEventListener('scroll', function() {
|
||||
if (window.pageYOffset > 300) {
|
||||
scrollTopBtn.classList.add('visible');
|
||||
} else {
|
||||
scrollTopBtn.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
scrollTopBtn.addEventListener('click', function() {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@ -1 +1 @@
|
||||
1.0.0
|
||||
1.1.7
|
Loading…
x
Reference in New Issue
Block a user