Compare commits

...

13 Commits
1.1.2 ... main

23 changed files with 1593 additions and 603 deletions

154
about.php
View File

@ -1,6 +1,5 @@
<?php
require_once 'includes/config.php';
require_once 'includes/DeltaConverter.php';
require_once 'includes/Stats.php';
$config = Config::load();
@ -13,11 +12,6 @@ $about = $config['about'] ?? [
// Charger les statistiques
$stats = new Stats();
$siteStats = $stats->getStats();
// Fonction pour convertir le contenu Delta en HTML
function deltaToHtml($content) {
return DeltaConverter::toHtml($content);
}
?>
<!DOCTYPE html>
<html lang="fr">
@ -43,77 +37,97 @@ function deltaToHtml($content) {
</header>
<!-- Contenu principal -->
<div class="novel-content">
<div class="novel-description">
<?= deltaToHtml($about['content']) ?>
<div class="about-content">
<div class="about-description">
<?= $about['content'] = Config::fixImagePaths($about['content']); ?>
</div>
<aside class="stats-menu">
<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>
<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; ?>
<!-- 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>
<!-- 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 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>
<!-- 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>
<!-- 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; ?>
<!-- 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>
<!-- 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; ?>
<!-- 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; ?>
<!-- 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>
<!-- 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>

View 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
View 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()
]);
}

View File

@ -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;
}

View File

@ -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"}]}';

View File

@ -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);
}

View File

@ -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);

View 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()
]);
}

View File

@ -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) {

View File

@ -25,6 +25,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$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');
@ -92,10 +106,10 @@ $config = Config::load();
<?php endif; ?>
<div class="options-container">
<!-- Section Options du Site -->
<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"
@ -117,8 +131,7 @@ $config = Config::load();
<?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;">
alt="Logo actuel">
</div>
<?php endif; ?>
<input type="file"
@ -144,8 +157,7 @@ $config = Config::load();
<?php if (!empty($config['about']['background'])): ?>
<div class="current-background">
<img src="<?= htmlspecialchars('../' . $config['about']['background']) ?>"
alt="Image de fond actuelle"
style="max-height: 100px; margin: 10px 0;">
alt="Image de fond actuelle">
</div>
<?php endif; ?>
<input type="file"
@ -158,146 +170,63 @@ $config = Config::load();
<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"></div>
<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>
<button type="submit" class="button">Enregistrer les modifications</button>
</form>
</section>
</div>
</main>
<script src="https://cdn.quilljs.com/1.3.6/quill.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// 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);
}
});
// Prévisualisation du background
document.getElementById('about_background').addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function(e) {
let currentBackground = document.querySelector('.current-background');
if (!currentBackground) {
currentBackground = document.createElement('div');
currentBackground.className = 'current-background';
e.target.parentElement.insertBefore(currentBackground, e.target.nextSibling);
}
currentBackground.innerHTML = `
<img src="${e.target.result}"
alt="Aperçu du fond"
style="max-height: 100px; margin: 10px 0;">
`;
};
reader.readAsDataURL(file);
}
});
// 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/upload-image.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);
aboutEditor.insertEmbed(range.index, 'image', result.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...'
});
// Initialiser le contenu si existant
<?php if (!empty($config['about']['content'])): ?>
aboutEditor.root.innerHTML = <?= json_encode($config['about']['content']) ?>;
<?php endif; ?>
// 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 = '';
}
});
});
</script>
<script src="../assets/js/options.js"></script>
<link rel="stylesheet" href="../assets/css/dialog.css">
<script src="../assets/js/dialog.js"></script>
</body>

View File

