ajout de la possibilité d'ajouter des couvertures aux chapitres
This commit is contained in:
parent
2a6ae05837
commit
3660e796ee
@ -41,6 +41,11 @@ try {
|
|||||||
$chapter['title'] = '';
|
$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
|
// Conversion du contenu HTML en format Delta de Quill si nécessaire
|
||||||
if (!isset($chapter['content'])) {
|
if (!isset($chapter['content'])) {
|
||||||
$chapter['content'] = '{"ops":[{"insert":"\n"}]}';
|
$chapter['content'] = '{"ops":[{"insert":"\n"}]}';
|
||||||
|
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()
|
||||||
|
]);
|
||||||
|
}
|
@ -136,6 +136,7 @@ function generateSlug($title) {
|
|||||||
<h3 class="chapter-title"><?= htmlspecialchars($chapter['title']) ?></h3>
|
<h3 class="chapter-title"><?= htmlspecialchars($chapter['title']) ?></h3>
|
||||||
<div class="chapter-actions">
|
<div class="chapter-actions">
|
||||||
<button type="button" class="button edit-chapter">Éditer</button>
|
<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>
|
<button type="button" class="button delete-chapter">Supprimer</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -161,6 +162,31 @@ function generateSlug($title) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?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>
|
</main>
|
||||||
|
|
||||||
<script src="https://cdn.quilljs.com/1.3.6/quill.min.js"></script>
|
<script src="https://cdn.quilljs.com/1.3.6/quill.min.js"></script>
|
||||||
|
@ -306,6 +306,28 @@
|
|||||||
animation: modalFadeIn 0.3s ease-out;
|
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 queries */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.story-cover {
|
.story-cover {
|
||||||
|
@ -127,6 +127,154 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const chapterTitleInput = document.getElementById('chapterTitle');
|
const chapterTitleInput = document.getElementById('chapterTitle');
|
||||||
const chaptersList = document.getElementById('chaptersList');
|
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
|
// Configuration de Sortable pour la réorganisation des chapitres
|
||||||
if (chaptersList) {
|
if (chaptersList) {
|
||||||
new Sortable(chaptersList, {
|
new Sortable(chaptersList, {
|
||||||
|
@ -57,8 +57,11 @@ $config = Config::load();
|
|||||||
<meta name="description" content="<?= htmlspecialchars($story['title']) ?> - <?= htmlspecialchars($currentChapter['title']) ?>">
|
<meta name="description" content="<?= htmlspecialchars($story['title']) ?> - <?= htmlspecialchars($currentChapter['title']) ?>">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- En-tête simple -->
|
<!-- En-tête avec image de couverture si disponible -->
|
||||||
<header class="novel-header" style="height: 180px;">
|
<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>
|
<h1><?= htmlspecialchars($currentChapter['title']) ?></h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user