@ -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>
@ -161,6 +162,31 @@ function generateSlug($title) {
</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>

View File

@ -306,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 {

View File

@ -2,7 +2,8 @@
/* Conteneur principal du contenu */
.chapter-content,
.novel-description {
.novel-description,
.about-description {
font-size: 1.1rem;
line-height: 1.8;
color: var(--text-primary);
@ -24,34 +25,47 @@
.novel-description h3,
.novel-description h4,
.novel-description h5,
.novel-description h6 {
.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 { font-size: 2em; }
.novel-description h1,
.about-description h1 { font-size: 2em; }
.chapter-content h2,
.novel-description h2 { font-size: 1.75em; }
.novel-description h2,
.about-description h2 { font-size: 1.75em; }
.chapter-content h3,
.novel-description h3 { font-size: 1.5em; }
.novel-description h3,
.about-description h3 { font-size: 1.5em; }
.chapter-content h4,
.novel-description h4 { font-size: 1.25em; }
.novel-description h4,
.about-description h4 { font-size: 1.25em; }
.chapter-content h5,
.novel-description h5 { font-size: 1.1em; }
.novel-description h5,
.about-description h5 { font-size: 1.1em; }
.chapter-content h6,
.novel-description h6 { font-size: 1em; }
.novel-description h6,
.about-description h6 { font-size: 1em; }
/* Paragraphes et espacement */
.chapter-content p,
.novel-description p {
margin: 0 0 1.5em 0;
.novel-description p,
.about-description p {
margin: 0em 0;
min-height: 1.5em;
}
@ -59,21 +73,25 @@
.chapter-content ul,
.chapter-content ol,
.novel-description ul,
.novel-description ol {
.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 {
.novel-description li,
.about-description li {
margin: 0.5em 0;
padding-left: 0.5em;
}
/* Citations */
.chapter-content blockquote,
.novel-description blockquote {
.novel-description blockquote,
.about-description blockquote {
margin: 1.5em 0;
padding: 1em 1.5em;
border-left: 4px solid var(--accent-primary);
@ -85,7 +103,8 @@
/* Blocs de code */
.chapter-content pre,
.novel-description pre {
.novel-description pre,
.about-description pre {
margin: 1.5em 0;
padding: 1em;
background-color: var(--bg-secondary);
@ -97,7 +116,8 @@
}
.chapter-content code,
.novel-description code {
.novel-description code,
.about-description code {
font-family: "Consolas", "Monaco", monospace;
font-size: 0.9em;
padding: 0.2em 0.4em;
@ -106,28 +126,109 @@
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 img,
.novel-description img {
.chapter-content p img,
.novel-description p img,
.about-description p img {
max-width: 100%;
height: auto;
margin: 1.5em 0;
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;
}
/* Alignements */
.chapter-content [style*="text-align"],
.novel-description [style*="text-align"],
.novel-description p[style*="text-align"] {
display: block !important;
margin: 1em 0 !important;
.ql-align-right p img {
margin-left: auto;
margin-right: 0;
display: block;
}
.novel-description .font-serif,
.novel-description .font-sans,
.novel-description .font-mono {
display: inline-block !important;
/* 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 */
@ -143,68 +244,37 @@
font-family: "Consolas", "Monaco", monospace !important;
}
/* 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;
}
/* Styles pour les liens */
.chapter-content a,
.novel-description a {
color: var(--accent-primary);
text-decoration: none;
transition: color var(--transition-fast);
}
.chapter-content a:hover,
.novel-description a:hover {
color: var(--accent-secondary);
text-decoration: underline;
}
/* Styles pour les indices et exposants */
.chapter-content sub,
.novel-description sub {
vertical-align: sub;
font-size: smaller;
}
.chapter-content sup,
.novel-description sup {
vertical-align: super;
font-size: smaller;
}
/* Media queries pour le responsive */
@media (max-width: 768px) {
.chapter-content,
.novel-description {
.novel-description,
.about-description {
font-size: 1rem;
}
.chapter-content blockquote,
.novel-description blockquote {
.novel-description blockquote,
.about-description blockquote {
margin: 1em 0;
padding: 0.8em 1em;
}
.chapter-content pre,
.novel-description pre {
.novel-description pre,
.about-description pre {
padding: 0.8em;
font-size: 0.85em;
}
.chapter-content h1,
.novel-description h1 { font-size: 1.75em; }
.novel-description h1,
.about-description h1 { font-size: 1.75em; }
.chapter-content h2,
.novel-description h2 { font-size: 1.5em; }
.novel-description h2,
.about-description h2 { font-size: 1.5em; }
.chapter-content h3,
.novel-description h3 { font-size: 1.25em; }
.novel-description h3,
.about-description h3 { font-size: 1.25em; }
}

View File

@ -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);
}
}

View File

@ -522,6 +522,129 @@ body {
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 {
@ -542,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) {
@ -610,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;
}
}

227
assets/js/options.js Normal file
View 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');
}
}
});
});
}
});

View File

@ -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
})
});

View File

@ -1,7 +1,6 @@
<?php
require_once 'includes/config.php';
require_once 'includes/stories.php';
require_once 'includes/DeltaConverter.php';
// Récupération des paramètres
$storyId = $_GET['story'] ?? '';
@ -35,16 +34,6 @@ if (!$currentChapter) {
exit;
}
// Fonction pour convertir le contenu Delta en HTML
function deltaToHtml($content) {
return DeltaConverter::toHtml($content);
}
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;
@ -68,15 +57,18 @@ $config = Config::load();
<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">
@ -112,7 +104,8 @@ $config = Config::load();
</div>
<div class="back-to-home">
<a href="index.php">&larr; Retour à l'accueil</a> | <a href="roman.php?id=<?= urlencode($storyId) ?>">&larr; Retour au roman</a>
<a href="index.php">&larr; Retour à l'accueil</a> |
<a href="roman.php?id=<?= urlencode($storyId) ?>">&larr; Retour au roman</a>
</div>
<button class="scroll-top" aria-label="Retour en haut de page"></button>
@ -121,7 +114,7 @@ $config = Config::load();
document.addEventListener('DOMContentLoaded', function() {
const scrollTopBtn = document.querySelector('.scroll-top');
// Afficher/masquer le bouton
// Afficher/masquer le bouton de retour en haut
window.addEventListener('scroll', function() {
if (window.pageYOffset > 300) {
scrollTopBtn.classList.add('visible');

View File

@ -6,7 +6,14 @@
"about": {
"title": "À propos",
"content": "",
"background": "assets/images/site/about-bg.jpg"
"background": "assets/images/site/about-bg.jpg",
"links": [
{
"title": "Mon Blog",
"url": "https://blog.example.com",
"target": "_blank"
}
]
},
"users": [
{

View File

@ -1,268 +0,0 @@
<?php
class DeltaConverter {
private static $blockAttrs = ['align', 'header', 'blockquote', 'code-block', 'list'];
private static $inlineAttrs = ['bold', 'italic', 'underline', 'strike', 'color', 'background', 'link', 'font', 'script'];
public static function toHtml($content) {
if (empty($content)) return '';
// Si le contenu est déjà en HTML
if (is_string($content) && !self::isJson($content)) {
// Convertir les classes ql-align-* en styles d'alignement
$content = preg_replace_callback(
'/class="ql-align-(justify|center|right)"/',
function($matches) {
return 'style="text-align: ' . $matches[1] . ' !important"';
},
$content
);
// Convertir les classes ql-font-* en classes de police
$content = preg_replace_callback(
'/class="ql-font-(serif|monospace|sans-serif)"/',
function($matches) {
switch($matches[1]) {
case 'serif':
return 'class="font-serif"';
case 'monospace':
return 'class="font-mono"';
case 'sans-serif':
return 'class="font-sans"';
default:
return '';
}
},
$content
);
return self::cleanImageUrls($content);
}
// Convertir la chaîne JSON en tableau si nécessaire
if (is_string($content)) {
$content = json_decode($content, true);
}
if (!isset($content['ops'])) return '';
$html = '';
$currentBlock = [
'content' => '',
'attrs' => [],
'inlineAttrs' => []
];
foreach ($content['ops'] as $op) {
$text = '';
$attrs = isset($op['attributes']) ? $op['attributes'] : [];
// Gérer l'insertion de texte
if (is_string($op['insert'])) {
$text = $op['insert'];
// Si c'est un saut de ligne, on finalise le bloc
if ($text === "\n") {
// Vérifier les attributs de bloc pour ce saut de ligne
if (!empty($attrs['align'])) {
$currentBlock['attrs']['text-align'] = $attrs['align'];
}
if (!empty($attrs['header'])) {
$currentBlock['attrs']['header'] = $attrs['header'];
}
if (!empty($attrs['blockquote'])) {
$currentBlock['attrs']['blockquote'] = true;
}
if (!empty($attrs['code-block'])) {
$currentBlock['attrs']['code-block'] = true;
}
// Finaliser le bloc courant
if (!empty($currentBlock['content']) || !empty($currentBlock['attrs'])) {
$html .= self::renderBlock($currentBlock);
}
// Réinitialiser pour le prochain bloc
$currentBlock = [
'content' => '',
'attrs' => [],
'inlineAttrs' => []
];
} else {
// Ajouter le texte avec ses attributs inline
$currentBlock['content'] .= $text;
if (!empty($attrs)) {
$currentBlock['inlineAttrs'][] = [
'start' => mb_strlen($currentBlock['content']) - mb_strlen($text),
'length' => mb_strlen($text),
'attrs' => $attrs
];
}
}
}
// Gérer les images
elseif (is_array($op['insert']) && isset($op['insert']['image'])) {
if (!empty($currentBlock['content'])) {
$html .= self::renderBlock($currentBlock);
$currentBlock = [
'content' => '',
'attrs' => [],
'inlineAttrs' => []
];
}
$imageUrl = self::cleanImageUrl($op['insert']['image']);
$imgAttributes = ['class' => 'chapter-image'];
if (isset($attrs['alt'])) {
$imgAttributes['alt'] = $attrs['alt'];
} else {
$imgAttributes['alt'] = "Image";
}
$html .= self::createImageTag($imageUrl, $imgAttributes);
}
}
// Finaliser le dernier bloc si nécessaire
if (!empty($currentBlock['content'])) {
$html .= self::renderBlock($currentBlock);
}
return $html;
}
private static function renderBlock($block) {
if (empty($block['content'])) return '';
$tag = 'p';
$styles = [];
$classes = [];
// Déterminer le tag et attributs de base
if (!empty($block['attrs']['header'])) {
$tag = 'h' . $block['attrs']['header'];
} elseif (!empty($block['attrs']['blockquote'])) {
$tag = 'blockquote';
} elseif (!empty($block['attrs']['code-block'])) {
$tag = 'pre';
$classes[] = 'code-block';
}
// Appliquer l'alignement au niveau du bloc
if (!empty($block['attrs']['text-align'])) {
$styles[] = "text-align: " . $block['attrs']['text-align'] . " !important";
}
// Construire le contenu avec les attributs inline
$content = $block['content'];
if (!empty($block['inlineAttrs'])) {
// Trier les attributs par position de fin pour les appliquer de la fin vers le début
usort($block['inlineAttrs'], function($a, $b) {
return ($a['start'] + $a['length']) - ($b['start'] + $b['length']);
});
// Appliquer les attributs inline
foreach (array_reverse($block['inlineAttrs']) as $attr) {
$start = $attr['start'];
$length = $attr['length'];
$segment = mb_substr($content, $start, $length);
if (!empty($attr['attrs'])) {
$segment = self::applyInlineAttributes($segment, $attr['attrs']);
}
$content = mb_substr($content, 0, $start) . $segment . mb_substr($content, $start + $length);
}
}
// Si c'est un bloc de code, wrapper dans code
if ($tag === 'pre') {
$content = "<code>$content</code>";
}
// Construire les attributs HTML finaux
$attributes = '';
if (!empty($styles)) {
$attributes .= ' style="' . implode('; ', array_unique($styles)) . '"';
}
if (!empty($classes)) {
$attributes .= ' class="' . implode(' ', array_unique($classes)) . '"';
}
return "<$tag$attributes>$content</$tag>";
}
private static function applyInlineAttributes($text, $attrs) {
$text = htmlspecialchars($text);
if (!empty($attrs['bold'])) {
$text = "<strong>$text</strong>";
}
if (!empty($attrs['italic'])) {
$text = "<em>$text</em>";
}
if (!empty($attrs['underline'])) {
$text = "<u>$text</u>";
}
if (!empty($attrs['strike'])) {
$text = "<s>$text</s>";
}
if (!empty($attrs['color'])) {
$text = "<span style=\"color: {$attrs['color']} !important\">$text</span>";
}
if (!empty($attrs['background'])) {
$text = "<span style=\"background-color: {$attrs['background']} !important\">$text</span>";
}
if (!empty($attrs['link'])) {
$text = "<a href=\"{$attrs['link']}\" target=\"_blank\">$text</a>";
}
if (!empty($attrs['font'])) {
switch($attrs['font']) {
case 'serif':
$text = "<span class=\"font-serif\">$text</span>";
break;
case 'monospace':
$text = "<span class=\"font-mono\">$text</span>";
break;
case 'sans-serif':
$text = "<span class=\"font-sans\">$text</span>";
break;
}
}
if (!empty($attrs['script'])) {
if ($attrs['script'] === 'super') $text = "<sup>$text</sup>";
if ($attrs['script'] === 'sub') $text = "<sub>$text</sub>";
}
return $text;
}
private static function isJson($string) {
json_decode($string);
return json_last_error() === JSON_ERROR_NONE;
}
private static function cleanImageUrl($url) {
return preg_replace('/^(?:\.\.\/)+/', '', $url);
}
private static function cleanImageUrls($html) {
return preg_replace_callback(
'/<img[^>]+src=([\'"])((?:\.\.\/)*(?:assets\/[^"\']+))\1[^>]*>/',
function($matches) {
$cleanUrl = self::cleanImageUrl($matches[2]);
return str_replace($matches[2], $cleanUrl, $matches[0]);
},
$html
);
}
private static function createImageTag($src, $attributes) {
$html = '<img src="' . htmlspecialchars($src) . '"';
foreach ($attributes as $key => $value) {
$html .= ' ' . $key . '="' . htmlspecialchars($value) . '"';
}
$html .= '>';
return $html;
}
}

View File

@ -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';

View File

@ -1,7 +1,6 @@
<?php
require_once 'includes/config.php';
require_once 'includes/stories.php';
require_once 'includes/DeltaConverter.php';
// Récupération de l'ID du roman depuis l'URL
$storyId = $_GET['id'] ?? '';
@ -17,16 +16,6 @@ if (!$story) {
exit;
}
// Fonction pour convertir le contenu Delta en HTML
function deltaToHtml($content) {
return DeltaConverter::toHtml($content);
}
function isJson($string) {
json_decode($string);
return json_last_error() === JSON_ERROR_NONE;
}
$config = Config::load();
?>
<!DOCTYPE html>
@ -57,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">
@ -90,7 +79,6 @@ $config = Config::load();
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');
@ -99,7 +87,6 @@ $config = Config::load();
}
});
// Action de retour en haut
scrollTopBtn.addEventListener('click', function() {
window.scrollTo({
top: 0,

View File

@ -1 +1 @@
1.1.2
1.1.7