// app/(client)/chapter-viewing-page/page.js 'use client'; import React, { useState, useEffect, Suspense, useRef } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import axios from 'axios'; import Link from 'next/link'; import { useClientAuth } from '@/lib/clientAuth'; import throttle from 'lodash/throttle'; // MUI Components import Container from '@mui/material/Container'; import Paper from '@mui/material/Paper'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Grid from '@mui/material/Grid'; import Card from '@mui/material/Card'; import CardMedia from '@mui/material/CardMedia'; import CardContent from '@mui/material/CardContent'; import CardActionArea from '@mui/material/CardActionArea'; import CircularProgress from '@mui/material/CircularProgress'; import Alert from '@mui/material/Alert'; import ImageIcon from '@mui/icons-material/Image'; import MusicNoteIcon from '@mui/icons-material/MusicNote'; import TheatersIcon from '@mui/icons-material/Theaters'; function ChapterView() { const searchParams = useSearchParams(); const router = useRouter(); const { clientUser, clientToken, isClientLoggedIn } = useClientAuth(); // Use correct context const mediaRef = useRef(null); // Renamed from videoRef for clarity const hasSetInitialTime = useRef(false); const saveProgressErrorTimeout = useRef(null); // Ref for error timeout const [currentChapter, setCurrentChapter] = useState({ id: null, book_id: null, audioVideo: '', name: 'Chapter', description: '', duration: null // Add duration }); const [relatedChapters, setRelatedChapters] = useState([]); const [isLoading, setIsLoading] = useState(true); // Combined loading state const [error, setError] = useState(null); const [progressError, setProgressError] = useState(null); // Separate state for progress errors const [currentBookName, setCurrentBookName] = useState(''); // --- Save Progress Function (Throttled) --- const saveProgress = useRef(throttle(async (currentTime) => { if (!isClientLoggedIn() || !currentChapter.id || !currentChapter.book_id || !clientUser?.id || !clientToken) { return; } // Clear previous progress error messages immediately on new attempt setProgressError(null); if (saveProgressErrorTimeout.current) clearTimeout(saveProgressErrorTimeout.current); try { await axios.post('/api/viewing-progress', { bookId: currentChapter.book_id, chapterId: currentChapter.id, currentTime: currentTime, }, { headers: { Authorization: `Bearer ${clientToken}` } // <-- Send Token }); // console.log('Progress saved.'); } catch (err) { const errMsg = err.response?.data?.message || err.message || 'Failed to save progress.'; console.error('Failed to save progress:', errMsg); setProgressError(`Couldn't save progress: ${errMsg}`); // Show specific error // Clear error message after a delay saveProgressErrorTimeout.current = setTimeout(() => setProgressError(null), 7000); } }, 5000)).current; // Save max once every 5 seconds // --- Fetch Data Effect --- useEffect(() => { const audioVideo = searchParams.get('audioVideo') ?? ''; const chapterName = searchParams.get('chapterName') ?? ''; // Empty default better for check const timestamp = searchParams.get('timestamp'); if (!chapterName || !audioVideo) { setError('Missing chapter details in URL.'); setIsLoading(false); setRelatedChapters([]); setCurrentChapter({ id: null, book_id: null, audioVideo: '', name: 'Chapter', description: '', duration: null }); setCurrentBookName(''); return; // Stop execution if params are missing } window.scrollTo(0, 0); hasSetInitialTime.current = false; setIsLoading(true); // Set loading true at the start of fetch setError(null); // Clear previous errors setProgressError(null); // Clear progress errors too if (saveProgressErrorTimeout.current) clearTimeout(saveProgressErrorTimeout.current); const fetchData = async () => { try { // Fetch current chapter (including duration) const currentChapterResponse = await axios.get(`/api/book-chapters?chapterName=${encodeURIComponent(chapterName)}&chapterAudioVideo=${encodeURIComponent(audioVideo)}&limit=1`); if (!currentChapterResponse.data || currentChapterResponse.data.length === 0) { throw new Error("Current chapter details not found."); } const currentChapterData = currentChapterResponse.data[0]; const bookId = currentChapterData.book_name_id; setCurrentChapter({ // Update state with fetched data id: currentChapterData.id, book_id: bookId, audioVideo: currentChapterData.chapter_audio_video, name: currentChapterData.chapter_name, description: currentChapterData.chapter_description, duration: currentChapterData.duration // Store duration }); setCurrentBookName(currentChapterData.book_name || ''); // Fetch related chapters const relatedResponse = await axios.get(`/api/book-chapters?bookId=${bookId}`); let chaptersData = relatedResponse.data; chaptersData = chaptersData.filter(chap => chap.chapter_audio_video !== audioVideo); // Sort chapters (ensure robust sorting) chaptersData.sort((a, b) => { const seqOrder = { first: 1, others: 2, last: 3 }; const orderA = seqOrder[a.chapter_sequence] || 2; const orderB = seqOrder[b.chapter_sequence] || 2; if (orderA !== orderB) return orderA - orderB; return (a.id || 0) - (b.id || 0); // Fallback sort by ID }); setRelatedChapters(chaptersData); } catch (err) { console.error('Error fetching chapter/related data:', err); setError(err.response?.data?.message || err.message || 'Could not load chapter data.'); setRelatedChapters([]); setCurrentBookName(''); setCurrentChapter({ id: null, book_id: null, audioVideo: '', name: 'Chapter', description: '', duration: null }); } finally { setIsLoading(false); // Set loading false after fetch completes/fails } }; fetchData(); return () => { saveProgress.cancel(); if (saveProgressErrorTimeout.current) clearTimeout(saveProgressErrorTimeout.current); }; }, [searchParams, saveProgress, isClientLoggedIn, clientToken, clientUser?.id]); // Dependencies // --- Media Event Listeners Effect --- useEffect(() => { const mediaElement = mediaRef.current; if (!mediaElement || !isClientLoggedIn()) return; const handleTimeUpdate = () => { if (mediaElement.currentTime > 0 && !mediaElement.paused && !mediaElement.seeking) { saveProgress(mediaElement.currentTime); } }; const handlePause = () => { if (mediaElement.currentTime > 0) { saveProgress.cancel(); // Cancel throttled call saveProgress(mediaElement.currentTime); // Save immediately } } const handleMetadataLoaded = () => { const timestamp = searchParams.get('timestamp'); // console.log("Metadata loaded, current time:", mediaElement.currentTime, "Timestamp param:", timestamp); if (timestamp && !isNaN(parseFloat(timestamp)) && !hasSetInitialTime.current) { const timeToSet = parseFloat(timestamp); // Only set if the desired time is significantly different from current time if (Math.abs(mediaElement.currentTime - timeToSet) > 1) { // console.log("Setting initial time to:", timeToSet); mediaElement.currentTime = timeToSet; } hasSetInitialTime.current = true; } }; mediaElement.addEventListener('timeupdate', handleTimeUpdate); mediaElement.addEventListener('pause', handlePause); mediaElement.addEventListener('loadedmetadata', handleMetadataLoaded); return () => { mediaElement.removeEventListener('timeupdate', handleTimeUpdate); mediaElement.removeEventListener('pause', handlePause); mediaElement.removeEventListener('loadedmetadata', handleMetadataLoaded); saveProgress.cancel(); if (saveProgressErrorTimeout.current) clearTimeout(saveProgressErrorTimeout.current); }; // Rerun if chapter changes or login status changes }, [isClientLoggedIn, saveProgress, currentChapter.id, currentChapter.book_id, searchParams]); // Determine media type and URL const isVideo = currentChapter.audioVideo?.match(/\.(mp4|mov|avi|webm|mkv)$/i); const isAudio = currentChapter.audioVideo?.match(/\.(mp3|wav|ogg|aac|m4a)$/i); const mediaUrl = currentChapter.audioVideo ? `/uploads/audio-video/${currentChapter.audioVideo}` : null; // Render logic if (isLoading) { return ; } return ( {/* Breadcrumbs & Title */} {currentBookName && ( Home / {currentBookName} )} {currentChapter.name || 'Chapter'} {/* Main Error Alert */} {error && {error}} {/* Progress Saving Error Alert */} {progressError && setProgressError(null)}>{progressError}} {/* Media Player */} {mediaUrl && !error ? ( // Only render player if URL exists and no main error isVideo ? ( ) : isAudio ? ( ) : ( // Fallback for unrecognized format Media format not supported for playback Download Media ) ) : !error ? ( // Show loading only if no media URL and no error ) : null /* Don't show player box if main error exists */ } {/* Description and Related Chapters */} {!error && ( // Don't show description/related if the main chapter failed to load Description {currentChapter.description || 'No description available.'} Other Chapters in {currentBookName || 'this Book'} {relatedChapters.length > 0 ? ( {relatedChapters.map((chapter) => { const chapterImageUrl = chapter.chapter_image ? `/uploads/images/${chapter.chapter_image}` : '/images/placeholder-chapter.png'; // Reset timestamp when explicitly clicking another chapter const chapterUrl = `/chapter-viewing-page?audioVideo=${encodeURIComponent(chapter.chapter_audio_video)}&chapterName=${encodeURIComponent(chapter.chapter_name)}&chapterDescription=${encodeURIComponent(chapter.chapter_description || '')}&chapterImage=${encodeURIComponent(chapter.chapter_image || '')}×tamp=0`; return ( {chapter.chapter_name} ); })} ) : ( No other chapters found. )} )} ); } export default function ChapterViewingPage() { const SuspenseFallback = ( ); return ( ); } // app/(client)/components/BookCard.js 'use client'; import React, { useState } from 'react'; import Card from '@mui/material/Card'; import CardMedia from '@mui/material/CardMedia'; import CardContent from '@mui/material/CardContent'; import Typography from '@mui/material/Typography'; import Box from '@mui/material/Box'; import { styled } from '@mui/material/styles'; import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import LinearProgress from '@mui/material/LinearProgress'; // Keep progress bar // Keep formatTimeShort here or move to utils function formatTimeShort(totalSeconds) { /* ... implementation ... */ } // --- Styled Components (Keep CardOverlay, ProgressInfoBox) --- const CardOverlay = styled(Box)(({ theme }) => ({ /* ... styles ... */ })); const ProgressInfoBox = styled(Box)(({ theme }) => ({ /* ... styles ... */ })); const BookCard = ({ book, onClick, isProgressCard = false, progressTimestamp, progressChapterName, chapterDuration // <-- Receive duration }) => { const [isHovered, setIsHovered] = useState(false); const imageUrl = book.book_image ? `/uploads/images/${book.book_image}` : '/images/placeholder-book.png'; // --- Calculate Progress Percentage --- let progressPercent = 0; if (isProgressCard && chapterDuration && chapterDuration > 0 && progressTimestamp) { progressPercent = Math.min(100, Math.max(0, (progressTimestamp / chapterDuration) * 100)); } const formattedTime = isProgressCard ? formatTimeShort(progressTimestamp) : null; return ( setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} elevation={2} > {/* Hover Overlay */} {book.book_name} {(!isProgressCard || book.book_author) && ( {book.book_author || ''} )} {/* Progress Info */} {isProgressCard && ( {/* --- Display Progress Bar --- */} {progressChapterName ? `Ch: ${progressChapterName}` : ''} {formattedTime ? ` @ ${formattedTime}` : ''} )} ); }; export default BookCard; // app/(client)/components/BookChapterModal.js 'use client'; import React, { useState, useEffect, useCallback } from 'react'; import axios from 'axios'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; // Use Next Link for chapter navigation // MUI Components import Dialog from '@mui/material/Dialog'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; import IconButton from '@mui/material/IconButton'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Button from '@mui/material/Button'; import Grid from '@mui/material/Grid'; // For chapter layout import Card from '@mui/material/Card'; import CardMedia from '@mui/material/CardMedia'; import CardContent from '@mui/material/CardContent'; import CircularProgress from '@mui/material/CircularProgress'; import Alert from '@mui/material/Alert'; import Skeleton from '@mui/material/Skeleton'; // For loading state import Tooltip from '@mui/material/Tooltip'; // <--- ADD THIS IMPORT // MUI Icons import CloseIcon from '@mui/icons-material/Close'; import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import AddIcon from '@mui/icons-material/Add'; // Example Add to list icon import CheckIcon from '@mui/icons-material/Check'; // Example Added to list icon import ThumbUpAltOutlinedIcon from '@mui/icons-material/ThumbUpAltOutlined'; // Example Like icon const BookChapterModal = ({ book, onClose }) => { const [chapters, setChapters] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [addedToList, setAddedToList] = useState(false); // Example state const router = useRouter(); // Function to fetch chapters for the selected book const fetchChapters = useCallback(async () => { if (!book?.id) return; setIsLoading(true); setError(null); try { // Fetch chapters specifically for this book ID const response = await axios.get(`/api/book-chapters?bookId=${book.id}`); // Sort chapters, e.g., by sequence or ID const sortedChapters = response.data.sort((a, b) => { // Example sort: 'first', 'others' by id, 'last' const seqOrder = { first: 1, others: 2, last: 3 }; const orderA = seqOrder[a.chapter_sequence] || 2; const orderB = seqOrder[b.chapter_sequence] || 2; if (orderA !== orderB) return orderA - orderB; return a.id - b.id; // Fallback to ID for 'others' }); setChapters(sortedChapters); } catch (err) { console.error('Error fetching book chapters:', err); setError('Could not load chapters.'); setChapters([]); } finally { setIsLoading(false); } }, [book?.id]); // Dependency on book.id useEffect(() => { fetchChapters(); }, [fetchChapters]); // Fetch when the modal opens (book changes) // Close modal doesn't need body scroll handling if using MUI Dialog // useEffect(() => { // document.body.classList.add('modal-open'); // return () => { // document.body.classList.remove('modal-open'); // }; // }, []); const handlePlayFirstChapter = async () => { if (!book?.id) return; try { // Use the sorted chapters state if available and non-empty const firstChapter = chapters.length > 0 ? chapters[0] : null; if (firstChapter && firstChapter.chapter_audio_video) { handleChapterClick(firstChapter); // Use existing click handler } else { // Fallback or explicit fetch if needed (e.g., if chapters state isn't reliable yet) // console.log("First chapter not found in state, attempting fetch..."); // const response = await axios.get(`/api/book-chapters?bookId=${book.id}&chapterSequence=first&limit=1`); // const chapter = response.data[0]; // if (chapter && chapter.chapter_audio_video) { handleChapterClick(chapter); } console.log("No playable first chapter found for this book."); setError("No starting chapter available for this book."); } } catch (err) { console.error('Error getting first chapter:', err); setError("Could not load the first chapter."); } }; const handleChapterClick = (chapter) => { if (!chapter || !chapter.chapter_audio_video) { console.error("Invalid chapter data for navigation:", chapter); setError("Cannot play this chapter (invalid data)."); return; } const { chapter_audio_video, chapter_name, chapter_description, chapter_image } = chapter; const url = `/chapter-viewing-page?audioVideo=${encodeURIComponent(chapter_audio_video)}&chapterName=${encodeURIComponent(chapter_name)}&chapterDescription=${encodeURIComponent(chapter_description || '')}&chapterImage=${encodeURIComponent(chapter_image || '')}`; onClose(); // Close modal before navigating router.push(url); }; // Example action handler const handleAddToList = () => { setAddedToList(!addedToList); // TODO: Add API call to save to user's list }; // Helper to truncate description text const truncateDescription = (description, maxLength = 100) => { if (!description) return ''; if (description.length <= maxLength) return description; return description.substring(0, maxLength).trim() + '...'; }; const bookImageUrl = book?.book_image ? `/uploads/images/${book.book_image}` : '/images/placeholder-book.png'; // Fallback image return ( {/* Close Button */} {/* Dialog Content starts here, no padding */} {/* Top Section: Image/Video Banner + Basic Info */} {/* Background Image */} {/* Gradient Overlay */} {/* Content over Image */} {book?.book_name} {/* Buttons Row */} {addedToList ? : } {/* Main Content Area Below Banner */} {/* Book Description and Author */} {/* Description takes more space */} {book?.genre_name ? `${book.genre_name} | ` : ''}Author: {book?.book_author || 'N/A'} {book?.book_description || 'No description available.'} {/* Details on the side */} {/* Add more details like duration, rating etc. if available */} Chapters: {chapters.length} {/* Add more details */} {/* Error Display */} {error && {error}} {/* Chapters Section */} Book Chapters {isLoading ? ( ) : chapters.length > 0 ? ( {chapters.map((chapter, index) => { const chapterImageUrl = chapter.chapter_image ? `/uploads/images/${chapter.chapter_image}` : '/images/placeholder-chapter.png'; // Chapter placeholder return ( handleChapterClick(chapter)} elevation={0} // No shadow needed > {index + 1} {/* Adjust padding */} {chapter.chapter_name} {truncateDescription(chapter.chapter_description, 60)} ); })} ) : ( No chapters found for this book. )} ); }; export default BookChapterModal; // app/(client)/components/BookGenreModal.js 'use client'; import React, { useState, useEffect, useCallback } from 'react'; import axios from 'axios'; import { useRouter } from 'next/navigation'; // Keep router for potential navigation // MUI Components import Dialog from '@mui/material/Dialog'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; import IconButton from '@mui/material/IconButton'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Grid from '@mui/material/Grid'; import Card from '@mui/material/Card'; import CardMedia from '@mui/material/CardMedia'; import CardContent from '@mui/material/CardContent'; import CardActionArea from '@mui/material/CardActionArea'; // Make card clickable import CircularProgress from '@mui/material/CircularProgress'; import Alert from '@mui/material/Alert'; import Pagination from '@mui/material/Pagination'; // For pagination // MUI Icons import CloseIcon from '@mui/icons-material/Close'; import InfoIcon from '@mui/icons-material/Info'; // For empty state const BookGenreModal = ({ genreId, onClose, onBookClick }) => { const [books, setBooks] = useState([]); const [genreName, setGenreName] = useState(''); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [currentPage, setCurrentPage] = useState(1); const booksPerPage = 9; // Adjust number of books per page (3x3 grid) const router = useRouter(); // Function to fetch genre name and books const fetchData = useCallback(async () => { if (!genreId) return; setIsLoading(true); setError(null); setBooks([]); // Clear previous books setGenreName(''); setCurrentPage(1); // Reset page try { // Fetch genre details (including name) const genreResponse = await axios.get(`/api/book-genres/${genreId}`); if (genreResponse.data) { setGenreName(genreResponse.data.genre_name); } else { setGenreName('Unknown Genre'); throw new Error('Genre not found'); // Treat as error if genre doesn't exist } // Fetch all books // OPTIMIZATION: If API supports filtering by genre_id, use it: // const booksResponse = await axios.get(`/api/book-names?genreId=${genreId}`); // setBooks(booksResponse.data); // If not, filter client-side (as currently implemented): const booksResponse = await axios.get(`/api/book-names`); const allBooks = booksResponse.data; const filteredBooks = allBooks.filter(book => Number(book.genre_id) === Number(genreId)); setBooks(filteredBooks); } catch (err) { console.error('Error fetching genre data:', err); setError(err.message || 'Could not load books for this genre.'); } finally { setIsLoading(false); } }, [genreId]); useEffect(() => { fetchData(); // Modal opening/closing scroll lock handled by MUI Dialog }, [fetchData]); // Fetch when genreId changes // Pagination Logic const indexOfLastBook = currentPage * booksPerPage; const indexOfFirstBook = indexOfLastBook - booksPerPage; const currentBooks = books.slice(indexOfFirstBook, indexOfLastBook); const totalPages = Math.ceil(books.length / booksPerPage); const handlePageChange = (event, value) => { setCurrentPage(value); // Optionally scroll to top of modal content on page change const contentArea = document.querySelector('.MuiDialogContent-root'); // Find content area if (contentArea) contentArea.scrollTop = 0; }; const handleBookCardClick = (book) => { if (onBookClick) { onBookClick(book); // Call parent handler to open BookChapterModal // Don't close this modal automatically, let parent decide // onClose(); } }; // Helper to truncate description const truncateDescription = (description, maxLength) => { if (!description) return ''; return description.length > maxLength ? description.substring(0, maxLength).trim() + '...' : description; }; return ( Books in "{genreName || 'Loading...'}" Genre theme.palette.grey[500] }} > {isLoading ? ( ) : error ? ( {error} ) : books.length === 0 ? ( No books found in this genre yet. ) : ( {/* Responsive spacing */} {currentBooks.map((book) => { const imageUrl = book.book_image ? `/uploads/images/${book.book_image}` : '/images/placeholder-book.png'; return ( {/* Using a simplified card structure */} handleBookCardClick(book)} sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1 }}> {book.book_name} By {book.book_author || 'Unknown'} {truncateDescription(book.book_description, 60)} ); })} )} {/* Pagination Controls - Stick to bottom */} {totalPages > 1 && !isLoading && !error && books.length > 0 && ( )} ); }; export default BookGenreModal; // app/(client)/components/BookSearchModal.js 'use client'; import React, { useState, useEffect, useCallback } from 'react'; import axios from 'axios'; import { useRouter } from 'next/navigation'; // Keep if direct navigation is needed // MUI Components import Dialog from '@mui/material/Dialog'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; import IconButton from '@mui/material/IconButton'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Grid from '@mui/material/Grid'; import Card from '@mui/material/Card'; import CardMedia from '@mui/material/CardMedia'; import CardContent from '@mui/material/CardContent'; import CardActionArea from '@mui/material/CardActionArea'; import CircularProgress from '@mui/material/CircularProgress'; import Alert from '@mui/material/Alert'; import Pagination from '@mui/material/Pagination'; import TextField from '@mui/material/TextField'; // Keep search input within modal import InputAdornment from '@mui/material/InputAdornment'; // MUI Icons import CloseIcon from '@mui/icons-material/Close'; import SearchIcon from '@mui/icons-material/Search'; import InfoIcon from '@mui/icons-material/Info'; // For empty state const BookSearchModal = ({ searchTerm: initialSearchTerm = '', onClose, onBookClick }) => { const [allBooks, setAllBooks] = useState([]); // Store all fetched books const [filteredBooks, setFilteredBooks] = useState([]); // Books matching the current filter const [currentSearchTerm, setCurrentSearchTerm] = useState(initialSearchTerm); // Allow modification within modal const [isLoading, setIsLoading] = useState(true); // Start loading initially const [error, setError] = useState(null); const [currentPage, setCurrentPage] = useState(1); const booksPerPage = 9; // Number of results per page const router = useRouter(); // Keep if needed // Fetch all books once when the modal opens or if allBooks is empty const fetchAllBooks = useCallback(async () => { // No longer check allBooks.length > 0, always fetch on open? Or keep cached? // For simplicity, let's fetch each time modal opens. Could be optimized with caching. setIsLoading(true); setError(null); try { const response = await axios.get(`/api/book-names`); setAllBooks(response.data || []); } catch (err) { console.error('Error fetching books for search:', err); setError(err.response?.data?.message || 'Could not load books.'); setAllBooks([]); } finally { setIsLoading(false); } }, []); // No dependencies, runs on mount useEffect(() => { fetchAllBooks(); }, [fetchAllBooks]); // Fetch books when component mounts (modal opens) // Filter books whenever the search term or the list of all books changes useEffect(() => { // Wait until books are loaded before filtering if (isLoading) return; if (!currentSearchTerm.trim()) { setFilteredBooks([]); // Clear results if search is empty setCurrentPage(1); return; } if (allBooks.length > 0) { const lowerSearchTerm = currentSearchTerm.toLowerCase(); const results = allBooks.filter(book => (book.book_name && book.book_name.toLowerCase().includes(lowerSearchTerm)) || (book.book_author && book.book_author.toLowerCase().includes(lowerSearchTerm)) || (book.genre_name && book.genre_name.toLowerCase().includes(lowerSearchTerm)) // Search genre name ); setFilteredBooks(results); setCurrentPage(1); // Reset page when search term changes } }, [currentSearchTerm, allBooks, isLoading]); // Pagination Logic const indexOfLastBook = currentPage * booksPerPage; const indexOfFirstBook = indexOfLastBook - booksPerPage; const currentBooksOnPage = filteredBooks.slice(indexOfFirstBook, indexOfLastBook); const totalPages = Math.ceil(filteredBooks.length / booksPerPage); const handlePageChange = (event, value) => { setCurrentPage(value); // Scroll to top of modal content on page change const contentArea = document.querySelector('.search-dialog-content'); // Use class selector if (contentArea) contentArea.scrollTop = 0; }; const handleBookCardClick = (book) => { if (onBookClick) { onBookClick(book); // Notify parent (likely opens BookChapterModal) } // onClose(); // Let parent decide if search modal closes }; // Helper to truncate description const truncateDescription = (description, maxLength) => { if (!description) return ''; return description.length > maxLength ? description.substring(0, maxLength).trim() + '...' : description; }; // Handle body scroll lock handled by MUI Dialog // useEffect(() => { // document.body.classList.add('modal-open'); // return () => { // document.body.classList.remove('modal-open'); // }; // }, []); return ( {/* Search Input within Title */} setCurrentSearchTerm(e.target.value)} autoFocus // Focus input when modal opens InputProps={{ startAdornment: ( ), disableUnderline: true, // Remove underline for standard variant sx: { fontSize: '1.1rem' } }} sx={{ mr: 4 /* Space before close button */ }} /> theme.palette.grey[500] }} > {isLoading ? ( ) : error ? ( {error} ) : !currentSearchTerm.trim() ? ( Search for books, authors, or genres. ) : filteredBooks.length === 0 ? ( No books found matching "{currentSearchTerm}". ) : ( // Display Results Grid Showing {filteredBooks.length} result(s) {currentBooksOnPage.map((book) => { const imageUrl = book.book_image ? `/uploads/images/${book.book_image}` : '/images/placeholder-book.png'; // Default placeholder return ( {/* Reusing Card structure similar to BookGenreModal */} handleBookCardClick(book)} sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1 }}> {book.book_name} By {book.book_author || 'Unknown'} {truncateDescription(book.book_description, 60)} ); })} )} {/* Pagination Controls */} {totalPages > 1 && !isLoading && !error && filteredBooks.length > 0 && ( )} ); }; export default BookSearchModal; // app/(client)/components/CardSection.js 'use client'; import React, { useRef } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; import NavigateBeforeIcon from '@mui/icons-material/NavigateBefore'; import NavigateNextIcon from '@mui/icons-material/NavigateNext'; import BookCard from './BookCard'; // Keep formatTimeShort here or move to utils function formatTimeShort(totalSeconds) { /* ... implementation ... */ } const CardSection = ({ title, books = [], onCardClick, isProgressSection = false }) => { const scrollContainerRef = useRef(null); const scroll = (direction) => { /* ... scroll logic ... */ }; if (!books || books.length === 0) return null; return ( {title} {/* Scroll Buttons */} scroll('left')} /* ... sx ... */ sx={{ display: { xs: 'none', md: 'inline-flex' } /* etc */}} > scroll('right')} /* ... sx ... */ sx={{ display: { xs: 'none', md: 'inline-flex' } /* etc */}}> {/* Scrollable Container */} *': { flexShrink: 0 } }}> {books.map((item) => ( onCardClick(item)} isProgressCard={isProgressSection} progressTimestamp={isProgressSection ? item.last_watched_timestamp : undefined} progressChapterName={isProgressSection ? item.last_watched_chapter_name : undefined} // --- Pass Duration --- chapterDuration={isProgressSection ? item.last_watched_chapter_duration : undefined} /> ))} {/* Spacer */} ); }; export default CardSection; //app/(client)/components/HeroSection.js 'use client'; import React, { useState, useEffect, useRef } from 'react'; import { useBookStore } from '@/lib/store/bookStore'; import { useRouter } from 'next/navigation'; import axios from 'axios'; // Keep axios for specific chapter fetch // MUI Components import Box from '@mui/material/Box'; import IconButton from '@mui/material/IconButton'; import Button from '@mui/material/Button'; import Typography from '@mui/material/Typography'; import CircularProgress from '@mui/material/CircularProgress'; import Alert from '@mui/material/Alert'; // MUI Icons import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; // Use outlined version import VolumeOffIcon from '@mui/icons-material/VolumeOff'; import VolumeUpIcon from '@mui/icons-material/VolumeUp'; import NavigateBeforeIcon from '@mui/icons-material/NavigateBefore'; import NavigateNextIcon from '@mui/icons-material/NavigateNext'; import PauseIcon from '@mui/icons-material/Pause'; // Assume BookChapterModal is also converted to MUI // import BookChapterModal from './BookChapterModal'; // Placeholder for Modal until converted const BookChapterModalPlaceholder = ({ book, onClose }) => (

Modal for: {book?.book_name}

Content goes here...

); const HeroSection = ({ onOpenBookModal }) => { // Receive handler from parent const books = useBookStore(state => state.books); const isLoading = useBookStore(state => state.isLoading); const error = useBookStore(state => state.error); const [currentIndex, setCurrentIndex] = useState(0); const [isMuted, setIsMuted] = useState(true); const [isPlaying, setIsPlaying] = useState(false); // State for video play/pause // Modal state removed - handled by parent (Home page) // const [selectedBook, setSelectedBook] = useState(null); const videoRef = useRef(null); const router = useRouter(); const topBooks = books.filter(book => book.book_preference === 'top'); const currentBook = topBooks.length > 0 ? topBooks[currentIndex] : null; useEffect(() => { const videoElement = videoRef.current; if (videoElement && currentBook?.trailer) { videoElement.muted = isMuted; videoElement.src = `/uploads/videos/${currentBook.trailer}`; // Path relative to public videoElement.load(); // Important to load the new source const playPromise = videoElement.play(); if (playPromise !== undefined) { playPromise.then(() => { setIsPlaying(true); }).catch(err => { console.warn("Autoplay prevented:", err); // Autoplay was prevented, start paused or show play button setIsPlaying(false); }); } else { setIsPlaying(false); // play() might not return a promise in older browsers } const handleVideoEnd = () => { // Go to the next video when one ends setCurrentIndex(prev => (prev + 1) % topBooks.length); }; videoElement.addEventListener('ended', handleVideoEnd); return () => videoElement.removeEventListener('ended', handleVideoEnd); } else if (videoElement) { // If no trailer, pause and reset videoElement.pause(); videoElement.src = ''; setIsPlaying(false); } }, [currentIndex, topBooks, isMuted]); // Rerun when index or mute state changes const handleNext = () => { if (topBooks.length > 0) { setCurrentIndex((prevIndex) => (prevIndex + 1) % topBooks.length); } }; const handlePrevious = () => { if (topBooks.length > 0) { setCurrentIndex((prevIndex) => (prevIndex - 1 + topBooks.length) % topBooks.length); } }; const toggleMute = () => { setIsMuted(!isMuted); }; const togglePlayPause = () => { const video = videoRef.current; if (!video) return; if (isPlaying) { video.pause(); setIsPlaying(false); } else { video.play().then(() => setIsPlaying(true)).catch(err => console.error("Error playing video:", err)); } }; const handleMoreInfoClick = () => { if (currentBook && onOpenBookModal) { onOpenBookModal(currentBook); // Call parent handler } }; const handlePlayChapter = async () => { if (!currentBook) return; try { // Fetch the first chapter details const response = await axios.get(`/api/book-chapters?bookId=${currentBook.id}&chapterSequence=first&limit=1`); const chapter = response.data[0]; if (chapter) { const { chapter_audio_video, chapter_name, chapter_description, chapter_image } = chapter; // Navigate to the chapter viewing page const url = `/chapter-viewing-page?audioVideo=${encodeURIComponent(chapter_audio_video)}&chapterName=${encodeURIComponent(chapter_name)}&chapterDescription=${encodeURIComponent(chapter_description || '')}&chapterImage=${encodeURIComponent(chapter_image || '')}`; router.push(url); } else { console.log("No first chapter found for this book."); // TODO: Show a message to the user (e.g., using Snackbar) } } catch (err) { console.error('Error fetching first chapter:', err); // TODO: Show error message to the user } }; // --- Render Logic --- if (isLoading) { return ( ); } if (error) { return ( Could not load featured books: {error} ); } if (topBooks.length === 0) { return ( No top books available. ); } if (!currentBook) return null; // Should be handled by length check, but safety return ( {/* Video Player */} ); }; export default HeroSection; // app/(client)/components/Navbar.js 'use client'; // This component uses client-side hooks and state import React, { useState, useEffect } from 'react'; import Link from 'next/link'; // Needed for logo and potentially other links import { useRouter } from 'next/navigation'; // For navigation // Auth Contexts import { useAuth } from '@/lib/auth'; // Admin auth (for Admin button action) import { useClientAuth } from '@/lib/clientAuth'; // Client auth // Zustand Stores (if used) import { useGenreStore } from '@/lib/store/genreStore'; import { useUIStore } from '@/lib/store/uiStore'; // MUI Components import AppBar from '@mui/material/AppBar'; import Box from '@mui/material/Box'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import Button from '@mui/material/Button'; import IconButton from '@mui/material/IconButton'; import TextField from '@mui/material/TextField'; import InputAdornment from '@mui/material/InputAdornment'; import Container from '@mui/material/Container'; import Tooltip from '@mui/material/Tooltip'; import Avatar from '@mui/material/Avatar'; import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; // MUI Icons import SearchIcon from '@mui/icons-material/Search'; import NotificationsIcon from '@mui/icons-material/Notifications'; // Import AccountCircle if needed for logged-out state visual // import AccountCircle from '@mui/icons-material/AccountCircle'; const Navbar = () => { // Genre and UI Store hooks const genres = useGenreStore(state => state.genres); const fetchGenres = useGenreStore(state => state.fetchGenres); const openGenreModal = useUIStore((state) => state.openGenreModal); const openSearchModal = useUIStore((state) => state.openSearchModal); // State for UI elements const [scrolled, setScrolled] = useState(false); const [isSearchVisible, setIsSearchVisible] = useState(false); const [searchTerm, setSearchTerm] = useState(''); const [anchorEl, setAnchorEl] = useState(null); // For profile menu const profileMenuOpen = Boolean(anchorEl); // Auth hooks const { logout: adminLogout } = useAuth(); // Admin logout function const { clientUser, isClientLoggedIn, clientLogout, clientLoading } = useClientAuth(); // Client auth state and functions // Router hook const router = useRouter(); // Effect for fetching genres and handling scroll useEffect(() => { fetchGenres(); const handleScroll = () => { setScrolled(window.scrollY > 10); }; window.addEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll); }, [fetchGenres]); // --- Event Handlers --- const handleGenreClick = (genreId) => { openGenreModal(genreId); // Open genre modal via UI store }; const toggleSearchVisibility = () => { setIsSearchVisible(!isSearchVisible); if (isSearchVisible) setSearchTerm(''); // Clear search on hide }; const handleSearch = () => { if (searchTerm.trim()) { openSearchModal(searchTerm.trim()); // Open search modal via UI store setIsSearchVisible(false); setSearchTerm(''); } }; const handleKeyDown = (event) => { if (event.key === 'Enter') handleSearch(); else if (event.key === 'Escape') toggleSearchVisibility(); }; // Handler for the "Admin" button const handleAdminButtonClick = () => { adminLogout(); // Logout admin user router.push('/admin/auth/login'); // Redirect to admin login }; // Profile Menu Handlers const handleProfileMenuOpen = (event) => setAnchorEl(event.currentTarget); const handleProfileMenuClose = () => setAnchorEl(null); const handleLogoutClick = () => { clientLogout(); // Logout client user handleProfileMenuClose(); // Close menu router.push('/'); // Optional: Navigate home or stay on page }; // --- End Event Handlers --- // Don't render full navbar until client auth is loaded to prevent flicker if (clientLoading) { return ( ); } return ( <> theme.zIndex.drawer + 1 // Ensure above drawer }} > {/* Logo */} SkizaFM {/* Mobile Logo */} SkizaFM {/* Genre Links (Desktop) */} {/* Display first few genres */} {genres.slice(0, 5).map((genre) => ( ))} {/* Consider adding a "More Genres" dropdown if needed */} {/* Right Side Icons & Buttons */} {/* Admin Button */} {/* Search */} {/* Conditionally render search field */} {isSearchVisible && ( setSearchTerm(e.target.value)} onKeyDown={handleKeyDown} sx={{ // Responsive width width: { xs: '100px', sm: '150px', md: '200px' }, ml: 1, input: { color: 'white' }, // Text color // Underline styling '& .MuiInput-underline:before': { borderBottomColor: 'rgba(255, 255, 255, 0.7)' }, '& .MuiInput-underline:hover:not(.Mui-disabled):before': { borderBottomColor: 'white' }, '& .MuiInput-underline:after': { borderBottomColor: 'primary.main' }, }} autoFocus // Focus when revealed onBlur={() => { if(!searchTerm) setIsSearchVisible(false) }} // Hide if blurred and empty /> )} {/* Notifications Icon (Placeholder) */} {/* --- Client Authentication Display --- */} {isClientLoggedIn() ? ( // Logged In View: Avatar + Menu <> {/* Future: Add profile link */} {/* router.push('/profile')}>Profile */} {/* */} Logout ) : ( // Logged Out View: Sign In / Sign Up Buttons <> )} {/* --- End Client Authentication Display --- */} {/* Modals are still rendered by the parent Home page */} ); }; export default Navbar; // app/(client)/layout.js 'use client'; // This layout uses client-side context and components // MUI Components import Box from '@mui/material/Box'; import CssBaseline from '@mui/material/CssBaseline'; // Client Components & Context import Navbar from './components/Navbar'; // REMOVED: No longer need to import ClientAuthProvider here // import { ClientAuthProvider } from '@/lib/clientAuth'; // This layout specifically serves the client-facing part of the application export default function ClientLayout({ children }) { return ( // REMOVED: ClientAuthProvider is no longer needed here, provided by root layout // {/* Apply baseline styles */} {/* Navbar component, now uses useClientAuth */} {/* The actual page content (e.g., Home page) */} {children} {/* Optional: Add a Footer component here */} // ); } // app/(client)/page.js 'use client'; import React, { useEffect, Suspense, useState } from 'react'; import { useRouter } from 'next/navigation'; import axios from 'axios'; import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; import Alert from '@mui/material/Alert'; // Import Client components import HeroSection from './components/HeroSection'; import CardSection from './components/CardSection'; import BookGenreModal from './components/BookGenreModal'; import BookChapterModal from './components/BookChapterModal'; import BookSearchModal from './components/BookSearchModal'; // Import Stores import { useBookStore } from '@/lib/store/bookStore'; import { useGenreStore } from '@/lib/store/genreStore'; import { useUIStore } from '@/lib/store/uiStore'; import { useClientAuth } from '@/lib/clientAuth'; // Use correct context // Inner component function HomePageContent() { const router = useRouter(); const { modalType, modalData, openBookModal, closeModal } = useUIStore(); const { books, fetchBooks, isLoading: booksLoading, error: booksError } = useBookStore(); const { genres, fetchGenres, isLoading: genresLoading, error: genresError } = useGenreStore(); const { clientUser, clientToken, isClientLoggedIn } = useClientAuth(); // Get client auth const [continueWatchingList, setContinueWatchingList] = useState([]); const [isLoadingProgress, setIsLoadingProgress] = useState(false); const [progressError, setProgressError] = useState(null); // Fetch books and genres useEffect(() => { fetchBooks(); fetchGenres(); }, [fetchBooks, fetchGenres]); // Fetch Continue Watching data useEffect(() => { const fetchProgress = async () => { if (isClientLoggedIn() && clientToken) { setIsLoadingProgress(true); setProgressError(null); // Clear previous error try { const response = await axios.get('/api/viewing-progress', { headers: { Authorization: `Bearer ${clientToken}` } // <-- Send token }); // The API now filters finished items and includes duration setContinueWatchingList(response.data || []); } catch (err) { console.error("Failed to fetch viewing progress:", err); const errMsg = err.response?.data?.message || err.message || "Could not load progress"; setProgressError(errMsg); setContinueWatchingList([]); // Clear list on error } finally { setIsLoadingProgress(false); } } else { setContinueWatchingList([]); // Clear list if not logged in setIsLoadingProgress(false); setProgressError(null); } }; fetchProgress(); }, [isClientLoggedIn, clientToken]); const handleCardClick = (book) => { openBookModal(book); }; const handleHeroInfoClick = (book) => { openBookModal(book); }; const handleContinueWatchingClick = (progressItem) => { if (!progressItem?.last_watched_chapter_media) { console.error("Missing data for continue watching navigation", progressItem); // Optionally show an error to the user return; } // Navigate with timestamp const url = `/chapter-viewing-page?audioVideo=${encodeURIComponent(progressItem.last_watched_chapter_media)}&chapterName=${encodeURIComponent(progressItem.last_watched_chapter_name || 'Chapter')}&chapterDescription=${encodeURIComponent(progressItem.last_watched_chapter_desc || '')}&chapterImage=${encodeURIComponent(progressItem.last_watched_chapter_image || '')}×tamp=${progressItem.last_watched_timestamp || 0}`; router.push(url); }; // Combined loading state for initial data const initialLoading = booksLoading || genresLoading; // Combined error for initial data const initialError = booksError || genresError; return ( {/* Hero Section */} {!initialLoading && !initialError && } {initialLoading && } {initialError && !initialLoading && Error loading initial data: {initialError}} {/* Continue Watching Section */} {isClientLoggedIn() && !isLoadingProgress && continueWatchingList.length > 0 && ( )} {isClientLoggedIn() && isLoadingProgress && } {isClientLoggedIn() && !isLoadingProgress && progressError && Could not load progress: {progressError}} {/* Regular Card Sections (only render if initial data loaded ok) */} {!initialLoading && !initialError && ( <> {featuredBooks.length > 0 && ( )} {otherBooks.length > 0 && ( )} {genres.slice(0, 3).map(genre => { const genreBooks = books.filter(book => book.genre_id === genre.id); return genreBooks.length > 0 ? ( ) : null; })} )} {/* Modals */} {modalType === 'genre' && modalData?.genreId && ( )} {modalType === 'book' && modalData?.book && ( )} {modalType === 'search' && modalData?.searchTerm && ( )} ); } // Main export wrapped in Suspense export default function Home() { const SuspenseFallback = ( ); return ( ); } // app/admin/auth/forgot-password/page.js 'use client'; import React, { useState } from 'react'; import axios from 'axios'; import Link from 'next/link'; // MUI Components import Box from '@mui/material/Box'; import TextField from '@mui/material/TextField'; import Button from '@mui/material/Button'; import Typography from '@mui/material/Typography'; import CircularProgress from '@mui/material/CircularProgress'; import Alert from '@mui/material/Alert'; import Container from '@mui/material/Container'; import Paper from '@mui/material/Paper'; import Grid from '@mui/material/Grid'; const ForgotPasswordPage = () => { const [email, setEmail] = useState(''); const [message, setMessage] = useState(''); // Success message const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const handleSubmit = async (e) => { e.preventDefault(); setError(''); setMessage(''); setLoading(true); try { // Use internal API route const response = await axios.post('/api/auth/forgot-password', { email }); if (response.status === 200) { setMessage(response.data.message); // Show success/info message from API setEmail(''); // Clear email field on success } // No else needed, catch handles errors } catch (err) { setError(err.response?.data?.message || 'Failed to send reset link. Please try again.'); } finally { setLoading(false); } }; return ( // Center content vertically and horizontally Forgot Password? Enter your email address and we'll send you a link to reset your password. {error && {error}} {message && {message}} setEmail(e.target.value)} disabled={loading} InputLabelProps={{ style: { color: '#aaa' } }} sx={{ input: { color: 'white' }, '& .MuiOutlinedInput-root': { '& fieldset': { borderColor: '#aaa' }, '&:hover fieldset': { borderColor: 'white' }, '&.Mui-focused fieldset': { borderColor: 'primary.main' } } }} /> Back to Sign In ); }; export default ForgotPasswordPage; // app/admin/auth/login/page.js 'use client'; import React, { useState, useEffect } from 'react'; import axios from 'axios'; import { useAuth } from '@/app/lib/auth'; // Correct path import { useRouter } from 'next/navigation'; import Link from 'next/link'; // <--- Import Link import Cookies from 'js-cookie'; // MUI Components import Box from '@mui/material/Box'; import TextField from '@mui/material/TextField'; import Button from '@mui/material/Button'; import Typography from '@mui/material/Typography'; import CircularProgress from '@mui/material/CircularProgress'; import Alert from '@mui/material/Alert'; import FormControlLabel from '@mui/material/FormControlLabel'; import Checkbox from '@mui/material/Checkbox'; import Container from '@mui/material/Container'; import Paper from '@mui/material/Paper'; // To contain the form visually import Grid from '@mui/material/Grid'; // Import Grid import ArrowBackIcon from '@mui/icons-material/ArrowBack'; // Example icon const LoginPage = () => { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [rememberMe, setRememberMe] = useState(false); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const { login, isLoggedIn } = useAuth(); const router = useRouter(); // Redirect if already logged in useEffect(() => { if (isLoggedIn()) { router.push('/admin/dashboard'); } }, [isLoggedIn, router]); // Check for remembered email useEffect(() => { const storedEmail = Cookies.get('rememberedAdminEmail'); // Use a specific cookie name if (storedEmail) { setEmail(storedEmail); setRememberMe(true); } }, []); const handleSubmit = async (e) => { e.preventDefault(); setError(''); setLoading(true); try { const response = await axios.post('/api/auth/login', { email, password }); // Use internal API if (response.status === 200) { login(response.data.user, response.data.token); // Update auth context // Handle Remember Me cookie if (rememberMe) { Cookies.set('rememberedAdminEmail', email, { expires: 30 }); // Expires in 30 days } else { Cookies.remove('rememberedAdminEmail'); } router.push('/admin/dashboard'); // Redirect to dashboard } // No else needed, error handling below } catch (err) { setError(err.response?.data?.message || 'Login failed. Check credentials.'); } finally { setLoading(false); } }; return ( {/* Client Button - Positioned above the login form */} Admin Sign In {error && {error}} setEmail(e.target.value)} disabled={loading} // Styling for dark mode InputLabelProps={{ style: { color: '#aaa' } }} sx={{ input: { color: 'white' }, '& .MuiOutlinedInput-root': { '& fieldset': { borderColor: '#aaa' }, '&:hover fieldset': { borderColor: 'white' }, '&.Mui-focused fieldset': { borderColor: 'primary.main' } } }} /> setPassword(e.target.value)} disabled={loading} // Styling for dark mode InputLabelProps={{ style: { color: '#aaa' } }} sx={{ input: { color: 'white' }, '& .MuiOutlinedInput-root': { '& fieldset': { borderColor: '#aaa' }, '&:hover fieldset': { borderColor: 'white' }, '&.Mui-focused fieldset': { borderColor: 'primary.main' } } }} /> setRememberMe(e.target.checked)} sx={{ color: '#aaa' }} /> } label="Remember me" sx={{ color: '#aaa' }} /> {/* Grid for links - already responsive */} {/* Allow wrapping */} Forgot password? ); }; export default LoginPage; // app/admin/auth/reset-password/page.js 'use client'; import React, { useState, useEffect, Suspense } from 'react'; // Added Suspense import axios from 'axios'; import { useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; // MUI Components import Box from '@mui/material/Box'; import TextField from '@mui/material/TextField'; import Button from '@mui/material/Button'; import Typography from '@mui/material/Typography'; import CircularProgress from '@mui/material/CircularProgress'; import Alert from '@mui/material/Alert'; import Container from '@mui/material/Container'; import Paper from '@mui/material/Paper'; import Grid from '@mui/material/Grid'; // Inner component to use useSearchParams function ResetPasswordForm() { const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [message, setMessage] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const router = useRouter(); const searchParams = useSearchParams(); const token = searchParams.get('token'); useEffect(() => { if (!token && !loading) { // Check only when not loading initial state setError('Invalid or missing password reset token.'); // Optionally redirect after a delay // setTimeout(() => router.push('/admin/auth/login'), 3000); } else { setError(''); // Clear error if token exists } }, [token, loading, router]); // Add loading dependency const handleSubmit = async (e) => { e.preventDefault(); setError(''); setMessage(''); if (!token) { setError('Invalid or missing password reset token.'); return; } if (password !== confirmPassword) { setError('Passwords do not match.'); return; } if (password.length < 6) { setError('Password must be at least 6 characters long.'); return; } setLoading(true); try { // Use internal API route const response = await axios.post('/api/auth/reset-password', { password, token }); if (response.status === 200) { setMessage(response.data.message + ' Redirecting to login...'); setPassword(''); // Clear fields on success setConfirmPassword(''); // Redirect to login after a short delay setTimeout(() => { router.push('/admin/auth/login'); }, 3000); } // No else needed } catch (err) { setError(err.response?.data?.message || 'Password reset failed. The link may be invalid or expired.'); } finally { setLoading(false); } }; return ( Reset Your Password {error && {error}} {message && {message}} setPassword(e.target.value)} disabled={loading || !token || !!message} // Disable if no token or success helperText="Minimum 6 characters" InputLabelProps={{ style: { color: '#aaa' } }} sx={{ input: { color: 'white' }, '& .MuiOutlinedInput-root': { '& fieldset': { borderColor: '#aaa' }, '&:hover fieldset': { borderColor: 'white' }, '&.Mui-focused fieldset': { borderColor: 'primary.main' } } }} /> setConfirmPassword(e.target.value)} disabled={loading || !token || !!message} InputLabelProps={{ style: { color: '#aaa' } }} sx={{ input: { color: 'white' }, '& .MuiOutlinedInput-root': { '& fieldset': { borderColor: '#aaa' }, '&:hover fieldset': { borderColor: 'white' }, '&.Mui-focused fieldset': { borderColor: 'primary.main' } } }} /> Back to Sign In ); } // Wrap with Suspense for useSearchParams export default function ResetPasswordPage() { // Fallback UI for Suspense const SuspenseFallback = ( ); return ( ); } // app/admin/auth/signup/page.js 'use client'; import React, { useState, useEffect } from 'react'; import axios from 'axios'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { useAuth } from '@/app/lib/auth'; // Import useAuth // MUI Components (similar to Login) import Box from '@mui/material/Box'; import TextField from '@mui/material/TextField'; import Button from '@mui/material/Button'; import Typography from '@mui/material/Typography'; import CircularProgress from '@mui/material/CircularProgress'; import Alert from '@mui/material/Alert'; import Container from '@mui/material/Container'; import Paper from '@mui/material/Paper'; import Grid from '@mui/material/Grid'; import Avatar from '@mui/material/Avatar'; // For preview import IconButton from '@mui/material/IconButton'; // For upload button alternative import PhotoCamera from '@mui/icons-material/PhotoCamera'; const SignupPage = () => { const [username, setUsername] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [profilePicture, setProfilePicture] = useState(null); // File object const [preview, setPreview] = useState(null); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const router = useRouter(); const { isLoggedIn } = useAuth(); // Check if already logged in // Redirect if already logged in useEffect(() => { if (isLoggedIn()) { router.push('/admin/dashboard'); } }, [isLoggedIn, router]); useEffect(() => { // Create preview URL if (!profilePicture) { setPreview(null); return; } const objectUrl = URL.createObjectURL(profilePicture); setPreview(objectUrl); // Cleanup function to revoke the object URL return () => URL.revokeObjectURL(objectUrl); }, [profilePicture]); const handleFileChange = (event) => { if (event.target.files && event.target.files[0]) { // Optional: Add file size/type validation here setProfilePicture(event.target.files[0]); } }; const handleSubmit = async (e) => { e.preventDefault(); setError(''); if (password.length < 6) { setError('Password must be at least 6 characters long.'); return; } if (!username || !email) { setError('Username and Email are required.'); return; } setLoading(true); const formData = new FormData(); formData.append('username', username); formData.append('email', email); formData.append('password', password); if (profilePicture) { formData.append('profilePicture', profilePicture); } try { // Use internal API route for signup const response = await axios.post('/api/auth/signup', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); if (response.status === 201) { // Optionally show success message before redirecting // setMessage('Signup successful! Redirecting to login...'); // setTimeout(() => router.push('/admin/auth/login'), 2000); router.push('/admin/auth/login'); // Redirect to login after successful signup } // No else needed, using catch block for errors } catch (err) { setError(err.response?.data?.message || 'Signup failed. Please try again.'); } finally { setLoading(false); } }; return ( Admin Sign Up {error && {error}} {/* Fields similar to Login, add Username */} setUsername(e.target.value)} disabled={loading} InputLabelProps={{ style: { color: '#aaa' } }} sx={{ /* Dark mode styles */ input: { color: 'white' }, '& .MuiOutlinedInput-root': { '& fieldset': { borderColor: '#aaa' }, '&:hover fieldset': { borderColor: 'white' }, '&.Mui-focused fieldset': { borderColor: 'primary.main' } } }} /> setEmail(e.target.value)} disabled={loading} InputLabelProps={{ style: { color: '#aaa' } }} sx={{ /* Dark mode styles */ input: { color: 'white' }, '& .MuiOutlinedInput-root': { '& fieldset': { borderColor: '#aaa' }, '&:hover fieldset': { borderColor: 'white' }, '&.Mui-focused fieldset': { borderColor: 'primary.main' } } }}/> setPassword(e.target.value)} disabled={loading} helperText="Minimum 6 characters" InputLabelProps={{ style: { color: '#aaa' } }} sx={{ /* Dark mode styles */ input: { color: 'white' }, '& .MuiOutlinedInput-root': { '& fieldset': { borderColor: '#aaa' }, '&:hover fieldset': { borderColor: 'white' }, '&.Mui-focused fieldset': { borderColor: 'primary.main' } } }}/> {/* Profile Picture Upload */} {/* Preview Avatar */} {preview && } Already have an admin account? Sign in ); }; export default SignupPage; // app/admin/components/AdminAccounts.js 'use client'; import React, { useState, useEffect } from 'react'; import axios from 'axios'; import { useAuth } from '@/app/lib/auth'; // Use auth context import { useRouter } from 'next/navigation'; // Removed Dropzone import as standard input is used // MUI Components import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; import CircularProgress from '@mui/material/CircularProgress'; import Alert from '@mui/material/Alert'; import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogContentText from '@mui/material/DialogContentText'; import DialogTitle from '@mui/material/DialogTitle'; import IconButton from '@mui/material/IconButton'; import Avatar from '@mui/material/Avatar'; import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; import TableCell from '@mui/material/TableCell'; import TableContainer from '@mui/material/TableContainer'; import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import Paper from '@mui/material/Paper'; import Checkbox from '@mui/material/Checkbox'; // For delete picture confirmation import Tooltip from '@mui/material/Tooltip'; import InputAdornment from '@mui/material/InputAdornment'; // For search icon import FormControlLabel from '@mui/material/FormControlLabel'; // For delete pic checkbox label // MUI Icons import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import AddIcon from '@mui/icons-material/Add'; import RefreshIcon from '@mui/icons-material/Refresh'; import SearchIcon from '@mui/icons-material/Search'; import SaveIcon from '@mui/icons-material/Save'; import CancelIcon from '@mui/icons-material/Cancel'; import PhotoCamera from '@mui/icons-material/PhotoCamera'; // For upload button // --- AddAdminForm Component (MUI) --- const AddAdminForm = ({ open, onClose, onSignupSuccess }) => { const [username, setUsername] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [profilePicture, setProfilePicture] = useState(null); // File object const [preview, setPreview] = useState(null); // Image preview URL const [error, setError] = useState(''); const [loading, setLoading] = useState(false); useEffect(() => { // Create preview URL when profilePicture changes if (!profilePicture) { setPreview(null); return; } const objectUrl = URL.createObjectURL(profilePicture); setPreview(objectUrl); // Free memory when the component is unmounted return () => URL.revokeObjectURL(objectUrl); }, [profilePicture]); const handleFileChange = (event) => { if (event.target.files && event.target.files[0]) { setProfilePicture(event.target.files[0]); } }; const handleSubmit = async (e) => { e.preventDefault(); setError(''); if (!username || !email || !password) { setError('Please fill in username, email, and password.'); return; // Stop submission } if (password.length < 6) { setError('Password must be at least 6 characters.'); return; // Stop submission } setLoading(true); // Set loading only after validation const formData = new FormData(); formData.append('username', username); formData.append('email', email); formData.append('password', password); if (profilePicture) { formData.append('profilePicture', profilePicture); } try { const response = await axios.post('/api/auth/signup', formData, { // Use internal API headers: { 'Content-Type': 'multipart/form-data' }, }); if (response.status === 201) { onSignupSuccess(); // Notify parent to refresh list and close handleClose(); // Close and reset form } else { setError(response.data.message || 'Failed to add admin.'); } } catch (err) { setError(err.response?.data?.message || 'An error occurred.'); } finally { setLoading(false); } }; const handleClose = () => { // Reset form state on close setUsername(''); setEmail(''); setPassword(''); setProfilePicture(null); setPreview(null); setError(''); setLoading(false); onClose(); // Call the parent's onClose handler }; return ( // Ensure Dialog is responsive Add New Admin {error && {error}} setUsername(e.target.value)} required disabled={loading} /> setEmail(e.target.value)} required disabled={loading} /> setPassword(e.target.value)} required helperText="Minimum 6 characters" disabled={loading} /> {preview && } ); }; // --- AdminAccounts Main Component --- const AdminAccounts = () => { const [users, setUsers] = useState([]); const [filteredUsers, setFilteredUsers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [searchTerm, setSearchTerm] = useState(''); const [showAddAdminForm, setShowAddAdminForm] = useState(false); // Editing State const [editingUserId, setEditingUserId] = useState(null); const [editFormData, setEditFormData] = useState({ username: '', email: '', password: '' }); const [editProfilePictureFile, setEditProfilePictureFile] = useState(null); const [editProfilePicturePreview, setEditProfilePicturePreview] = useState(null); const [editDeletePicture, setEditDeletePicture] = useState(false); const [editLoading, setEditLoading] = useState(false); const [editError, setEditError] = useState(''); const { user: loggedInUser } = useAuth(); // Get currently logged-in user details const router = useRouter(); // For potential refreshes // Fetch Users Function const fetchUsers = async () => { setLoading(true); setError(null); try { const response = await axios.get('/api/admin/users'); // Use internal API setUsers(response.data); setFilteredUsers(response.data); } catch (err) { console.error('Error fetching users:', err); setError(err.response?.data?.message || 'Failed to fetch users'); } finally { setLoading(false); } }; useEffect(() => { fetchUsers(); // Fetch on initial mount }, []); useEffect(() => { // Create preview URL when editing profilePicture changes if (!editProfilePictureFile) { // Don't clear preview if no new file is selected yet, keep existing // setEditProfilePicturePreview(null); return; } const objectUrl = URL.createObjectURL(editProfilePictureFile); setEditProfilePicturePreview(objectUrl); // Free memory when the component is unmounted or file changes return () => URL.revokeObjectURL(objectUrl); }, [editProfilePictureFile]); // Search Handler useEffect(() => { const lowerSearchTerm = searchTerm.toLowerCase(); const results = users.filter(u => (u.username && u.username.toLowerCase().includes(lowerSearchTerm)) || (u.email && u.email.toLowerCase().includes(lowerSearchTerm)) ); setFilteredUsers(results); }, [searchTerm, users]); // --- CRUD Handlers --- const handleAddAdminClick = () => setShowAddAdminForm(true); const handleCloseAddAdminForm = () => setShowAddAdminForm(false); const handleSignupSuccess = () => { setShowAddAdminForm(false); fetchUsers(); // Refresh the list // TODO: Add success notification (Snackbar) }; // --- Edit Handlers --- const handleEditClick = (userToEdit) => { setEditingUserId(userToEdit.id); setEditFormData({ username: userToEdit.username, email: userToEdit.email, password: '' // Clear password field for editing }); setEditProfilePictureFile(null); // Reset file input // Set initial preview from existing data or null setEditProfilePicturePreview(userToEdit.profile_picture ? `/uploads/${userToEdit.profile_picture}` : null); setEditDeletePicture(false); setEditError(''); }; const handleEditCancel = () => { setEditingUserId(null); setEditError(''); // Clear any edit errors // Reset edit form state related to file handling setEditProfilePictureFile(null); setEditProfilePicturePreview(null); setEditDeletePicture(false); }; const handleEditInputChange = (e) => { const { name, value } = e.target; setEditFormData(prev => ({ ...prev, [name]: value })); }; const handleEditFileChange = (event) => { if (event.target.files && event.target.files[0]) { setEditProfilePictureFile(event.target.files[0]); setEditDeletePicture(false); // If a new file is chosen, don't delete setEditError(''); // Clear error on new file select } }; const handleEditDeletePictureToggle = (event) => { const checked = event.target.checked; setEditDeletePicture(checked); if (checked) { setEditProfilePictureFile(null); // Clear any selected new file if delete is checked setEditProfilePicturePreview(null); // Clear preview immediately } else { // If unchecked, restore preview from original data if available const currentUser = users.find(u => u.id === editingUserId); setEditProfilePicturePreview(currentUser?.profile_picture ? `/uploads/${currentUser.profile_picture}` : null); } }; const handleEditSave = async (id) => { setEditError(''); // Clear previous errors first if (!editFormData.username || !editFormData.email) { setEditError('Username and email cannot be empty.'); return; // Stop save } if (editFormData.password && editFormData.password.length < 6) { setEditError('New password must be at least 6 characters.'); return; // Stop save } setEditLoading(true); // Set loading after validation const formData = new FormData(); formData.append('username', editFormData.username); formData.append('email', editFormData.email); if (editFormData.password) { formData.append('password', editFormData.password); } if (editProfilePictureFile) { formData.append('profilePicture', editProfilePictureFile); } // Send delete flag only if it's true AND there was an original picture const currentUser = users.find(u => u.id === editingUserId); if (editDeletePicture && currentUser?.profile_picture) { formData.append('deleteProfilePicture', 'true'); } try { const response = await axios.put(`/api/admin/users/${id}`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }); if (response.status === 200) { fetchUsers(); // Refresh list setEditingUserId(null); // Exit editing mode // TODO: Add success notification (Snackbar) // Update AuthContext if the logged-in user was edited (requires login function update in context) // if (loggedInUser && loggedInUser.id === id) { // // Fetch the updated user data from response and update context // // login(response.data.user, currentToken); // Need token access here or refetch // } } else { // This case might not be reached if axios throws for non-2xx status setEditError(response.data.message || 'Update failed.'); } } catch (err) { console.error('Error updating user:', err); setEditError(err.response?.data?.message || 'An error occurred during update.'); } finally { setEditLoading(false); // Clear file-related states after save attempt (success or fail) setEditProfilePictureFile(null); // Preview should be updated by fetchUsers on success // On failure, keep the UI state until cancel or retry } }; // --- Delete Handler --- const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [userToDelete, setUserToDelete] = useState(null); const handleDeleteClick = (user) => { if (loggedInUser && loggedInUser.id === user.id) { setError("You cannot delete your own account."); // Show error directly setTimeout(() => setError(null), 4000); // Clear error after delay return; } setUserToDelete(user); setShowDeleteConfirm(true); }; const handleCloseDeleteConfirm = () => { setShowDeleteConfirm(false); setUserToDelete(null); }; const handleConfirmDelete = async () => { if (!userToDelete) return; // Use main loading indicator for delete action? Or a separate one? Main is fine. setLoading(true); setError(null); try { await axios.delete(`/api/admin/users/${userToDelete.id}`); handleCloseDeleteConfirm(); // Close modal first fetchUsers(); // Refresh list // TODO: Add success notification (Snackbar) } catch (err) { console.error('Error deleting user:', err); setError(err.response?.data?.message || 'Failed to delete user.'); handleCloseDeleteConfirm(); // Close modal even on error } finally { setLoading(false); } }; // --- Render Logic --- if (loading && users.length === 0) { // Show loader only on initial load return ; } return ( // Use responsive padding for the main container Admin Accounts {error && setError(null)}>{error}} {/* Controls: Add, Search, Refresh */} {/* Use flexWrap for responsiveness */} setSearchTerm(e.target.value)} InputProps={{ startAdornment: ( // Use startAdornment for icon inside ), }} // Allow text field to shrink/grow sx={{ flexGrow: { xs: 1, sm: 0 }, width: { xs: '100%', sm: 250, md: 300 } }} /> {/* Add Admin Modal */} {/* Delete Confirmation Dialog (already uses MUI Dialog - responsive) */} Confirm Deletion Are you sure you want to delete the admin account for "{userToDelete?.username}" ({userToDelete?.email})? This action cannot be undone. {/* Users Table */} {/* TableContainer handles horizontal scroll on small screens */} ID {/* Hide ID on extra small */} Picture Username Email Password {/* Hide Password on small/xs */} Actions {loading && users.length > 0 && ( // Show spinner overlay if loading new data )} {!loading && filteredUsers.map((rowUser) => ( {/* Hide ID on extra small */} {rowUser.id} {editingUserId === rowUser.id ? ( {/* Show delete only if there's a picture (either original or previewed new) */} { (editProfilePicturePreview || rowUser.profile_picture) && } label={Delete Pic} // Smaller label sx={{ mr: 0 }} // Reduce margin /> } {editError && {editError}} ) : ( )} {editingUserId === rowUser.id ? ( ) : ( <> {rowUser.username} {loggedInUser && loggedInUser.id === rowUser.id && (You)} )} {editingUserId === rowUser.id ? ( ) : ( rowUser.email )} {/* Password - Hidden on small screens */} {editingUserId === rowUser.id ? ( ) : ( '********' )} {/* Actions */} {editingUserId === rowUser.id ? ( <> handleEditSave(rowUser.id)} disabled={editLoading}> {editLoading ? : } ) : ( <> {/* Span needed for disabled button tooltip */} handleEditClick(rowUser)} // Maybe allow editing self but show warning? Or redirect to profile page? // disabled={loggedInUser?.id === rowUser.id} // Cannot edit self directly in table view > {/* Span needed for disabled button tooltip */} handleDeleteClick(rowUser)} disabled={loggedInUser?.id === rowUser.id} // Cannot delete self > )} ))} {!loading && filteredUsers.length === 0 && ( No admin accounts found{searchTerm ? ' matching your search' : ''}. )}
{/* Optional: Add TablePagination component here if needed */}
); }; export default AdminAccounts; // app/admin/components/BookChapters.js 'use client'; import React, { useState, useEffect } from 'react'; import axios from 'axios'; // MUI Components import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; import CircularProgress from '@mui/material/CircularProgress'; import Alert from '@mui/material/Alert'; import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogContentText from '@mui/material/DialogContentText'; // Import for confirmation text import DialogTitle from '@mui/material/DialogTitle'; import IconButton from '@mui/material/IconButton'; import Avatar from '@mui/material/Avatar'; import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; import TableCell from '@mui/material/TableCell'; import TableContainer from '@mui/material/TableContainer'; import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import Paper from '@mui/material/Paper'; import Tooltip from '@mui/material/Tooltip'; import Select from '@mui/material/Select'; import MenuItem from '@mui/material/MenuItem'; import InputLabel from '@mui/material/InputLabel'; import FormControl from '@mui/material/FormControl'; import Stepper from '@mui/material/Stepper'; import Step from '@mui/material/Step'; import StepLabel from '@mui/material/StepLabel'; import Checkbox from '@mui/material/Checkbox'; import InputAdornment from '@mui/material/InputAdornment'; import FormControlLabel from '@mui/material/FormControlLabel'; // Import // MUI Icons import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import AddIcon from '@mui/icons-material/Add'; import RefreshIcon from '@mui/icons-material/Refresh'; import SearchIcon from '@mui/icons-material/Search'; import SaveIcon from '@mui/icons-material/Save'; import CancelIcon from '@mui/icons-material/Cancel'; import ImageIcon from '@mui/icons-material/Image'; import UploadFileIcon from '@mui/icons-material/UploadFile'; import DescriptionIcon from '@mui/icons-material/Description'; import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline'; // For edit media button import NavigateNextIcon from '@mui/icons-material/NavigateNext'; import NavigateBeforeIcon from '@mui/icons-material/NavigateBefore'; // Import Sub-Modals import EditChapterDescription from './EditChapterDescription'; import EditChapterVideoAudio from './EditChapterVideoAudio'; // --- Add/Edit Chapter Form Component (Multi-Step) --- const ChapterForm = ({ open, onClose, chapter, books = [], onSaveSuccess }) => { const isEditMode = !!chapter; const steps = ['Chapter Info & Media', 'Details & Sequence']; const [activeStep, setActiveStep] = useState(0); // Form State const [bookNameId, setBookNameId] = useState(''); // Foreign Key const [chapterName, setChapterName] = useState(''); const [chapterImageFile, setChapterImageFile] = useState(null); const [chapterAudioVideoFile, setChapterAudioVideoFile] = useState(null); const [imagePreview, setImagePreview] = useState(null); const [chapterDescription, setChapterDescription] = useState(''); const [chapterSequence, setChapterSequence] = useState('others'); // Default // Edit Mode State const [deleteChapterImage, setDeleteChapterImage] = useState(false); // Delete Audio/Video is handled by its dedicated modal or API flag const [error, setError] = useState(''); const [loading, setLoading] = useState(false); // Populate form useEffect(() => { if (isEditMode && chapter) { setBookNameId(chapter.book_name_id || ''); setChapterName(chapter.chapter_name || ''); setChapterDescription(chapter.chapter_description || ''); setChapterSequence(chapter.chapter_sequence || 'others'); setImagePreview(chapter.chapter_image ? `/uploads/images/${chapter.chapter_image}` : null); // Reset file inputs and delete flags setChapterImageFile(null); setChapterAudioVideoFile(null); setDeleteChapterImage(false); } else { // Reset for Add mode setBookNameId(''); setChapterName(''); setChapterDescription(''); setChapterSequence('others'); setChapterImageFile(null); setChapterAudioVideoFile(null); setImagePreview(null); setDeleteChapterImage(false); } setError(''); setActiveStep(0); }, [chapter, isEditMode, open]); // Image Preview Effect useEffect(() => { if (!chapterImageFile) { if (isEditMode && !deleteChapterImage && chapter?.chapter_image) { setImagePreview(`/uploads/images/${chapter.chapter_image}`); } else if (!deleteChapterImage && !isEditMode) { setImagePreview(null); // Clear only if adding and no file } return; } const objectUrl = URL.createObjectURL(chapterImageFile); setImagePreview(objectUrl); return () => URL.revokeObjectURL(objectUrl); }, [chapterImageFile, isEditMode, chapter?.chapter_image, deleteChapterImage]); // --- Stepper Navigation --- const handleNext = () => { if (activeStep === 0) { if (!bookNameId || !chapterName ) { setError('Please select Book and enter Chapter Name.'); return; } // Media required only on Add if (!isEditMode && !chapterAudioVideoFile) { setError('Audio/Video file is required when adding a chapter.'); return; } } if(activeStep === 1) { // Validate step 2 before submitting (e.g., sequence) if (!chapterSequence) { setError('Please select a chapter sequence.'); return; } } setError(''); setActiveStep((prev) => prev + 1); }; const handleBack = () => setActiveStep((prev) => prev - 1); // --- Input Handlers --- const handleImageFileChange = (event) => { if (event.target.files && event.target.files[0]) { setChapterImageFile(event.target.files[0]); setDeleteChapterImage(false); // Unset delete on new file } }; const handleAudioVideoFileChange = (event) => { if (event.target.files && event.target.files[0]) { setChapterAudioVideoFile(event.target.files[0]); // Delete flag for audio/video managed by dedicated modal/API flag } }; const handleDeleteImageToggle = (event) => { setDeleteChapterImage(event.target.checked); if (event.target.checked) { setChapterImageFile(null); setImagePreview(null); } else if (isEditMode && chapter?.chapter_image) { setImagePreview(`/uploads/images/${chapter.chapter_image}`); } }; // --- Form Submission --- const handleSubmit = async () => { setError(''); // Final validation (Step 2 fields) - Already done in handleNext for step 1 if (!chapterSequence) { setError('Please select a chapter sequence.'); setActiveStep(1); // Ensure user is on Step 2 return; } setLoading(true); // Set loading after validation const formData = new FormData(); formData.append('book_name_id', bookNameId); formData.append('chapter_name', chapterName); formData.append('chapter_description', chapterDescription); formData.append('chapter_sequence', chapterSequence); // Add files if they exist if (chapterImageFile) formData.append('chapter_image', chapterImageFile); if (chapterAudioVideoFile) formData.append('chapter_audio_video', chapterAudioVideoFile); // Add delete flag if editing image AND original exists if (isEditMode && deleteChapterImage && chapter?.chapter_image) { formData.append('deleteChapterImage', 'true'); } // Delete audio/video flag handled by dedicated modal/API const url = isEditMode ? `/api/book-chapters/${chapter.id}` : '/api/book-chapters'; const method = isEditMode ? 'put' : 'post'; try { const response = await axios({ url, method, data: formData, headers: { 'Content-Type': 'multipart/form-data' } }); if (response.status === 200 || response.status === 201) { onSaveSuccess(); handleClose(); } else { setError(response.data.message || `Failed to ${isEditMode ? 'update' : 'add'} chapter.`); } } catch (err) { console.error(`Error ${isEditMode ? 'updating' : 'adding'} chapter:`, err); setError(err.response?.data?.message || 'An error occurred.'); } finally { setLoading(false); } }; const handleClose = () => { // Reset state fully setBookNameId(''); setChapterName(''); setChapterDescription(''); setChapterSequence('others'); setChapterImageFile(null); setChapterAudioVideoFile(null); setImagePreview(null); setDeleteChapterImage(false); setError(''); setLoading(false); setActiveStep(0); onClose(); }; return ( // Responsive Dialog {isEditMode ? `Edit Chapter: ${chapter?.chapter_name}` : 'Add New Book Chapter'} {/* Stepper adapts reasonably */} {steps.map((label) => ({label}))} {error && {error}} {activeStep === 0 && ( Book Name setChapterName(e.target.value)} required disabled={loading} /> {/* Audio/Video Upload */} Chapter Audio/Video * {chapterAudioVideoFile && New: {chapterAudioVideoFile.name}} {isEditMode && chapter?.chapter_audio_video && !chapterAudioVideoFile && (Current media exists) } {!isEditMode && * Audio/Video required for new chapter} )} {activeStep === 1 && ( {/* Image Upload */} Chapter Image (Optional) {/* --- UPDATED BUTTON/LABEL/INPUT STRUCTURE --- */} {/* --- END OF UPDATE --- */} {isEditMode && chapter?.chapter_image && } label={Delete} sx={{ ml: 1 }} /> } {chapterImageFile && New: {chapterImageFile.name}} {/* Description */} setChapterDescription(e.target.value)} disabled={loading}/> {/* Sequence */} Chapter Sequence )} {activeStep === steps.length - 1 ? ( ) : ( )} ); }; // --- BookChapters Main Component --- const BookChapters = () => { // State for chapters, books (for dropdown), loading, error, search const [chapters, setChapters] = useState([]); const [books, setBooks] = useState([]); const [filteredChapters, setFilteredChapters] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [searchTerm, setSearchTerm] = useState(''); // Dialog States const [formOpen, setFormOpen] = useState(false); const [editingChapter, setEditingChapter] = useState(null); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [chapterToDelete, setChapterToDelete] = useState(null); // Sub-Modal States const [editingDescChapter, setEditingDescChapter] = useState(null); const [editingMediaChapter, setEditingMediaChapter] = useState(null); const fetchData = async () => { setLoading(true); setError(null); try { const [chaptersRes, booksRes] = await Promise.all([ axios.get('/api/book-chapters'), // Gets all chapters with book names joined axios.get('/api/book-names') // Needed for the Add/Edit dropdown ]); setChapters(chaptersRes.data); setFilteredChapters(chaptersRes.data); setBooks(booksRes.data); } catch (err) { console.error('Error fetching chapters or books:', err); setError(err.response?.data?.message || 'Failed to fetch data'); } finally { setLoading(false); } }; useEffect(() => { fetchData(); }, []); // Search Handler useEffect(() => { const lowerSearchTerm = searchTerm.toLowerCase(); const results = chapters.filter(c => (c.chapter_name && c.chapter_name.toLowerCase().includes(lowerSearchTerm)) || (c.book_name && c.book_name.toLowerCase().includes(lowerSearchTerm)) || // Search by book name (c.chapter_sequence && c.chapter_sequence.toLowerCase().includes(lowerSearchTerm)) ); setFilteredChapters(results); }, [searchTerm, chapters]); // --- Dialog/Modal Openers & Closers --- const handleAddClick = () => { setEditingChapter(null); setFormOpen(true); }; const handleEditClick = (chapter) => { setEditingChapter(chapter); setFormOpen(true); }; const handleDeleteClick = (chapter) => { setChapterToDelete(chapter); setConfirmDeleteOpen(true); }; const handleFormClose = () => { setFormOpen(false); setEditingChapter(null); }; const handleConfirmDeleteClose = () => { setConfirmDeleteOpen(false); setChapterToDelete(null); }; const handleEditDescClick = (chapter) => setEditingDescChapter(chapter); const handleEditMediaClick = (chapter) => setEditingMediaChapter(chapter); const handleCloseSubModal = () => { setEditingDescChapter(null); setEditingMediaChapter(null); }; // --- Actions --- const handleSaveSuccess = () => { fetchData(); // Refetch chapters after add/update }; const handleConfirmDelete = async () => { if (!chapterToDelete) return; setLoading(true); setError(null); try { await axios.delete(`/api/book-chapters/${chapterToDelete.id}`); handleConfirmDeleteClose(); // Close first fetchData(); // Then refetch } catch (err) { console.error('Error deleting chapter:', err); setError(err.response?.data?.message || 'Failed to delete chapter.'); handleConfirmDeleteClose(); // Still close on error } finally { setLoading(false); } }; // --- Render Logic --- if (loading && chapters.length === 0) { return ; } return ( Book Chapters {error && setError(null)}>{error}} {/* Controls */} setSearchTerm(e.target.value)} InputProps={{ startAdornment: ( ), }} sx={{ flexGrow: { xs: 1, sm: 0 }, width: { xs: '100%', sm: 300, md: 400 } }} /> {/* Add/Edit Chapter Modal */} {/* Delete Confirmation */} Confirm Deletion {/* Use standard text component */} Are you sure you want to delete chapter "{chapterToDelete?.chapter_name}" from "{chapterToDelete?.book_name}"? {/* Edit Description Modal */} {editingDescChapter && ( )} {/* Edit Media Modal */} {editingMediaChapter && ( )} {/* Chapters Table */} ID {/* Hide ID xs */} Book Name Image {/* Hide Img xs/sm */} Chapter Name Sequence {/* Hide Seq xs/sm */} Actions {loading && chapters.length > 0 && ( )} {!loading && filteredChapters.map((chap) => ( {chap.id} {chap.book_name} {chap.chapter_name} {chap.chapter_sequence} handleEditClick(chap)}> handleEditDescClick(chap)}> handleEditMediaClick(chap)}> handleDeleteClick(chap)}> ))} {!loading && filteredChapters.length === 0 && ( No chapters found{searchTerm ? ' matching search' : ''}. )}
); }; export default BookChapters; // app/admin/components/BookGenres.js 'use client'; import React, { useState, useEffect } from 'react'; import axios from 'axios'; // MUI Components import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; import CircularProgress from '@mui/material/CircularProgress'; import Alert from '@mui/material/Alert'; import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; import DialogContentText from '@mui/material/DialogContentText'; // <--- CORRECTED: Added import import IconButton from '@mui/material/IconButton'; import Avatar from '@mui/material/Avatar'; import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; import TableCell from '@mui/material/TableCell'; import TableContainer from '@mui/material/TableContainer'; import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import Paper from '@mui/material/Paper'; import Checkbox from '@mui/material/Checkbox'; import Tooltip from '@mui/material/Tooltip'; import FormControlLabel from '@mui/material/FormControlLabel'; import InputAdornment from '@mui/material/InputAdornment'; // MUI Icons import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import AddIcon from '@mui/icons-material/Add'; import RefreshIcon from '@mui/icons-material/Refresh'; import SearchIcon from '@mui/icons-material/Search'; import SaveIcon from '@mui/icons-material/Save'; import CancelIcon from '@mui/icons-material/Cancel'; import ImageIcon from '@mui/icons-material/Image'; // Generic image icon import UploadFileIcon from '@mui/icons-material/UploadFile'; // --- Add/Edit Genre Form Component --- const GenreForm = ({ open, onClose, genre, onSaveSuccess }) => { const isEditMode = !!genre; // Determine if editing based on genre prop const [genreName, setGenreName] = useState(''); const [genreImageFile, setGenreImageFile] = useState(null); const [preview, setPreview] = useState(null); const [deleteImage, setDeleteImage] = useState(false); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); useEffect(() => { if (isEditMode && genre) { setGenreName(genre.genre_name || ''); setPreview(genre.genre_image ? `/uploads/images/${genre.genre_image}` : null); setGenreImageFile(null); // Clear file input on edit open setDeleteImage(false); // Reset delete flag } else { // Reset for Add mode setGenreName(''); setGenreImageFile(null); setPreview(null); setDeleteImage(false); } setError(''); // Clear error when form opens/changes mode }, [genre, isEditMode, open]); // Rerun when these change // Image Preview Effect useEffect(() => { if (!genreImageFile) { // If editing and file cleared, keep existing preview unless delete is checked if (isEditMode && !deleteImage && genre?.genre_image) { setPreview(`/uploads/images/${genre.genre_image}`); } else if (!deleteImage && !isEditMode) { // Clear preview only if adding and no file setPreview(null); } // If deleteImage is true, preview is cleared by the toggle handler return; } // Create preview for new file const objectUrl = URL.createObjectURL(genreImageFile); setPreview(objectUrl); return () => URL.revokeObjectURL(objectUrl); }, [genreImageFile, isEditMode, genre?.genre_image, deleteImage]); const handleFileChange = (event) => { if (event.target.files && event.target.files[0]) { setGenreImageFile(event.target.files[0]); setDeleteImage(false); // Uncheck delete if a new file is selected } }; const handleDeleteImageToggle = (event) => { setDeleteImage(event.target.checked); if (event.target.checked) { setGenreImageFile(null); // Clear selected file if delete is checked setPreview(null); // Clear preview immediately } else if (isEditMode && genre?.genre_image) { // If unchecked, restore original image preview if editing setPreview(`/uploads/images/${genre.genre_image}`); } }; const handleSubmit = async (e) => { e.preventDefault(); setError(''); if (!genreName) { setError('Genre name is required.'); return; } // Image is required for Add, optional for Edit (unless delete flag set without new image) if (!isEditMode && !genreImageFile) { setError('Genre image is required when adding.'); return; } // If editing, check if deleting without adding new one while original is null (shouldn't happen) if (isEditMode && deleteImage && !genre?.genre_image) { setError('No existing image to delete.'); // Edge case return; } setLoading(true); // Start loading after validation const formData = new FormData(); formData.append('genre_name', genreName); if (genreImageFile) { // API expects 'genreImage' based on original api route code provided formData.append('genreImage', genreImageFile); } // Only send delete flag if editing and checked AND there was an original image if (isEditMode && deleteImage && genre?.genre_image) { formData.append('deleteGenreImage', 'true'); } // Use internal API endpoints const url = isEditMode ? `/api/book-genres/${genre.id}` : '/api/book-genres'; const method = isEditMode ? 'put' : 'post'; try { const response = await axios({ url, method, data: formData, headers: { 'Content-Type': 'multipart/form-data' } }); if (response.status === 200 || response.status === 201) { onSaveSuccess(); // Notify parent to refresh list handleClose(); // Close and reset form } else { // This case might not be reached if axios throws for non-2xx status setError(response.data.message || `Failed to ${isEditMode ? 'update' : 'add'} genre.`); } } catch (err) { console.error(`Error ${isEditMode ? 'updating' : 'adding'} genre:`, err); setError(err.response?.data?.message || 'An error occurred.'); } finally { setLoading(false); } }; const handleClose = () => { // Reset form state fully before closing setGenreName(''); setGenreImageFile(null); setPreview(null); setDeleteImage(false); setError(''); setLoading(false); onClose(); // Call the parent's onClose handler }; return ( {isEditMode ? `Edit Genre: ${genre?.genre_name}` : 'Add New Book Genre'} {error && {error}} setGenreName(e.target.value)} required disabled={loading} /> {!preview && } {/* Show icon if no image */} {/* Show delete checkbox only when editing an existing image */} {isEditMode && genre?.genre_image && } label="Delete Image" sx={{ alignSelf: 'flex-start', mt: 1 }} // Align checkbox left, add margin /> } {genreImageFile && New: {genreImageFile.name}} {!isEditMode && * Image required for new genre} ); }; // --- BookGenres Main Component --- const BookGenre = () => { const [genres, setGenres] = useState([]); const [filteredGenres, setFilteredGenres] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [searchTerm, setSearchTerm] = useState(''); // Dialog States const [formOpen, setFormOpen] = useState(false); const [editingGenre, setEditingGenre] = useState(null); // null for Add, genre object for Edit const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [genreToDelete, setGenreToDelete] = useState(null); const fetchGenres = async () => { setLoading(true); setError(null); try { const response = await axios.get('/api/book-genres'); // Use internal API setGenres(response.data); setFilteredGenres(response.data); } catch (err) { console.error('Error fetching genres:', err); setError(err.response?.data?.message || 'Failed to fetch genres'); } finally { setLoading(false); } }; useEffect(() => { fetchGenres(); }, []); // Search Handler useEffect(() => { const lowerSearchTerm = searchTerm.toLowerCase(); const results = genres.filter(g => (g.genre_name && g.genre_name.toLowerCase().includes(lowerSearchTerm)) ); setFilteredGenres(results); }, [searchTerm, genres]); // --- Dialog Openers --- const handleAddClick = () => { setEditingGenre(null); // Ensure edit mode is off setFormOpen(true); }; const handleEditClick = (genre) => { setEditingGenre(genre); // Set genre data for editing setFormOpen(true); }; const handleDeleteClick = (genre) => { setGenreToDelete(genre); setConfirmDeleteOpen(true); }; // --- Dialog Closers --- const handleFormClose = () => { setFormOpen(false); setEditingGenre(null); // Clear editing state on close }; const handleConfirmDeleteClose = () => { setConfirmDeleteOpen(false); setGenreToDelete(null); }; // --- Actions --- const handleSaveSuccess = () => { fetchGenres(); // Refetch data after add/update // Optionally show success Snackbar }; const handleConfirmDelete = async () => { if (!genreToDelete) return; setLoading(true); // Use main loading indicator setError(null); try { await axios.delete(`/api/book-genres/${genreToDelete.id}`); // Use internal API handleConfirmDeleteClose(); // Close dialog immediately fetchGenres(); // Refresh list // Optionally show success Snackbar } catch (err) { console.error('Error deleting genre:', err); // Display error from API response if available setError(err.response?.data?.message || 'Failed to delete genre.'); handleConfirmDeleteClose(); // Still close dialog on error } finally { setLoading(false); } }; // --- Render Logic --- if (loading && genres.length === 0) { return ; } return ( Book Genres {error && setError(null)}>{error}} {/* Controls */} setSearchTerm(e.target.value)} InputProps={{ startAdornment: ( ), }} sx={{ flexGrow: { xs: 1, sm: 0 }, width: { xs: '100%', sm: 250, md: 300 } }} /> {/* Add/Edit Genre Modal */} {/* Delete Confirmation Dialog */} Confirm Deletion {/* CORRECTED: Use imported DialogContentText */} Are you sure you want to delete the genre "{genreToDelete?.genre_name}"? This might affect associated books. {/* Genres Table */} ID {/* Hide ID on xs */} Image Genre Name Actions {loading && genres.length > 0 && ( )} {!loading && filteredGenres.map((genre) => ( {genre.id} {!genre.genre_image && } {genre.genre_name} handleEditClick(genre)}> handleDeleteClick(genre)}> ))} {!loading && filteredGenres.length === 0 && ( No genres found{searchTerm ? ' matching your search' : ''}. )}
); }; export default BookGenre; // app/admin/components/BookNames.js 'use client'; import React, { useState, useEffect } from 'react'; import axios from 'axios'; // MUI Components import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; import CircularProgress from '@mui/material/CircularProgress'; import Alert from '@mui/material/Alert'; import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; import IconButton from '@mui/material/IconButton'; import Avatar from '@mui/material/Avatar'; import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; import TableCell from '@mui/material/TableCell'; import TableContainer from '@mui/material/TableContainer'; import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import Paper from '@mui/material/Paper'; import Tooltip from '@mui/material/Tooltip'; import Select from '@mui/material/Select'; import MenuItem from '@mui/material/MenuItem'; import InputLabel from '@mui/material/InputLabel'; import FormControl from '@mui/material/FormControl'; import Stepper from '@mui/material/Stepper'; import Step from '@mui/material/Step'; import StepLabel from '@mui/material/StepLabel'; import Checkbox from '@mui/material/Checkbox'; import InputAdornment from '@mui/material/InputAdornment'; import FormControlLabel from '@mui/material/FormControlLabel'; // Import // MUI Icons import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import AddIcon from '@mui/icons-material/Add'; import RefreshIcon from '@mui/icons-material/Refresh'; import SearchIcon from '@mui/icons-material/Search'; import SaveIcon from '@mui/icons-material/Save'; import CancelIcon from '@mui/icons-material/Cancel'; import ImageIcon from '@mui/icons-material/Image'; import UploadFileIcon from '@mui/icons-material/UploadFile'; import DescriptionIcon from '@mui/icons-material/Description'; // For Edit Desc button import TheatersIcon from '@mui/icons-material/Theaters'; // For Edit Trailer button import NavigateNextIcon from '@mui/icons-material/NavigateNext'; import NavigateBeforeIcon from '@mui/icons-material/NavigateBefore'; // Import Sub-Modals import EditBookDescription from './EditBookDescription'; import EditBookTrailer from './EditBookTrailer'; // --- Add/Edit Book Form Component (Multi-Step) --- const BookNameForm = ({ open, onClose, book, genres = [], onSaveSuccess }) => { const isEditMode = !!book; const steps = ['Basic Info & Media', 'Details & Preference']; const [activeStep, setActiveStep] = useState(0); // Form State const [bookName, setBookName] = useState(''); const [bookAuthor, setBookAuthor] = useState(''); const [genreId, setGenreId] = useState(''); const [bookDescription, setBookDescription] = useState(''); const [bookPreference, setBookPreference] = useState('none'); const [bookImageFile, setBookImageFile] = useState(null); const [trailerFile, setTrailerFile] = useState(null); const [imagePreview, setImagePreview] = useState(null); // No trailer preview needed here, handled by EditBookTrailer modal // Edit Mode State const [deleteBookImage, setDeleteBookImage] = useState(false); // Delete trailer flag is managed internally by EditBookTrailer modal/API const [error, setError] = useState(''); const [loading, setLoading] = useState(false); // Populate form on edit mode or reset useEffect(() => { if (isEditMode && book) { setBookName(book.book_name || ''); setBookAuthor(book.book_author || ''); setGenreId(book.genre_id || ''); setBookDescription(book.book_description || ''); setBookPreference(book.book_preference || 'none'); setImagePreview(book.book_image ? `/uploads/images/${book.book_image}` : null); setBookImageFile(null); setTrailerFile(null); setDeleteBookImage(false); } else { setBookName(''); setBookAuthor(''); setGenreId(''); setBookDescription(''); setBookPreference('none'); setBookImageFile(null); setTrailerFile(null); setImagePreview(null); setDeleteBookImage(false); } setError(''); setActiveStep(0); }, [book, isEditMode, open]); // Image Preview Effect useEffect(() => { if (!bookImageFile) { if (isEditMode && !deleteBookImage && book?.book_image) { setImagePreview(`/uploads/images/${book.book_image}`); } else if (!deleteBookImage && !isEditMode) { setImagePreview(null); } // Keep preview if deleteImage is true until save/cancel return; } const objectUrl = URL.createObjectURL(bookImageFile); setImagePreview(objectUrl); return () => URL.revokeObjectURL(objectUrl); }, [bookImageFile, isEditMode, book?.book_image, deleteBookImage]); // --- Stepper Navigation --- const handleNext = () => { if (activeStep === 0) { if (!bookName || !bookAuthor ) { setError('Please fill in Book Name and Author.'); return; } // Image required only on Add if (!isEditMode && !bookImageFile) { setError('Book Image is required when adding.'); return; } } // Optional: Add validation for Step 1 -> 2 if needed setError(''); // Clear error if validation passes setActiveStep((prevActiveStep) => prevActiveStep + 1); }; const handleBack = () => setActiveStep((prevActiveStep) => prevActiveStep - 1); // --- Input Handlers --- const handleImageFileChange = (event) => { if (event.target.files && event.target.files[0]) { setBookImageFile(event.target.files[0]); setDeleteBookImage(false); // Unset delete if new file selected } }; const handleTrailerFileChange = (event) => { if (event.target.files && event.target.files[0]) { setTrailerFile(event.target.files[0]); // Delete trailer flag is handled by EditBookTrailer modal or API flag directly } }; const handleDeleteImageToggle = (event) => { setDeleteBookImage(event.target.checked); if (event.target.checked) { setBookImageFile(null); // Clear selected file setImagePreview(null); // Clear preview immediately } else if (isEditMode && book?.book_image) { // Restore original preview if unchecked setImagePreview(`/uploads/images/${book.book_image}`); } }; // --- Form Submission --- const handleSubmit = async () => { setError(''); // Final Validation (Step 2 fields) if (!genreId) { setError('Please select a Genre.'); setActiveStep(1); // Go back to step 2 if error return; } if (!bookDescription) { setError('Please provide a Book Description.'); setActiveStep(1); // Go back to step 2 if error return; } setLoading(true); // Set loading after validation const formData = new FormData(); formData.append('book_name', bookName); formData.append('book_author', bookAuthor); formData.append('genre_id', genreId); formData.append('book_description', bookDescription); formData.append('book_preference', bookPreference); if (bookImageFile) formData.append('bookImage', bookImageFile); if (trailerFile) formData.append('trailer', trailerFile); // Only add if new one selected // Add delete flags if editing and checked AND original exists if (isEditMode) { if (deleteBookImage && book?.book_image) formData.append('deleteBookImage', 'true'); // Delete trailer flag is handled separately by the EditBookTrailer modal's API call } const url = isEditMode ? `/api/book-names/${book.id}` : '/api/book-names'; const method = isEditMode ? 'put' : 'post'; try { const response = await axios({ url, method, data: formData, headers: { 'Content-Type': 'multipart/form-data' } }); if (response.status === 200 || response.status === 201) { onSaveSuccess(); handleClose(); } else { setError(response.data.message || `Failed to ${isEditMode ? 'update' : 'add'} book.`); } } catch (err) { console.error(`Error ${isEditMode ? 'updating' : 'adding'} book:`, err); setError(err.response?.data?.message || 'An error occurred.'); } finally { setLoading(false); } }; const handleClose = () => { // Reset state fully before closing setBookName(''); setBookAuthor(''); setGenreId(''); setBookDescription(''); setBookPreference('none'); setBookImageFile(null); setTrailerFile(null); setImagePreview(null); setDeleteBookImage(false); setError(''); setLoading(false); setActiveStep(0); onClose(); }; return ( // Responsive Dialog {isEditMode ? `Edit Book: ${book?.book_name}` : 'Add New Book Name'} {steps.map((label) => ( {label} ))} {error && {error}} {/* Form Content based on Active Step */} {activeStep === 0 && ( setBookName(e.target.value)} required disabled={loading} /> setBookAuthor(e.target.value)} required disabled={loading}/> {/* Image Upload */} Book Image * {!imagePreview && } {isEditMode && book?.book_image && } label={Delete} // Smaller label sx={{ ml: 1 }} // Adjust margin if needed /> } {bookImageFile && New: {bookImageFile.name}} {!isEditMode && * Image required for new book} {/* Trailer Upload (Optional on Edit) */} Book Trailer (Optional) {trailerFile && New: {trailerFile.name}} {isEditMode && book?.trailer && !trailerFile && (Current trailer exists) } )} {activeStep === 1 && ( {/* Genre Select */} Book Genre {/* Description */} setBookDescription(e.target.value)} required disabled={loading}/> {/* Preference Select */} Book Preference )} {activeStep === steps.length - 1 ? ( ) : ( )} ); }; // --- BookNames Main Component --- const BookNames = () => { const [bookNames, setBookNames] = useState([]); const [genres, setGenres] = useState([]); // Need genres for the form select const [filteredBookNames, setFilteredBookNames] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [searchTerm, setSearchTerm] = useState(''); // Dialog States const [formOpen, setFormOpen] = useState(false); const [editingBook, setEditingBook] = useState(null); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [bookToDelete, setBookToDelete] = useState(null); // Sub-Modal States const [editingDescBook, setEditingDescBook] = useState(null); const [editingTrailerBook, setEditingTrailerBook] = useState(null); const fetchBookNamesAndGenres = async () => { setLoading(true); setError(null); try { // Fetch both in parallel const [booksRes, genresRes] = await Promise.all([ axios.get('/api/book-names'), axios.get('/api/book-genres') ]); setBookNames(booksRes.data); setFilteredBookNames(booksRes.data); setGenres(genresRes.data); } catch (err) { console.error('Error fetching books or genres:', err); setError(err.response?.data?.message || 'Failed to fetch data'); } finally { setLoading(false); } }; useEffect(() => { fetchBookNamesAndGenres(); }, []); // Search Handler useEffect(() => { const lowerSearchTerm = searchTerm.toLowerCase(); const results = bookNames.filter(b => (b.book_name && b.book_name.toLowerCase().includes(lowerSearchTerm)) || (b.book_author && b.book_author.toLowerCase().includes(lowerSearchTerm)) || (b.genre_name && b.genre_name.toLowerCase().includes(lowerSearchTerm)) || // Search genre name too (b.book_preference && b.book_preference.toLowerCase().includes(lowerSearchTerm)) ); setFilteredBookNames(results); }, [searchTerm, bookNames]); // --- Dialog/Modal Openers & Closers --- const handleAddClick = () => { setEditingBook(null); setFormOpen(true); }; const handleEditClick = (book) => { setEditingBook(book); setFormOpen(true); }; const handleDeleteClick = (book) => { setBookToDelete(book); setConfirmDeleteOpen(true); }; const handleFormClose = () => { setFormOpen(false); setEditingBook(null); }; const handleConfirmDeleteClose = () => { setConfirmDeleteOpen(false); setBookToDelete(null); }; const handleEditDescClick = (book) => setEditingDescBook(book); const handleEditTrailerClick = (book) => setEditingTrailerBook(book); const handleCloseSubModal = () => { setEditingDescBook(null); setEditingTrailerBook(null); }; // --- Actions --- const handleSaveSuccess = () => { fetchBookNamesAndGenres(); // Refetch data after add/update // Optionally show success Snackbar }; const handleConfirmDelete = async () => { if (!bookToDelete) return; setLoading(true); setError(null); try { await axios.delete(`/api/book-names/${bookToDelete.id}`); handleConfirmDeleteClose(); // Close first fetchBookNamesAndGenres(); // Refresh list } catch (err) { console.error('Error deleting book:', err); setError(err.response?.data?.message || 'Failed to delete book.'); handleConfirmDeleteClose(); // Still close on error } finally { setLoading(false); } }; // --- Render Logic --- // Similar loading/error display as BookGenres if (loading && bookNames.length === 0) { return ; } return ( Book Names {error && setError(null)}>{error}} {/* Controls */} setSearchTerm(e.target.value)} InputProps={{ startAdornment: ( ), }} sx={{ flexGrow: { xs: 1, sm: 0 }, width: { xs: '100%', sm: 300, md: 400 } }} /> {/* Add/Edit Book Modal (Multi-Step) */} {/* Delete Confirmation */} Confirm Deletion Are you sure you want to delete "{bookToDelete?.book_name}"? Associated chapters might also be affected. {/* Edit Description Modal */} {editingDescBook && ( )} {/* Edit Trailer Modal */} {editingTrailerBook && ( )} {/* Book Names Table */} ID {/* Hide ID xs */} Image Book Name Author {/* Hide Author xs/sm */} Genre {/* Hide Genre xs/sm */} Preference {/* Hide Preference xs/sm/md */} Actions {loading && bookNames.length > 0 && ( )} {!loading && filteredBookNames.map((book) => ( {book.id} {book.book_name} {book.book_author} {book.genre_name || 'N/A'} {/* Display genre name from JOIN */} {book.book_preference || 'none'} {/* Responsive Actions - Use small icons and ensure they fit */} handleEditClick(book)}> handleEditDescClick(book)}> handleEditTrailerClick(book)}> handleDeleteClick(book)}> ))} {!loading && filteredBookNames.length === 0 && ( No books found{searchTerm ? ' matching search' : ''}. )}
); }; export default BookNames; // app/admin/components/EditBookDescription.js 'use client'; import React, { useState, useRef, useEffect } from 'react'; import axios from 'axios'; import { useRouter } from 'next/navigation'; // Keep router if needed for refresh // MUI Components import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; import TextField from '@mui/material/TextField'; import Button from '@mui/material/Button'; import Box from '@mui/material/Box'; import IconButton from '@mui/material/IconButton'; import CircularProgress from '@mui/material/CircularProgress'; import Alert from '@mui/material/Alert'; import Tooltip from '@mui/material/Tooltip'; // MUI Icons import FormatBoldIcon from '@mui/icons-material/FormatBold'; import FormatItalicIcon from '@mui/icons-material/FormatItalic'; import FormatUnderlinedIcon from '@mui/icons-material/FormatUnderlined'; const EditBookDescription = ({ bookId, bookName, initialDescription, onClose, onUpdateSuccess }) => { const [description, setDescription] = useState(initialDescription || ''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const textareaRef = useRef(null); // const router = useRouter(); // Keep if explicit refresh is needed after success useEffect(() => { // Update state if the initial prop changes (e.g., opening for a different book) setDescription(initialDescription || ''); setError(''); // Clear error when opening for a new item }, [initialDescription, bookId]); const handleUpdate = async () => { setLoading(true); setError(''); try { // Use internal API route const response = await axios.put(`/api/book-names/${bookId}`, { book_description: description // Send only the field being updated }/* , { headers: { 'Content-Type': 'application/json' } } */); // Axios defaults to JSON if (response.status === 200) { if(onUpdateSuccess) onUpdateSuccess(); // Notify parent (e.g., to refetch data) onClose(); // Close the dialog // router.refresh(); // Optionally refresh if parent doesn't handle state update } else { setError(response.data.message || 'Failed to update description.'); } } catch (err) { console.error("Error updating book description:", err); setError(err.response?.data?.message || 'An error occurred.'); } finally { setLoading(false); } }; // --- Text Formatting --- // Basic formatting - more complex editors exist (like TipTap, Slate) if needed const formatText = (tag) => { const textarea = textareaRef.current; if (!textarea) return; const start = textarea.selectionStart; const end = textarea.selectionEnd; const selectedText = description.substring(start, end); const beforeText = description.substring(0, start); const afterText = description.substring(end); // Simple wrapping for demo (doesn't handle nested or partial tags well) setDescription(`${beforeText}<${tag}>${selectedText}${afterText}`); // Attempt to re-focus and select (might need adjustment) textarea.focus(); // Use setTimeout to allow state update before setting selection setTimeout(() => { if (textarea) { textarea.setSelectionRange(start + `<${tag}>`.length, end + `<${tag}>`.length); } }, 0); }; return ( Edit Description for "{bookName}" {error && {error}} {/* Simple Formatting Toolbar */} formatText('b')}> formatText('i')}> formatText('u')}> {/* Add more buttons as needed */} setDescription(e.target.value)} disabled={loading} // Use helperText for instructions if needed /> ); }; export default EditBookDescription; // app/admin/components/EditBookTrailer.js 'use client'; import React, { useState, useRef, useEffect } from 'react'; import axios from 'axios'; import { useRouter } from 'next/navigation'; // If needed for refresh // MUI Components import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; import Button from '@mui/material/Button'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import CircularProgress from '@mui/material/CircularProgress'; import Alert from '@mui/material/Alert'; import IconButton from '@mui/material/IconButton'; import Input from '@mui/material/Input'; // For hidden file input if needed // MUI Icons import VideoCameraFrontIcon from '@mui/icons-material/VideoCameraFront'; import DeleteForeverIcon from '@mui/icons-material/DeleteForever'; const EditBookTrailer = ({ bookId, bookName, initialTrailer, onClose, onUpdateSuccess }) => { const [currentTrailer, setCurrentTrailer] = useState(initialTrailer); const [newTrailerFile, setNewTrailerFile] = useState(null); const [trailerPreviewUrl, setTrailerPreviewUrl] = useState(null); // For previewing NEW uploads const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const videoRef = useRef(null); // Ref for the video player // const router = useRouter(); useEffect(() => { // Update state if initial prop changes setCurrentTrailer(initialTrailer); setNewTrailerFile(null); // Clear any previously selected new file setTrailerPreviewUrl(null); setError(''); }, [initialTrailer, bookId]); // Effect to create preview URL for newly selected file useEffect(() => { if (!newTrailerFile) { setTrailerPreviewUrl(null); // Clear preview if file is cleared return; } const objectUrl = URL.createObjectURL(newTrailerFile); setTrailerPreviewUrl(objectUrl); // Clean up the object URL on unmount or when the file changes return () => URL.revokeObjectURL(objectUrl); }, [newTrailerFile]); const handleFileChange = (event) => { if (event.target.files && event.target.files[0]) { setNewTrailerFile(event.target.files[0]); setError(''); // Clear previous errors } }; const handleUpdate = async () => { if (!newTrailerFile) { setError("Please select a new trailer file to upload."); return; } setLoading(true); setError(''); const formData = new FormData(); formData.append('trailer', newTrailerFile); // Key matches backend API try { const response = await axios.put(`/api/book-names/${bookId}`, formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); if (response.status === 200) { if (onUpdateSuccess) onUpdateSuccess(); onClose(); } else { setError(response.data.message || 'Failed to update trailer.'); } } catch (err) { console.error("Error updating book trailer:", err); setError(err.response?.data?.message || 'An error occurred during upload.'); } finally { setLoading(false); } }; const handleDeleteTrailer = async () => { if (!currentTrailer) { setError("No current trailer to delete."); return; } setLoading(true); setError(''); const formData = new FormData(); formData.append('deleteTrailer', 'true'); // Signal deletion try { const response = await axios.put(`/api/book-names/${bookId}`, formData, { headers: { 'Content-Type': 'multipart/form-data' }, // Still need this header type }); if (response.status === 200) { if (onUpdateSuccess) onUpdateSuccess(); onClose(); } else { setError(response.data.message || 'Failed to delete trailer.'); } } catch (err) { console.error("Error deleting book trailer:", err); setError(err.response?.data?.message || 'An error occurred during deletion.'); } finally { setLoading(false); } }; // Determine which URL to use for the video player const videoSource = trailerPreviewUrl // Show preview if a new file is selected ? trailerPreviewUrl : currentTrailer ? `/uploads/videos/${currentTrailer}` // Otherwise show current trailer : null; return ( Edit Trailer for "{bookName}" {error && {error}} {/* Video Player */} {videoSource ? ( ) : ( No trailer available. )} {/* Upload Controls */} {currentTrailer && ( // Show delete button only if a trailer exists )} {newTrailerFile && ( Selected: {newTrailerFile.name} )} {/* Conditionally show Update button only if a new file is selected */} {newTrailerFile && ( )} ); }; export default EditBookTrailer; // app/admin/components/EditChapterDescription.js 'use client'; import React, { useState, useRef, useEffect } from 'react'; import axios from 'axios'; // MUI Components (same as EditBookDescription) import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; import TextField from '@mui/material/TextField'; import Button from '@mui/material/Button'; import Box from '@mui/material/Box'; import IconButton from '@mui/material/IconButton'; import CircularProgress from '@mui/material/CircularProgress'; import Alert from '@mui/material/Alert'; import Tooltip from '@mui/material/Tooltip'; // MUI Icons (same as EditBookDescription) import FormatBoldIcon from '@mui/icons-material/FormatBold'; import FormatItalicIcon from '@mui/icons-material/FormatItalic'; import FormatUnderlinedIcon from '@mui/icons-material/FormatUnderlined'; const EditChapterDescription = ({ chapterId, chapterName, initialDescription, onClose, onUpdateSuccess }) => { const [description, setDescription] = useState(initialDescription || ''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const textareaRef = useRef(null); useEffect(() => { setDescription(initialDescription || ''); setError(''); }, [initialDescription, chapterId]); const handleUpdate = async () => { setLoading(true); setError(''); try { // Use internal API route for book chapters const response = await axios.put(`/api/book-chapters/${chapterId}`, { chapter_description: description // Field name matches backend }); if (response.status === 200) { if(onUpdateSuccess) onUpdateSuccess(); onClose(); } else { setError(response.data.message || 'Failed to update chapter description.'); } } catch (err) { console.error("Error updating chapter description:", err); setError(err.response?.data?.message || 'An error occurred.'); } finally { setLoading(false); } }; // --- Text Formatting (same as EditBookDescription) --- const formatText = (tag) => { const textarea = textareaRef.current; if (!textarea) return; const start = textarea.selectionStart; const end = textarea.selectionEnd; const selectedText = description.substring(start, end); const beforeText = description.substring(0, start); const afterText = description.substring(end); setDescription(`${beforeText}<${tag}>${selectedText}${afterText}`); textarea.focus(); setTimeout(() => { if (textarea) { textarea.setSelectionRange(start + `<${tag}>`.length, end + `<${tag}>`.length); } }, 0); }; return ( Edit Description for "{chapterName}" {error && {error}} {/* Formatting Toolbar */} formatText('b')}> formatText('i')}> formatText('u')}> setDescription(e.target.value)} disabled={loading} /> ); }; export default EditChapterDescription; // app/admin/components/EditChapterVideoAudio.js 'use client'; import React, { useState, useRef, useEffect } from 'react'; import axios from 'axios'; // MUI Components import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; import Button from '@mui/material/Button'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import CircularProgress from '@mui/material/CircularProgress'; import Alert from '@mui/material/Alert'; import IconButton from '@mui/material/IconButton'; // Added for potential close button // MUI Icons import AudiotrackIcon from '@mui/icons-material/Audiotrack'; import VideocamIcon from '@mui/icons-material/Videocam'; import DeleteForeverIcon from '@mui/icons-material/DeleteForever'; import UploadFileIcon from '@mui/icons-material/UploadFile'; // Icon for upload button import CloseIcon from '@mui/icons-material/Close'; // Added for close button const EditChapterVideoAudio = ({ chapterId, chapterName, initialChapterVideoAudio, onClose, onUpdateSuccess }) => { const [currentMedia, setCurrentMedia] = useState(initialChapterVideoAudio); const [newMediaFile, setNewMediaFile] = useState(null); const [mediaPreviewUrl, setMediaPreviewUrl] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const mediaRef = useRef(null); // Ref for video/audio player useEffect(() => { setCurrentMedia(initialChapterVideoAudio); setNewMediaFile(null); setMediaPreviewUrl(null); setError(''); }, [initialChapterVideoAudio, chapterId]); useEffect(() => { if (!newMediaFile) { setMediaPreviewUrl(null); return; } const objectUrl = URL.createObjectURL(newMediaFile); setMediaPreviewUrl(objectUrl); return () => URL.revokeObjectURL(objectUrl); }, [newMediaFile]); const handleFileChange = (event) => { if (event.target.files && event.target.files[0]) { setNewMediaFile(event.target.files[0]); setError(''); } }; const handleUpdate = async () => { if (!newMediaFile) { setError("Please select a new audio or video file."); return; } setLoading(true); setError(''); const formData = new FormData(); formData.append('chapter_audio_video', newMediaFile); // Key matches API try { const response = await axios.put(`/api/book-chapters/${chapterId}`, formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); if (response.status === 200) { if (onUpdateSuccess) onUpdateSuccess(); onClose(); } else { setError(response.data.message || 'Failed to update media.'); } } catch (err) { console.error("Error updating chapter media:", err); setError(err.response?.data?.message || 'An error occurred during upload.'); } finally { setLoading(false); } }; // Assumes PUT route handles setting field to null correctly const handleDeleteMedia = async () => { if (!currentMedia) { setError("No current media to delete."); return; } setLoading(true); setError(''); try { // Send request to update the field to null const response = await axios.put(`/api/book-chapters/${chapterId}`, { chapter_audio_video: null // Update field to null }); // Axios defaults to JSON content-type if (response.status === 200) { if (onUpdateSuccess) onUpdateSuccess(); onClose(); } else { setError(response.data.message || 'Failed to delete media.'); } } catch (err) { console.error("Error deleting chapter media:", err); setError(err.response?.data?.message || 'An error occurred during deletion.'); } finally { setLoading(false); } }; const sourceUrl = mediaPreviewUrl || (currentMedia ? `/uploads/audio-video/${currentMedia}` : null); const mediaType = newMediaFile?.type || ''; const isVideo = mediaType.startsWith('video/') || (!mediaType && currentMedia?.match(/\.(mp4|mov|avi|webm|mkv)$/i)); const isAudio = mediaType.startsWith('audio/') || (!mediaType && currentMedia?.match(/\.(mp3|wav|ogg|aac|m4a)$/i)); return ( Edit Media for "{chapterName}" theme.palette.grey[500] }} disabled={loading} > {/* Add dividers */} {error && {error}} {/* Media Player Area */} {sourceUrl && (isVideo || isAudio) ? ( isVideo ? ( ) : ( ) ) : ( {isVideo ? : } No media available or selected. )} {/* Upload Controls */} {currentMedia && ( )} {newMediaFile && ( Selected: {newMediaFile.name} (Click Update to save) )} ); }; export default EditChapterVideoAudio; // app/admin/components/Sidebar.js // No changes needed for responsiveness based on the provided code. 'use client'; import * as React from 'react'; import Box from '@mui/material/Box'; import Drawer from '@mui/material/Drawer'; import List from '@mui/material/List'; import ListItem from '@mui/material/ListItem'; import ListItemButton from '@mui/material/ListItemButton'; import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemText from '@mui/material/ListItemText'; import Divider from '@mui/material/Divider'; import Toolbar from '@mui/material/Toolbar'; // To provide spacing under AppBar // Import Icons import AnalyticsIcon from '@mui/icons-material/Analytics'; import BookIcon from '@mui/icons-material/Book'; import CategoryIcon from '@mui/icons-material/Category'; import SupervisorAccountIcon from '@mui/icons-material/SupervisorAccount'; import PeopleIcon from '@mui/icons-material/People'; import AddIcon from '@mui/icons-material/Add'; import { useRouter } from 'next/navigation'; // Use router for navigation // Define drawerWidth here or import from layout const drawerWidth = 240; export default function Sidebar({ mobileOpen, handleDrawerToggle }) { const router = useRouter(); // Navigation handler const handleNavigate = (path) => { router.push(path); if (mobileOpen) { // Close mobile drawer on navigation handleDrawerToggle(); } }; const drawerContent = (
{/* Toolbar provides spacing equivalent to AppBar height */} {/* Ensure Toolbar variant matches AppBar Toolbar if necessary for exact height */} {/* Replace setActiveView with router pushes */} handleNavigate('/admin/dashboard?view=analytics')}> {/* Books Section (Example with sub-items) */} handleNavigate('/admin/dashboard?view=bookNames')}> {/* Indent sub-items */} handleNavigate('/admin/dashboard?view=bookGenres')}> handleNavigate('/admin/dashboard?view=bookNames')}> handleNavigate('/admin/dashboard?view=bookChapters')}> handleNavigate('/admin/dashboard?view=adminAccounts')}> handleNavigate('/admin/dashboard?view=customerAccounts')}>
); return ( {/* Mobile Drawer */} {drawerContent} {/* Desktop Drawer */} {drawerContent} ); } // app/admin/dashboard/page.js 'use client'; import { Suspense } from 'react'; import { useSearchParams } from 'next/navigation'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import CircularProgress from '@mui/material/CircularProgress'; // Import Admin components using the CORRECT '@/' alias path // These paths assume jsconfig.json maps '@/*' to 'app/*' import AdminAccounts from '@/admin/components/AdminAccounts'; import BookGenres from '@/admin/components/BookGenre'; // <--- Check this file exists at app/admin/components/BookGenres.js import BookNames from '@/admin/components/BookNames'; import BookChapters from '@/admin/components/BookChapters'; // ... rest of the component remains the same ... // Placeholder components const AnalyticsView = () => Analytics View; const CustomerAccountsView = () => Customer Accounts View; function DashboardContent() { const searchParams = useSearchParams(); const activeView = searchParams.get('view') || 'analytics'; const renderView = () => { switch (activeView) { case 'analytics': return ; case 'bookGenres': return ; // <--- The component being used case 'bookNames': return ; case 'bookChapters': return ; case 'adminAccounts': return ; case 'customerAccounts': return ; default: return ; } }; return {renderView()}; } export default function DashboardPage() { const SuspenseFallback = ( ); return ( ); } // app/admin/layout.js 'use client'; import { useState, useEffect } from 'react'; import { useAuth } from '@/lib/auth'; // Adjust path import { useRouter, usePathname } from 'next/navigation'; // REMOVE Link import ONLY if it's not used elsewhere in this file // import Link from 'next/link'; import Box from '@mui/material/Box'; import Sidebar from './components/Sidebar'; // Adjust path import AppBar from '@mui/material/AppBar'; import Toolbar from '@mui/material/Toolbar'; import IconButton from '@mui/material/IconButton'; import MenuIcon from '@mui/icons-material/Menu'; import Typography from '@mui/material/Typography'; import Button from '@mui/material/Button'; import Avatar from '@mui/material/Avatar'; import CircularProgress from '@mui/material/CircularProgress'; import CssBaseline from '@mui/material/CssBaseline'; const drawerWidth = 240; // Sidebar width const ADMIN_PUBLIC_PATHS = [ '/admin/auth/login', '/admin/auth/signup', '/admin/auth/forgot-password', '/admin/auth/reset-password' ]; export default function AdminLayout({ children }) { // Ensure useAuth and useRouter are called const { isLoggedIn, user, logout, loading: authLoading } = useAuth(); const router = useRouter(); const pathname = usePathname(); const [mobileOpen, setMobileOpen] = useState(false); useEffect(() => { if (!authLoading) { const isPublicAdminPath = ADMIN_PUBLIC_PATHS.some(path => pathname.startsWith(path)); if (isLoggedIn() && isPublicAdminPath) { console.log("User logged in, redirecting from auth page to dashboard..."); router.replace('/admin/dashboard'); } else if (!isLoggedIn() && !isPublicAdminPath) { console.log("User not logged in, redirecting from protected page to login..."); router.replace('/admin/auth/login'); } } }, [authLoading, isLoggedIn, router, pathname]); const handleDrawerToggle = () => { setMobileOpen(!mobileOpen); }; const handleLogout = () => { logout(); // router.push('/admin/auth/login'); // Logout button still goes to admin login }; // --- NEW: Handler for the Client button --- const handleClientButtonClick = () => { logout(); // Call logout from auth context router.push('/'); // Redirect to the client homepage }; if (authLoading) { return ( ); } const isPublicAdminPath = ADMIN_PUBLIC_PATHS.some(path => pathname.startsWith(path)); if (!isLoggedIn() && !isPublicAdminPath) { return ( ); } return ( {isLoggedIn() && !isPublicAdminPath && ( <> theme.zIndex.drawer + 1, }} elevation={1} > Admin Dashboard {/* START: Modified Client Button */} {/* END: Modified Client Button */} {user && ( )} )} {/* Main Content Area */} {/* Toolbar Spacer - Renders only when AppBar/Sidebar are present */} {isLoggedIn() && !isPublicAdminPath && } {/* Render children */} {children} ); } // app/api/admin/users/[id]/route.js import pool from '@/lib/database'; // CORRECTED import bcrypt from 'bcrypt'; import { handleFileUpload, deleteUploadedFile } from '@/lib/utils/fileUpload'; // CORRECTED import { NextResponse } from 'next/server'; import User from '@/lib/models/User'; // CORRECTED export const config = { // Required for file uploads api: { bodyParser: false, }, }; // Helper to get user (excluding password) async function getUserById(id) { // NOTE: Using User.findById might be cleaner if it selects the needed fields // const user = await User.findById(id); // return user; // Sticking to direct query as per original code for now: const [rows] = await pool.query('SELECT id, username, email, profile_picture FROM users WHERE id = ?', [id]); return rows[0] || null; } export async function PUT(request, { params }) { // Corrected signature // TODO: Verify admin user from token/session const { id } = params; // Corrected access if (isNaN(parseInt(id))) { return NextResponse.json({ message: 'Invalid user ID' }, { status: 400 }); } const userId = parseInt(id); let oldProfilePictureFilename = null; let newProfilePictureFilename = null; try { const formData = await request.formData(); const username = formData.get('username'); const email = formData.get('email'); const password = formData.get('password'); // Optional new password const profilePictureFile = formData.get('profilePicture'); const deleteProfilePictureFlag = formData.get('deleteProfilePicture') === 'true'; // --- Basic Validation --- if (!username || !email) { return NextResponse.json({ message: 'Username and email are required' }, { status: 400 }); } // Add more validation as needed (email format, etc.) // Fetch the current user data to get the old picture filename const currentUser = await getUserById(userId); // Use helper or User.findById if (!currentUser) { return NextResponse.json({ message: 'User not found' }, { status: 404 }); } oldProfilePictureFilename = currentUser.profile_picture; // --- Prepare Update --- const updates = {}; const updateParams = []; // Params only needed for direct query updates.username = username; updates.email = email; if (password) { if (password.length < 6) { return NextResponse.json({ message: 'New password must be at least 6 characters long' }, { status: 400 }); } const hashedPassword = await bcrypt.hash(password, 10); updates.password = hashedPassword; } // --- Handle Profile Picture Update --- if (profilePictureFile && profilePictureFile.size > 0) { // Upload new picture newProfilePictureFilename = await handleFileUpload(profilePictureFile, ''); // Store in root uploads if (!newProfilePictureFilename) { return NextResponse.json({ message: 'Failed to upload new profile picture.' }, { status: 500 }); } updates.profile_picture = newProfilePictureFilename; // Delete old picture if it existed AFTER successful DB update // Deletion handled in the 'finally' block or after successful update } else if (deleteProfilePictureFlag && oldProfilePictureFilename) { // Flag set to delete, and an old picture exists updates.profile_picture = null; // Actual file deletion handled in 'finally' block or after successful update } // else: No new file, don't delete = keep existing picture (no change needed in updates) // --- Build and Execute Query --- let updateQuery = 'UPDATE users SET '; const fieldsToUpdate = Object.keys(updates); if (fieldsToUpdate.length === 0) { return NextResponse.json({ message: 'No update data provided' }, { status: 400 }); } updateQuery += fieldsToUpdate.map(field => `${field} = ?`).join(', '); updateQuery += ' WHERE id = ?'; const paramsForQuery = [...fieldsToUpdate.map(field => updates[field]), userId]; const [result] = await pool.query(updateQuery, paramsForQuery); if (result.affectedRows === 0) { // Should not happen if user was found earlier, but check defensively // If files were uploaded, they need cleanup here as DB update failed if (newProfilePictureFilename) await deleteUploadedFile(newProfilePictureFilename, ''); return NextResponse.json({ message: 'User not found or no changes made' }, { status: 404 }); } // --- Post-Update File Cleanup --- // This logic seems correct: delete the old file IF the profile picture field was modified if (updates.profile_picture !== undefined && oldProfilePictureFilename) { await deleteUploadedFile(oldProfilePictureFilename, ''); } // Fetch updated user data to return (optional) const updatedUser = await getUserById(userId); // Use helper or User.findById return NextResponse.json({ message: 'User updated successfully', user: updatedUser }, { status: 200 }); } catch (error) { console.error(`Error updating user ${id}:`, error); // Attempt to clean up newly uploaded file if DB update failed *after* upload succeeded if (newProfilePictureFilename) { await deleteUploadedFile(newProfilePictureFilename, ''); } if (error.code === 'ER_DUP_ENTRY') { return NextResponse.json({ message: 'Email address already in use by another account.' }, { status: 409 }); } return NextResponse.json({ message: 'Failed to update user' }, { status: 500 }); } } export async function DELETE(request, { params }) { // Corrected signature // TODO: Verify admin user from token/session const { id } = params; // Corrected access if (isNaN(parseInt(id))) { return NextResponse.json({ message: 'Invalid user ID' }, { status: 400 }); } const userId = parseInt(id); // Optional: Prevent self-deletion? Get current admin ID from token. // const currentAdminId = ... get from token ... // if (userId === currentAdminId) { // return NextResponse.json({ message: 'Cannot delete your own account' }, { status: 403 }); // } let profilePictureToDelete = null; try { // 1. Find the user to get the profile picture filename *before* deleting the record const userToDelete = await getUserById(userId); // Use helper or User.findById if (!userToDelete) { return NextResponse.json({ message: 'User not found' }, { status: 404 }); } profilePictureToDelete = userToDelete.profile_picture; // 2. Delete the user record from the database const [result] = await pool.query('DELETE FROM users WHERE id = ?', [userId]); if (result.affectedRows === 0) { // Should not happen if found before, but handle defensively return NextResponse.json({ message: 'User not found' }, { status: 404 }); } // 3. If DB deletion was successful, delete the profile picture file if (profilePictureToDelete) { await deleteUploadedFile(profilePictureToDelete, ''); // Assuming stored in root uploads } return NextResponse.json({ message: 'User deleted successfully' }, { status: 200 }); } catch (error) { console.error(`Error deleting user ${id}:`, error); // Handle potential foreign key constraint errors if needed return NextResponse.json({ message: 'Failed to delete user' }, { status: 500 }); } } // app/api/admin/users/route.js import pool from '@/lib/database'; // CORRECTED: Use alias relative to app/ import { NextResponse } from 'next/server'; // TODO: Add authentication middleware/check here to ensure only admins can access export async function GET(request) { // TODO: Verify admin user from token/session try { // Exclude password from the selection const [rows] = await pool.query('SELECT id, username, email, profile_picture, created_at FROM users ORDER BY username'); return NextResponse.json(rows, { status: 200 }); } catch (error) { console.error('Error getting users:', error); return NextResponse.json({ message: 'Failed to fetch users' }, { status: 500 }); } } // POST might be handled by /api/auth/signup or a dedicated admin creation route // If you want admins to create other admins via this route: // app/api/auth/forgot-password/route.js import User from '@/app/lib/models/User'; import { NextResponse } from 'next/server'; import jwt from 'jsonwebtoken'; import nodemailer from 'nodemailer'; // Nodemailer transporter configuration (use environment variables) const transporter = nodemailer.createTransport({ host: process.env.SMTP_HOST, port: parseInt(process.env.SMTP_PORT || '465', 10), // Ensure port is a number secure: process.env.SMTP_SECURE === 'true', // Convert string to boolean auth: { user: process.env.EMAIL_USER, pass: process.env.EMAIL_PASSWORD, }, // Optional: Add timeout and other settings for robustness connectionTimeout: 10000, // 10 seconds greetingTimeout: 10000, socketTimeout: 10000, }); export async function POST(request) { try { const { email } = await request.json(); if (!email) { return NextResponse.json({ message: "Please provide an email address" }, { status: 400 }); } // Find user by email const user = await User.findByEmail(email); if (!user) { // Important: Don't reveal if the email exists for security reasons console.warn(`Password reset requested for non-existent email: ${email}`); // Still return a success-like message to prevent email enumeration return NextResponse.json({ message: "If an account with that email exists, a password reset link has been sent." }, { status: 200 }); } // Generate a short-lived reset token const resetToken = jwt.sign( { userId: user.id }, process.env.JWT_SECRET, { expiresIn: '15m' } // Short expiry for reset tokens (e.g., 15 minutes) ); // Construct the reset link (use APP_BASE_URL from env) const resetLink = `${process.env.APP_BASE_URL}/admin/auth/reset-password?token=${resetToken}`; // Email content const mailOptions = { from: `"SkizaFM Admin" <${process.env.EMAIL_USER}>`, // Sender address to: email, // List of receivers subject: 'SkizaFM Password Reset Request', // Subject line text: `You requested a password reset. Click the following link to reset your password: ${resetLink}\n\nIf you did not request this, please ignore this email. This link will expire in 15 minutes.`, // Plain text body html: `

You requested a password reset for your SkizaFM account.

Click the link below to reset your password:

Reset Password

Or copy and paste this URL into your browser:

${resetLink}

If you did not request this, please ignore this email.

This link will expire in 15 minutes.

`, // HTML body }; // Send the email try { const info = await transporter.sendMail(mailOptions); console.log("Password reset email sent:", info.messageId); return NextResponse.json({ message: "If an account with that email exists, a password reset link has been sent." }, { status: 200 }); } catch (mailError) { console.error("Error sending password reset email:", mailError); // Log specific mail error but return generic message to user return NextResponse.json({ message: "Error sending password reset email. Please try again later." }, { status: 500 }); } } catch (error) { console.error("Forgot password API error:", error); return NextResponse.json({ message: "Error processing forgot password request" }, { status: 500 }); } } // app/api/auth/login/route.js import pool from '@/app/lib/database'; import bcrypt from 'bcrypt'; import jwt from 'jsonwebtoken'; import User from '@/app/lib/models/User'; // Use the User model import { NextResponse } from 'next/server'; export async function POST(request) { try { const { email, password } = await request.json(); if (!email || !password) { return NextResponse.json({ message: "Please enter all fields" }, { status: 400 }); } // Find user by email using the model const user = await User.findByEmail(email); if (!user) { return NextResponse.json({ message: "User not found" }, { status: 404 }); } // Compare password const isMatch = await bcrypt.compare(password, user.password); if (!isMatch) { return NextResponse.json({ message: "Invalid credentials" }, { status: 401 }); // Changed message slightly } // Generate JWT token const token = jwt.sign( { userId: user.id /* Add other relevant info like role if available */ }, process.env.JWT_SECRET, { expiresIn: '1h' } // Token expiration time ); // Remove password from the user object before sending response const { password: _, ...userWithoutPassword } = user; // Return success response with user data and token return NextResponse.json({ message: "User logged in successfully", user: userWithoutPassword, token }, { status: 200 }); } catch (error) { console.error("Login API error:", error); // Generic error for security return NextResponse.json({ message: "An error occurred during login. Please try again." }, { status: 500 }); } } // app/api/auth/reset-password/route.js import pool from '@/app/lib/database'; import bcrypt from 'bcrypt'; import jwt from 'jsonwebtoken'; import { NextResponse } from 'next/server'; import User from '@/app/lib/models/User'; // Import User model export async function POST(request) { try { const { password, token } = await request.json(); if (!password || !token) { return NextResponse.json({ message: "Token and new password are required" }, { status: 400 }); } if (password.length < 6) { return NextResponse.json({ message: 'Password must be at least 6 characters long' }, { status: 400 }); } let decodedToken; try { // Verify the JWT token decodedToken = jwt.verify(token, process.env.JWT_SECRET); } catch (jwtError) { console.error("JWT verification failed:", jwtError.message); return NextResponse.json({ message: "Invalid or expired password reset token." }, { status: 401 }); // Unauthorized or Bad Request } if (!decodedToken || !decodedToken.userId) { return NextResponse.json({ message: "Invalid token payload." }, { status: 400 }); } const userId = decodedToken.userId; // Optional: Verify user still exists (though unlikely if token is valid) const userExists = await User.findById(userId); if (!userExists) { console.warn(`Password reset attempted for non-existent user ID: ${userId}`); return NextResponse.json({ message: "User not found." }, { status: 404 }); } // Hash the new password const hashedPassword = await bcrypt.hash(password, 10); // Use appropriate salt rounds // Update the user's password in the database const [result] = await pool.query( 'UPDATE users SET password = ? WHERE id = ?', [hashedPassword, userId] ); if (result.affectedRows === 0) { // This case is unlikely if the user was found before, but handle defensively console.error(`Failed to update password for user ID: ${userId}. User might have been deleted.`); return NextResponse.json({ message: "Failed to update password. User not found or no changes needed." }, { status: 404 }); } console.log(`Password successfully reset for user ID: ${userId}`); return NextResponse.json({ message: 'Password reset successfully. You can now log in with your new password.' }, { status: 200 }); } catch (error) { console.error("Reset password API error:", error); if (error instanceof jwt.JsonWebTokenError || error instanceof jwt.TokenExpiredError) { return NextResponse.json({ message: "Invalid or expired password reset token." }, { status: 401 }); } return NextResponse.json({ message: 'Error resetting password. Please try again.' }, { status: 500 }); } } // app/api/auth/signup/route.js import User from '@/lib/models/User'; import { handleFileUpload, deleteUploadedFile } from '@/lib/utils/fileUpload'; import { NextResponse } from 'next/server'; import jwt from 'jsonwebtoken'; export const config = { // Required for Multer-like handling with formidable or manual parsing api: { bodyParser: false, // Disable Next.js body parsing }, }; export async function POST(request) { let profilePictureFilename = null; try { const formData = await request.formData(); const username = formData.get('username'); const email = formData.get('email'); const password = formData.get('password'); const profilePictureFile = formData.get('profilePicture'); // Basic Validation if (!username || !email || !password) { return NextResponse.json({ message: 'Username, email, and password are required' }, { status: 400 }); } if (password.length < 6) { // Example password policy return NextResponse.json({ message: 'Password must be at least 6 characters long' }, { status: 400 }); } // Handle file upload if provided if (profilePictureFile && profilePictureFile.size > 0) { // Assuming profile pictures go directly into 'uploads' for simplicity based on original code profilePictureFilename = await handleFileUpload(profilePictureFile, ''); // Empty string for root of uploads if (!profilePictureFilename) { return NextResponse.json({ message: "Profile picture upload failed." }, { status: 500 }); } } // Create and save user using the model // The model handles password hashing const user = new User(username, email, password, profilePictureFilename); const savedUser = await user.save(); // save() should return the user object without password // Generate token upon successful signup const token = jwt.sign( { userId: savedUser.id }, process.env.JWT_SECRET, { expiresIn: '1h' } ); return NextResponse.json({ message: 'User created successfully', user: savedUser, token }, { status: 201 }); } catch (error) { console.error("Signup API error:", error); // Attempt to delete uploaded file if signup failed after upload if (profilePictureFilename) { await deleteUploadedFile(profilePictureFilename, ''); } if (error.message === 'Email address already in use.') { return NextResponse.json({ message: error.message }, { status: 409 }); // Conflict } return NextResponse.json({ message: "Error creating user" }, { status: 500 }); } } // app/api/book-chapters/[id]/route.js import pool from '@/lib/database'; import BookChapter from '@/lib/models/BookChapter'; import { handleFileUpload, deleteUploadedFile } from '@/lib/utils/fileUpload'; // Use correct path import { NextResponse } from 'next/server'; export const config = { api: { bodyParser: false } }; // --- GET (no changes needed, model handles duration selection) --- export async function GET(request, { params }) { const { id } = params; const chapterId = parseInt(id, 10); if (isNaN(chapterId)) return NextResponse.json({ message: 'Invalid chapter ID' }, { status: 400 }); try { const chapter = await BookChapter.findById(chapterId); if (!chapter) return NextResponse.json({ message: 'Book chapter not found' }, { status: 404 }); return NextResponse.json(chapter, { status: 200 }); } catch (error) { console.error(`API Error GET /api/book-chapters/${id}:`, error); return NextResponse.json({ message: 'Failed to fetch book chapter' }, { status: 500 }); } } // --- PUT --- export async function PUT(request, { params }) { // TODO: Add admin authentication check const { id } = params; const chapterId = parseInt(id, 10); if (isNaN(chapterId)) return NextResponse.json({ message: 'Invalid chapter ID' }, { status: 400 }); let oldImageFilename = null; let oldMediaFilename = null; let newImageFilename = null; let newMediaFilename = null; let newMediaDuration = null; // Variable for duration try { const currentChapter = await BookChapter.findById(chapterId); if (!currentChapter) return NextResponse.json({ message: 'Book chapter not found' }, { status: 404 }); oldImageFilename = currentChapter.chapter_image; oldMediaFilename = currentChapter.chapter_audio_video; const formData = await request.formData(); const updates = {}; // Extract text/select fields const chapter_name = formData.get('chapter_name'); const chapter_description = formData.get('chapter_description'); const chapter_sequence = formData.get('chapter_sequence'); const book_name_id = formData.get('book_name_id'); const isMediaDeletion = formData.get('deleteChapterAudioVideo') === 'true'; // Check for explicit media delete const deleteChapterImageFlag = formData.get('deleteChapterImage') === 'true'; // Check for changes if (chapter_name !== null && chapter_name !== currentChapter.chapter_name) updates.chapter_name = chapter_name; if (chapter_description !== null && chapter_description !== currentChapter.chapter_description) updates.chapter_description = chapter_description; if (chapter_sequence !== null && chapter_sequence !== currentChapter.chapter_sequence) updates.chapter_sequence = chapter_sequence; if (book_name_id !== null && Number(book_name_id) !== currentChapter.book_name_id) updates.book_name_id = Number(book_name_id); // Process file updates const chapterImageFile = formData.get('chapter_image'); const chapterAudioVideoFile = formData.get('chapter_audio_video'); // Handle Image if (chapterImageFile && chapterImageFile.size > 0) { const imgResult = await handleFileUpload(chapterImageFile, 'images'); if (!imgResult) throw new Error('Image upload failed'); newImageFilename = imgResult.filename; updates.chapter_image = newImageFilename; } else if (deleteChapterImageFlag && oldImageFilename) { updates.chapter_image = null; } // Handle Audio/Video if (chapterAudioVideoFile && chapterAudioVideoFile.size > 0) { const mediaResult = await handleFileUpload(chapterAudioVideoFile, 'audio-video'); if (!mediaResult) throw new Error('Audio/Video upload failed'); newMediaFilename = mediaResult.filename; newMediaDuration = mediaResult.duration; updates.chapter_audio_video = newMediaFilename; updates.duration = newMediaDuration; // Update duration as well } else if (isMediaDeletion && oldMediaFilename) { updates.chapter_audio_video = null; updates.duration = null; // Reset duration if media is deleted } // Perform Database Update if (Object.keys(updates).length === 0) { return NextResponse.json({ message: 'No changes provided for update.' }, { status: 200 }); } const affectedRows = await BookChapter.update(chapterId, updates); if (affectedRows === 0) { if (newImageFilename) await deleteUploadedFile(newImageFilename, 'images'); if (newMediaFilename) await deleteUploadedFile(newMediaFilename, 'audio-video'); return NextResponse.json({ message: 'Chapter not found or update failed.' }, { status: 404 }); } // Cleanup Old Files const cleanupPromises = []; if ((newImageFilename || updates.chapter_image === null) && oldImageFilename) { cleanupPromises.push(deleteUploadedFile(oldImageFilename, 'images')); } if ((newMediaFilename || updates.chapter_audio_video === null) && oldMediaFilename) { cleanupPromises.push(deleteUploadedFile(oldMediaFilename, 'audio-video')); } await Promise.all(cleanupPromises); return NextResponse.json({ message: 'Book chapter updated successfully' }, { status: 200 }); } catch (error) { console.error(`API Error PUT /api/book-chapters/${id}:`, error); const errorCleanupPromises = []; if (newImageFilename) errorCleanupPromises.push(deleteUploadedFile(newImageFilename, 'images')); if (newMediaFilename) errorCleanupPromises.push(deleteUploadedFile(newMediaFilename, 'audio-video')); if(errorCleanupPromises.length > 0) await Promise.all(errorCleanupPromises); if (error.message?.includes('already exist') || error.message?.includes('Invalid Book ID')) { return NextResponse.json({ message: error.message }, { status: error.message.includes('Invalid') ? 400 : 409 }); } if (error.message === 'Image upload failed' || error.message === 'Audio/Video upload failed') { return NextResponse.json({ message: error.message }, { status: 500 }); } return NextResponse.json({ message: 'Failed to update book chapter' }, { status: 500 }); } } // --- DELETE (no changes needed for duration logic) --- export async function DELETE(request, { params }) { // TODO: Add admin authentication check const { id } = params; const chapterId = parseInt(id, 10); if (isNaN(chapterId)) return NextResponse.json({ message: 'Invalid chapter ID' }, { status: 400 }); let imageToDelete = null; let mediaToDelete = null; try { const chapter = await BookChapter.findById(chapterId); if (!chapter) return NextResponse.json({ message: 'Book chapter not found' }, { status: 404 }); imageToDelete = chapter.chapter_image; mediaToDelete = chapter.chapter_audio_video; const affectedRows = await BookChapter.delete(chapterId); if (affectedRows === 0) return NextResponse.json({ message: 'Book chapter not found' }, { status: 404 }); const cleanupPromises = []; if (imageToDelete) cleanupPromises.push(deleteUploadedFile(imageToDelete, 'images')); if (mediaToDelete) cleanupPromises.push(deleteUploadedFile(mediaToDelete, 'audio-video')); await Promise.all(cleanupPromises); return NextResponse.json({ message: 'Book chapter deleted successfully' }, { status: 200 }); } catch (error) { console.error(`API Error DELETE /api/book-chapters/${id}:`, error); return NextResponse.json({ message: 'Failed to delete book chapter' }, { status: 500 }); } } // app/api/book-chapters/route.js import pool from '@/lib/database'; import BookChapter from '@/lib/models/BookChapter'; import { handleFileUpload, deleteUploadedFile } from '@/lib/utils/fileUpload'; // Use correct path import { NextResponse } from 'next/server'; import { URLSearchParams } from 'url'; export const config = { api: { bodyParser: false } }; // --- GET (no changes needed here, model handles selecting duration) --- export async function GET(request) { try { const { searchParams } = new URL(request.url); const criteria = {}; if (searchParams.has('bookId')) criteria.bookId = parseInt(searchParams.get('bookId'), 10); if (searchParams.has('chapterSequence')) criteria.chapterSequence = searchParams.get('chapterSequence'); if (searchParams.has('chapterName')) criteria.chapterName = searchParams.get('chapterName'); if (searchParams.has('chapterAudioVideo')) criteria.chapterAudioVideo = searchParams.get('chapterAudioVideo'); if (searchParams.has('limit')) criteria.limit = parseInt(searchParams.get('limit'), 10); if ((criteria.bookId && isNaN(criteria.bookId)) || (criteria.limit && isNaN(criteria.limit))) { return NextResponse.json({ message: 'Invalid bookId or limit parameter' }, { status: 400 }); } const chapters = await BookChapter.findByCriteria(criteria); return NextResponse.json(chapters, { status: 200 }); } catch (error) { console.error('API Error GET /api/book-chapters:', error); return NextResponse.json({ message: 'Failed to fetch book chapters' }, { status: 500 }); } } // --- POST --- export async function POST(request) { // TODO: Add admin authentication check let chapterImageFilename = null; let chapterAudioVideoFilename = null; let chapterDuration = null; // Variable for duration try { const formData = await request.formData(); const book_name_id = formData.get('book_name_id'); const chapter_name = formData.get('chapter_name'); const chapter_description = formData.get('chapter_description'); const chapter_sequence = formData.get('chapter_sequence') || 'others'; const chapterImageFile = formData.get('chapter_image'); const chapterAudioVideoFile = formData.get('chapter_audio_video'); // --- Validation --- if (!book_name_id || !chapter_name || !chapterAudioVideoFile) { return NextResponse.json({ message: 'Book ID, Chapter Name, and Chapter Audio/Video file are required.' }, { status: 400 }); } // ... (keep other validations: isNaN, trim, sequence check, file check) ... if (isNaN(parseInt(book_name_id, 10))) return NextResponse.json({ message: 'Invalid Book ID.' }, { status: 400 }); if (typeof chapter_name !== 'string' || chapter_name.trim().length === 0) return NextResponse.json({ message: 'Invalid Chapter Name.' }, { status: 400 }); if (!['first', 'last', 'others'].includes(chapter_sequence)) return NextResponse.json({ message: 'Invalid Chapter Sequence value.' }, { status: 400 }); if (!chapterAudioVideoFile || typeof chapterAudioVideoFile.arrayBuffer !== 'function') return NextResponse.json({ message: 'Valid Chapter Audio/Video file is required.' }, { status: 400 }); // --- File Uploads --- const audioVideoUploadResult = await handleFileUpload(chapterAudioVideoFile, 'audio-video'); if (!audioVideoUploadResult) { return NextResponse.json({ message: 'Audio/Video upload failed.' }, { status: 500 }); } chapterAudioVideoFilename = audioVideoUploadResult.filename; chapterDuration = audioVideoUploadResult.duration; // Get duration if (chapterImageFile && chapterImageFile.size > 0) { const imageUploadResult = await handleFileUpload(chapterImageFile, 'images'); if (!imageUploadResult) { if (chapterAudioVideoFilename) await deleteUploadedFile(chapterAudioVideoFilename, 'audio-video'); return NextResponse.json({ message: 'Chapter Image upload failed.' }, { status: 500 }); } chapterImageFilename = imageUploadResult.filename; // Duration is not relevant for images } // --- Database Insert --- const chapter = new BookChapter( parseInt(book_name_id, 10), chapter_name.trim(), chapterImageFilename, chapterAudioVideoFilename, chapter_description || '', chapter_sequence, chapterDuration // Pass duration to constructor ); const insertId = await chapter.save(); const newChapterData = await BookChapter.findById(insertId); // Fetch created chapter with duration return NextResponse.json({ message: 'Book Chapter added successfully', chapter: newChapterData }, { status: 201 }); } catch (error) { console.error('API Error POST /api/book-chapters:', error); const cleanupPromises = []; if (chapterImageFilename) cleanupPromises.push(deleteUploadedFile(chapterImageFilename, 'images')); if (chapterAudioVideoFilename) cleanupPromises.push(deleteUploadedFile(chapterAudioVideoFilename, 'audio-video')); if(cleanupPromises.length > 0) await Promise.all(cleanupPromises); if (error.message?.includes('already exist') || error.message?.includes('Invalid Book ID')) { return NextResponse.json({ message: error.message }, { status: error.message.includes('Invalid') ? 400 : 409 }); } return NextResponse.json({ message: 'Failed to add book chapter' }, { status: 500 }); } } // app/api/book-genres/[id]/route.js import pool from '@/lib/database'; import BookGenre from '@/lib/models/BookGenre'; import { handleFileUpload, deleteUploadedFile } from '@/lib/utils/fileUpload'; import { NextResponse } from 'next/server'; export const config = { // Required for file uploads in PUT api: { bodyParser: false, }, }; // GET a single genre by ID export async function GET(request, { params }) { // Corrected signature // TODO: Optional: Add authentication if needed const { id } = params; // Corrected access if (isNaN(parseInt(id))) { return NextResponse.json({ message: 'Invalid genre ID' }, { status: 400 }); } try { const genre = await BookGenre.findById(id); if (!genre) { return NextResponse.json({ message: 'Book genre not found' }, { status: 404 }); } return NextResponse.json(genre, { status: 200 }); } catch (error) { console.error(`API Error getting book genre ${id}:`, error); return NextResponse.json({ message: 'Failed to fetch book genre' }, { status: 500 }); } } // PUT (Update a genre) export async function PUT(request, { params }) { // Corrected signature // TODO: Add authentication (ensure admin) const { id } = params; // Corrected access if (isNaN(parseInt(id))) { return NextResponse.json({ message: 'Invalid genre ID' }, { status: 400 }); } const genreId = parseInt(id); let oldGenreImageFilename = null; let newGenreImageFilename = null; try { const formData = await request.formData(); const genre_name = formData.get('genre_name'); const genreImageFile = formData.get('genreImage'); const deleteGenreImageFlag = formData.get('deleteGenreImage') === 'true'; // Fetch current genre data const currentGenre = await BookGenre.findById(genreId); if (!currentGenre) { return NextResponse.json({ message: 'Book genre not found' }, { status: 404 }); } oldGenreImageFilename = currentGenre.genre_image; // Prepare updates const updates = {}; if (genre_name && typeof genre_name === 'string' && genre_name.trim() !== currentGenre.genre_name) { updates.genre_name = genre_name.trim(); } // Handle image update if (genreImageFile && genreImageFile.size > 0) { // Upload new image newGenreImageFilename = await handleFileUpload(genreImageFile, 'images'); if (!newGenreImageFilename) { return NextResponse.json({ message: 'Failed to upload new genre image.' }, { status: 500 }); } updates.genre_image = newGenreImageFilename; // Old image deletion handled after successful DB update } else if (deleteGenreImageFlag && oldGenreImageFilename) { // Flag set to delete, and an old image exists updates.genre_image = null; // Set DB field to null // Actual file deletion handled after successful DB update } // else: No new file, don't delete flag = keep existing image (no 'genre_image' in updates) // Only proceed if there are actual changes if (Object.keys(updates).length === 0) { return NextResponse.json({ message: 'No changes detected or provided for update.' }, { status: 200 }); // Or 304 Not Modified? 200 is fine. } // Perform the update using the model const affectedRows = await BookGenre.update(genreId, updates); if (affectedRows === 0) { // This might happen if the ID was valid initially but deleted before update console.warn(`BookGenre update affected 0 rows for ID: ${genreId}. Might have been deleted concurrently.`); // No need to delete files if update failed or didn't happen return NextResponse.json({ message: 'Book genre not found or no changes applied' }, { status: 404 }); } // Post-Update File Cleanup (only if DB update was successful) if (updates.genre_image !== undefined && oldGenreImageFilename) { // If genre_image was part of the update (new file or null), delete the old one await deleteUploadedFile(oldGenreImageFilename, 'images'); } return NextResponse.json({ message: 'Book genre updated successfully' }, { status: 200 }); } catch (error) { console.error(`API Error updating book genre ${id}:`, error); // Clean up newly uploaded file if DB update failed if (newGenreImageFilename) { await deleteUploadedFile(newGenreImageFilename, 'images'); } if (error.message?.includes('already exists')) { return NextResponse.json({ message: error.message }, { status: 409 }); } return NextResponse.json({ message: 'Failed to update book genre' }, { status: 500 }); } } // DELETE a genre export async function DELETE(request, { params }) { // Corrected signature // TODO: Add authentication (ensure admin) const { id } = params; // Corrected access if (isNaN(parseInt(id))) { return NextResponse.json({ message: 'Invalid genre ID' }, { status: 400 }); } const genreId = parseInt(id); let genreImageToDelete = null; try { // 1. Find the genre to get image filename before deleting record const genreToDelete = await BookGenre.findById(genreId); if (!genreToDelete) { return NextResponse.json({ message: 'Book genre not found' }, { status: 404 }); } genreImageToDelete = genreToDelete.genre_image; // 2. Delete the genre record using the model const affectedRows = await BookGenre.delete(genreId); if (affectedRows === 0) { // Should not happen if found before, but check return NextResponse.json({ message: 'Book genre not found' }, { status: 404 }); } // 3. If DB deletion successful, delete the associated image file if (genreImageToDelete) { await deleteUploadedFile(genreImageToDelete, 'images'); } return NextResponse.json({ message: 'Book genre deleted successfully' }, { status: 200 }); } catch (error) { console.error(`API Error deleting book genre ${id}:`, error); // Handle specific errors like foreign key constraints if (error.message?.includes('Cannot delete genre')) { return NextResponse.json({ message: error.message }, { status: 409 }); // Conflict } return NextResponse.json({ message: 'Failed to delete book genre' }, { status: 500 }); } } //app/api/book-genres/route.js import pool from '@/lib/database'; import BookGenre from '@/lib/models/BookGenre'; import { NextResponse } from 'next/server'; import { writeFile } from 'fs/promises'; import path from 'path'; // Helper function to handle file uploads (Needs robust error handling) async function handleFileUpload(file) { if (!file) return null; const bytes = await file.arrayBuffer(); const buffer = Buffer.from(bytes); // Generate unique filename const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); const filename = file.name.split('.')[0] + '-' + uniqueSuffix + path.extname(file.name); const uploadPath = path.join(process.cwd(), 'public/uploads', filename); // Adjust subfolder if needed await writeFile(uploadPath, buffer); console.log(`File uploaded to ${uploadPath}`); return filename; // Return only the filename to store in DB } export async function GET(request) { // ... (logic similar to BookGenre.getAll()) ... try { const genres = await BookGenre.getAll(); return NextResponse.json(genres, { status: 200 }); } catch (error) { console.error('Error getting book genres:', error); return NextResponse.json({ message: 'Failed to fetch book genres' }, { status: 500 }); } } export async function POST(request) { try { const formData = await request.formData(); const genre_name = formData.get('genre_name'); const genreImageFile = formData.get('genreImage'); // Name matches frontend form if (!genre_name || !genreImageFile) { return NextResponse.json({ message: 'Genre name and image are required.' }, { status: 400 }); } // Handle file upload const genre_image_filename = await handleFileUpload(genreImageFile); if (!genre_image_filename) { return NextResponse.json({ message: 'File upload failed.' }, { status: 500 }); } // Save to database using the model const genre = new BookGenre(genre_name, genre_image_filename); const insertId = await genre.save(); return NextResponse.json({ message: 'Book genre added successfully', id: insertId }, { status: 201 }); } catch (error) { console.error('Error adding book genre:', error); // TODO: Add logic to delete uploaded file if DB save fails return NextResponse.json({ message: 'Failed to add book genre' }, { status: 500 }); } } // app/api/book-names/[id]/route.js import pool from '@/lib/database'; // Might not be needed if model handles everything import BookName from '@/lib/models/BookName'; import { handleFileUpload, deleteUploadedFile } from '@/lib/utils/fileUpload'; import { NextResponse } from 'next/server'; // Configuration to disable default body parsing for file uploads in PUT export const config = { api: { bodyParser: false, }, }; // --- GET: Retrieve a single book name by ID --- export async function GET(request, { params }) { // Corrected signature // TODO: Add authentication/authorization if needed (e.g., public access or logged-in users?) const { id } = params; // Corrected access const bookId = parseInt(id, 10); if (isNaN(bookId)) { return NextResponse.json({ message: 'Invalid book ID' }, { status: 400 }); } try { const book = await BookName.findById(bookId); // findById should join with genre name if (!book) { return NextResponse.json({ message: 'Book not found' }, { status: 404 }); } return NextResponse.json(book, { status: 200 }); } catch (error) { console.error(`API Error getting book name ${bookId}:`, error); return NextResponse.json({ message: 'Failed to fetch book name' }, { status: 500 }); } } // --- PUT: Update a specific book name --- export async function PUT(request, { params }) { // Corrected signature // TODO: Add authentication (ensure admin) const { id } = params; // Corrected access const bookId = parseInt(id, 10); if (isNaN(bookId)) { return NextResponse.json({ message: 'Invalid book ID' }, { status: 400 }); } let oldImageFilename = null; let oldTrailerFilename = null; let newImageFilename = null; let newTrailerFilename = null; try { // 1. Fetch current book data const currentBook = await BookName.findById(bookId); if (!currentBook) { return NextResponse.json({ message: 'Book not found' }, { status: 404 }); } oldImageFilename = currentBook.book_image; oldTrailerFilename = currentBook.trailer; // 2. Process form data const formData = await request.formData(); const updates = {}; // Store fields to be updated // Extract text/select fields and check for changes const book_name = formData.get('book_name'); const book_author = formData.get('book_author'); const genre_id = formData.get('genre_id'); // String from form data const book_description = formData.get('book_description'); const book_preference = formData.get('book_preference'); if (book_name !== null && book_name !== currentBook.book_name) updates.book_name = book_name; if (book_author !== null && book_author !== currentBook.book_author) updates.book_author = book_author; if (genre_id !== null && Number(genre_id) !== currentBook.genre_id) updates.genre_id = Number(genre_id); if (book_description !== null && book_description !== currentBook.book_description) updates.book_description = book_description; if (book_preference !== null && book_preference !== currentBook.book_preference) updates.book_preference = book_preference; // 3. Process file updates and delete flags const bookImageFile = formData.get('bookImage'); const trailerFile = formData.get('trailer'); const deleteBookImageFlag = formData.get('deleteBookImage') === 'true'; const deleteTrailerFlag = formData.get('deleteTrailer') === 'true'; // Handle Book Image Upload/Delete if (bookImageFile && bookImageFile.size > 0) { newImageFilename = await handleFileUpload(bookImageFile, 'images'); if (!newImageFilename) throw new Error('Book image upload failed'); updates.book_image = newImageFilename; } else if (deleteBookImageFlag && oldImageFilename) { updates.book_image = null; // Set DB field to null } // Handle Trailer Upload/Delete if (trailerFile && trailerFile.size > 0) { newTrailerFilename = await handleFileUpload(trailerFile, 'videos'); if (!newTrailerFilename) throw new Error('Trailer upload failed'); updates.trailer = newTrailerFilename; } else if (deleteTrailerFlag && oldTrailerFilename) { updates.trailer = null; // Set DB field to null } // 4. Perform Database Update (only if there are changes) if (Object.keys(updates).length === 0) { return NextResponse.json({ message: 'No changes provided for update.' }, { status: 200 }); } const affectedRows = await BookName.update(bookId, updates); if (affectedRows === 0) { // Possible race condition or ID issue if (newImageFilename) await deleteUploadedFile(newImageFilename, 'images'); if (newTrailerFilename) await deleteUploadedFile(newTrailerFilename, 'videos'); return NextResponse.json({ message: 'Book not found or update failed.' }, { status: 404 }); } // 5. Cleanup Old Files (After successful DB update) const cleanupPromises = []; // Delete old image if it was replaced or explicitly deleted if ((newImageFilename || updates.book_image === null) && oldImageFilename) { cleanupPromises.push(deleteUploadedFile(oldImageFilename, 'images')); } // Delete old trailer if it was replaced or explicitly deleted if ((newTrailerFilename || updates.trailer === null) && oldTrailerFilename) { cleanupPromises.push(deleteUploadedFile(oldTrailerFilename, 'videos')); } await Promise.all(cleanupPromises); // Optionally fetch and return updated book data const updatedBook = await BookName.findById(bookId); return NextResponse.json({ message: 'Book Name updated successfully', book: updatedBook }, { status: 200 }); } catch (error) { console.error(`API Error updating book name ${id}:`, error); // Attempt cleanup of newly uploaded files on error const errorCleanupPromises = []; if (newImageFilename) errorCleanupPromises.push(deleteUploadedFile(newImageFilename, 'images')); if (newTrailerFilename) errorCleanupPromises.push(deleteUploadedFile(newTrailerFilename, 'videos')); if(errorCleanupPromises.length > 0) await Promise.all(errorCleanupPromises); // Handle specific errors if (error.message?.includes('already exists')) { // From Model return NextResponse.json({ message: error.message }, { status: 409 }); } if (error.message?.includes('Invalid Genre ID')) { // From Model return NextResponse.json({ message: error.message }, { status: 400 }); } if (error.message === 'Book image upload failed' || error.message === 'Trailer upload failed') { return NextResponse.json({ message: error.message }, { status: 500 }); } return NextResponse.json({ message: 'Failed to update book name' }, { status: 500 }); } } // --- DELETE: Remove a specific book name --- export async function DELETE(request, { params }) { // Corrected signature // TODO: Add authentication (ensure admin) const { id } = params; // Corrected access const bookId = parseInt(id, 10); if (isNaN(bookId)) { return NextResponse.json({ message: 'Invalid book ID' }, { status: 400 }); } let imageToDelete = null; let trailerToDelete = null; try { // 1. Find the book to get file names const book = await BookName.findById(bookId); if (!book) { return NextResponse.json({ message: 'Book not found' }, { status: 404 }); } imageToDelete = book.book_image; trailerToDelete = book.trailer; // 2. Delete the book record from the database // The model's delete method might handle foreign key constraints or throw errors const affectedRows = await BookName.delete(bookId); if (affectedRows === 0) { return NextResponse.json({ message: 'Book not found or already deleted' }, { status: 404 }); } // 3. If DB deletion successful, delete associated files const cleanupPromises = []; if (imageToDelete) { cleanupPromises.push(deleteUploadedFile(imageToDelete, 'images')); } if (trailerToDelete) { cleanupPromises.push(deleteUploadedFile(trailerToDelete, 'videos')); } await Promise.all(cleanupPromises); return NextResponse.json({ message: 'Book name deleted successfully' }, { status: 200 }); } catch (error) { console.error(`API Error deleting book name ${id}:`, error); // Handle specific errors from the model (like foreign key constraints) if (error.message?.includes('Cannot delete book because chapters are associated')) { return NextResponse.json({ message: error.message }, { status: 409 }); // Conflict } return NextResponse.json({ message: 'Failed to delete book name' }, { status: 500 }); } } // app/api/book-names/route.js import pool from '@/app/lib/database'; // Might not be needed if model handles all import BookName from '@/app/lib/models/BookName'; import { handleFileUpload, deleteUploadedFile } from '@/app/lib/utils/fileUpload'; import { NextResponse } from 'next/server'; // Configuration to disable default body parsing for file uploads in POST export const config = { api: { bodyParser: false, }, }; // --- GET: Retrieve all book names --- export async function GET(request) { // TODO: Add authentication/authorization if needed (e.g., public or logged-in users?) try { // Use the model's getAll method, which should join with genre name const books = await BookName.getAll(); return NextResponse.json(books, { status: 200 }); } catch (error) { console.error('API Error getting all book names:', error); return NextResponse.json({ message: 'Failed to fetch book names' }, { status: 500 }); } } // --- POST: Create a new book name --- export async function POST(request) { // TODO: Add authentication (ensure admin) let bookImageFilename = null; let trailerFilename = null; try { const formData = await request.formData(); // Extract fields from formData const book_name = formData.get('book_name'); const book_author = formData.get('book_author'); const genre_id_str = formData.get('genre_id'); // Comes as string const book_description = formData.get('book_description'); const book_preference = formData.get('book_preference') || 'none'; // Default if not provided const bookImageFile = formData.get('bookImage'); const trailerFile = formData.get('trailer'); // --- Validation --- if (!book_name || !book_author || !genre_id_str || !book_description || !bookImageFile) { // Trailer is optional, but others are required (including the image file) return NextResponse.json({ message: 'Book Name, Author, Genre, Description, and Book Image are required.' }, { status: 400 }); } const genre_id = parseInt(genre_id_str, 10); if (isNaN(genre_id)) { return NextResponse.json({ message: 'Invalid Genre ID.' }, { status: 400 }); } if (!['none', 'top', 'featured'].includes(book_preference)) { return NextResponse.json({ message: 'Invalid Book Preference value.' }, { status: 400 }); } // Basic file validation if (!bookImageFile || typeof bookImageFile.arrayBuffer !== 'function') { return NextResponse.json({ message: 'Valid Book Image file is required.' }, { status: 400 }); } // --- File Uploads --- // Upload Book Image (required) bookImageFilename = await handleFileUpload(bookImageFile, 'images'); if (!bookImageFilename) { return NextResponse.json({ message: 'Book image upload failed.' }, { status: 500 }); } // Upload Trailer (optional) if (trailerFile && trailerFile.size > 0 && typeof trailerFile.arrayBuffer === 'function') { trailerFilename = await handleFileUpload(trailerFile, 'videos'); if (!trailerFilename) { // Cleanup the successfully uploaded image file before erroring await deleteUploadedFile(bookImageFilename, 'images'); return NextResponse.json({ message: 'Trailer upload failed.' }, { status: 500 }); } } // --- Database Insert --- // Create instance with filenames const book = new BookName( book_name.trim(), bookImageFilename, trailerFilename, // null if not uploaded genre_id, book_description.trim(), book_author.trim(), book_preference ); const insertId = await book.save(); // save() method from the model // Fetch the newly created book data to return (optional, but good practice) const newBookData = await BookName.findById(insertId); return NextResponse.json({ message: 'Book Name added successfully', book: newBookData }, { status: 201 }); } catch (error) { console.error('API Error adding book name:', error); // Attempt to clean up any successfully uploaded files if DB insert fails const cleanupPromises = []; if (bookImageFilename) cleanupPromises.push(deleteUploadedFile(bookImageFilename, 'images')); if (trailerFilename) cleanupPromises.push(deleteUploadedFile(trailerFilename, 'videos')); if(cleanupPromises.length > 0) await Promise.all(cleanupPromises); // Handle specific DB errors if (error.message?.includes('already exists')) { // From Model return NextResponse.json({ message: error.message }, { status: 409 }); // Conflict } if (error.message?.includes('Invalid Genre ID')) { // From Model return NextResponse.json({ message: error.message }, { status: 400 }); // Bad Request } return NextResponse.json({ message: 'Failed to add book name' }, { status: 500 }); } } // app/api/client-auth/login/route.js import pool from '@/lib/database'; import bcrypt from 'bcrypt'; import jwt from 'jsonwebtoken'; import Client from '@/lib/models/Client'; // Use the Client model import { NextResponse } from 'next/server'; export async function POST(request) { try { const { email, password } = await request.json(); if (!email || !password) { return NextResponse.json({ message: "Please enter all fields" }, { status: 400 }); } // Find client by email using the model const client = await Client.findByEmail(email); if (!client) { // Generic message for security return NextResponse.json({ message: "Invalid credentials" }, { status: 401 }); } // Compare password const isMatch = await bcrypt.compare(password, client.password); if (!isMatch) { return NextResponse.json({ message: "Invalid credentials" }, { status: 401 }); } // Generate JWT token (use the same secret, but payload indicates client) const token = jwt.sign( { clientId: client.id, type: 'client' }, // Indicate client type process.env.JWT_SECRET, { expiresIn: '1h' } // Adjust expiration as needed ); // Remove password from the client object before sending response const { password: _, ...clientWithoutPassword } = client; // Return success response with client data and token return NextResponse.json({ message: "Client logged in successfully", user: clientWithoutPassword, // Keep 'user' key for consistency? Or use 'client'? Let's use 'user' for now. token }, { status: 200 }); } catch (error) { console.error("Client Login API error:", error); return NextResponse.json({ message: "An error occurred during login." }, { status: 500 }); } } // app/api/client-auth/signup/route.js import Client from '@/lib/models/Client'; // Use Client model import { handleFileUpload, deleteUploadedFile } from '@/lib/utils/fileUpload'; // Reuse file upload util import { NextResponse } from 'next/server'; import jwt from 'jsonwebtoken'; export const config = { api: { bodyParser: false, // Disable default parsing for FormData }, }; export async function POST(request) { let profilePictureFilename = null; try { const formData = await request.formData(); const username = formData.get('username'); const email = formData.get('email'); const password = formData.get('password'); const profilePictureFile = formData.get('profilePicture'); // Optional picture // Basic Validation if (!username || !email || !password) { return NextResponse.json({ message: 'Username, email, and password are required' }, { status: 400 }); } if (password.length < 6) { return NextResponse.json({ message: 'Password must be at least 6 characters long' }, { status: 400 }); } // Handle optional file upload if (profilePictureFile && profilePictureFile.size > 0) { // Store client profile pics in a subfolder if desired, e.g., 'client-profiles' // Or reuse the main 'uploads' if structure is simple profilePictureFilename = await handleFileUpload(profilePictureFile, 'images'); // Saving to general images for now if (!profilePictureFilename) { return NextResponse.json({ message: "Profile picture upload failed." }, { status: 500 }); } } // Create and save client using the model (handles hashing) const client = new Client(username, email, password, profilePictureFilename); const savedClient = await client.save(); // Uses Client model's save // Generate token upon successful signup const token = jwt.sign( { clientId: savedClient.id, type: 'client' }, // Indicate client type process.env.JWT_SECRET, { expiresIn: '1h' } ); // Return user and token (user object excludes password) return NextResponse.json({ message: 'Client created successfully', user: savedClient, // Send back the created user data token }, { status: 201 }); } catch (error) { console.error("Client Signup API error:", error); // Attempt cleanup if file uploaded before DB error if (profilePictureFilename) { await deleteUploadedFile(profilePictureFilename, 'images'); // Adjust subfolder if needed } if (error.message?.includes('already in use') || error.message?.includes('already taken')) { return NextResponse.json({ message: error.message }, { status: 409 }); // Conflict } return NextResponse.json({ message: "Error creating client account" }, { status: 500 }); } } // app/api/viewing-progress/route.js import pool from '@/lib/database'; import { NextResponse } from 'next/server'; import { verifyToken } from '@/lib/utils/authUtils'; // Assuming this exists // --- POST (Add error handling, keep token check) --- export async function POST(request) { const token = request.headers.get('authorization')?.split(' ')[1]; const decoded = verifyToken(token); if (!decoded || decoded.type !== 'client') { return NextResponse.json({ message: 'Unauthorized: Invalid or missing token' }, { status: 401 }); } const clientId = decoded.clientId; try { const { bookId, chapterId, currentTime } = await request.json(); if (!bookId || !chapterId || currentTime === undefined || currentTime === null || isNaN(parseFloat(currentTime))) { return NextResponse.json({ message: 'Missing or invalid required progress data (bookId, chapterId, currentTime)' }, { status: 400 }); } const sql = ` INSERT INTO user_viewing_progress (client_id, book_id, last_watched_chapter_id, last_watched_timestamp) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE last_watched_chapter_id = VALUES(last_watched_chapter_id), last_watched_timestamp = VALUES(last_watched_timestamp), updated_at = CURRENT_TIMESTAMP; `; await pool.query(sql, [clientId, bookId, chapterId, parseFloat(currentTime)]); return NextResponse.json({ message: 'Progress saved successfully' }, { status: 200 }); } catch (error) { console.error("API Error saving viewing progress:", error); // Handle potential foreign key violations more specifically if needed if (error.code === 'ER_NO_REFERENCED_ROW_2') { return NextResponse.json({ message: 'Invalid book or chapter ID.' }, { status: 400 }); } return NextResponse.json({ message: 'Failed to save progress due to server error' }, { status: 500 }); } } // --- GET (Add duration and improved filtering) --- export async function GET(request) { const token = request.headers.get('authorization')?.split(' ')[1]; const decoded = verifyToken(token); if (!decoded || decoded.type !== 'client') { return NextResponse.json({ message: 'Unauthorized: Invalid or missing token' }, { status: 401 }); } const clientId = decoded.clientId; try { // Get progress, book details, and crucially, the *duration* of the last watched chapter const sql = ` SELECT uvp.book_id, uvp.last_watched_chapter_id, uvp.last_watched_timestamp, uvp.updated_at, bn.book_name, bn.book_author, bn.book_image, bn.book_preference, bn.genre_id, watched_chap.chapter_name as last_watched_chapter_name, watched_chap.chapter_audio_video as last_watched_chapter_media, watched_chap.chapter_description as last_watched_chapter_desc, watched_chap.chapter_image as last_watched_chapter_image, watched_chap.duration as last_watched_chapter_duration -- Get the duration -- We don't strictly need the 'last' chapter ID here anymore for filtering FROM user_viewing_progress uvp JOIN book_names bn ON uvp.book_id = bn.id JOIN book_chapters watched_chap ON uvp.last_watched_chapter_id = watched_chap.id WHERE uvp.client_id = ? ORDER BY uvp.updated_at DESC; `; const [results] = await pool.query(sql, [clientId]); // Filter out finished items const unfinishedItems = results.filter(item => { // If duration is unknown, assume not finished if (item.last_watched_chapter_duration === null || item.last_watched_chapter_duration <= 0) { return true; } // Consider finished if timestamp is within ~5 seconds of the end (or >= 98%) const thresholdSeconds = 5; const percentageThreshold = 0.98; // 98% const timeRemaining = item.last_watched_chapter_duration - item.last_watched_timestamp; const percentageWatched = item.last_watched_timestamp / item.last_watched_chapter_duration; const isFinished = timeRemaining < thresholdSeconds || percentageWatched >= percentageThreshold; // Keep if NOT finished return !isFinished; }); return NextResponse.json(unfinishedItems, { status: 200 }); } catch (error) { console.error("API Error getting viewing progress:", error); return NextResponse.json({ message: 'Failed to fetch progress due to server error' }, { status: 500 }); } } // app/auth/login/page.js 'use client'; import React, { useState, useEffect } from 'react'; import axios from 'axios'; import { useClientAuth } from '@/lib/clientAuth'; // Use Client Auth import { useRouter } from 'next/navigation'; import Link from 'next/link'; import Cookies from 'js-cookie'; // MUI Components (reuse styles from admin or create new ones) import Box from '@mui/material/Box'; import TextField from '@mui/material/TextField'; import Button from '@mui/material/Button'; import Typography from '@mui/material/Typography'; import CircularProgress from '@mui/material/CircularProgress'; import Alert from '@mui/material/Alert'; import FormControlLabel from '@mui/material/FormControlLabel'; import Checkbox from '@mui/material/Checkbox'; import Container from '@mui/material/Container'; import Paper from '@mui/material/Paper'; import Grid from '@mui/material/Grid'; const ClientLoginPage = () => { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [rememberMe, setRememberMe] = useState(false); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const { clientLogin, isClientLoggedIn } = useClientAuth(); // Get client functions const router = useRouter(); // Redirect if already logged in useEffect(() => { if (isClientLoggedIn()) { router.push('/'); // Redirect to client homepage } }, [isClientLoggedIn, router]); // Check for remembered client email useEffect(() => { const storedEmail = Cookies.get('rememberedClientEmail'); // Use different cookie name if (storedEmail) { setEmail(storedEmail); setRememberMe(true); } }, []); const handleSubmit = async (e) => { e.preventDefault(); setError(''); setLoading(true); try { // Call the CLIENT login API const response = await axios.post('/api/client-auth/login', { email, password }); if (response.status === 200) { clientLogin(response.data.user, response.data.token); // Use clientLogin // Handle Remember Me cookie if (rememberMe) { Cookies.set('rememberedClientEmail', email, { expires: 30 }); } else { Cookies.remove('rememberedClientEmail'); } router.push('/'); // Redirect to client homepage } } catch (err) { setError(err.response?.data?.message || 'Login failed. Check credentials.'); } finally { setLoading(false); } }; // --- Render Login Form (similar to admin, adjust text/links) --- return ( Sign In {error && {error}} {/* Email TextField */} setEmail(e.target.value)} InputLabelProps={{ style: { color: '#aaa' } }} sx={{ input: { color: 'white' }, /* ... other sx */ }} /> {/* Password TextField */} setPassword(e.target.value)} InputLabelProps={{ style: { color: '#aaa' } }} sx={{ input: { color: 'white' }, /* ... other sx */ }}/> {/* Remember Me Checkbox */} setRememberMe(e.target.checked)} sx={{ color: '#aaa' }} />} label="Remember me" sx={{ color: '#aaa' }} /> {/* Submit Button */} {/* Links */} {/* Optional Forgot Password Link */} {/* Forgot password? */} New to SkizaFM? Sign up now. {/* Link back to home */} Go back to Home ); }; export default ClientLoginPage; // app/auth/signup/page.js 'use client'; import React, { useState, useEffect } from 'react'; import axios from 'axios'; import { useClientAuth } from '@/lib/clientAuth'; // Use Client Auth import { useRouter } from 'next/navigation'; import Link from 'next/link'; // MUI Components (reuse or customize) import Box from '@mui/material/Box'; import TextField from '@mui/material/TextField'; import Button from '@mui/material/Button'; import Typography from '@mui/material/Typography'; import CircularProgress from '@mui/material/CircularProgress'; import Alert from '@mui/material/Alert'; import Container from '@mui/material/Container'; import Paper from '@mui/material/Paper'; import Grid from '@mui/material/Grid'; import Avatar from '@mui/material/Avatar'; import IconButton from '@mui/material/IconButton'; import PhotoCamera from '@mui/icons-material/PhotoCamera'; const ClientSignupPage = () => { const [username, setUsername] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [profilePicture, setProfilePicture] = useState(null); const [preview, setPreview] = useState(null); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const router = useRouter(); const { isClientLoggedIn, clientLogin } = useClientAuth(); // Get client functions // Redirect if already logged in useEffect(() => { if (isClientLoggedIn()) { router.push('/'); // Redirect to client homepage } }, [isClientLoggedIn, router]); useEffect(() => { if (!profilePicture) { setPreview(null); return; } const objectUrl = URL.createObjectURL(profilePicture); setPreview(objectUrl); return () => URL.revokeObjectURL(objectUrl); }, [profilePicture]); const handleFileChange = (event) => { if (event.target.files && event.target.files[0]) { setProfilePicture(event.target.files[0]); } }; const handleSubmit = async (e) => { e.preventDefault(); setError(''); if (password.length < 6) { setError('Password must be at least 6 characters long.'); return; } if (!username || !email) { setError('Username and Email are required.'); return; } setLoading(true); const formData = new FormData(); formData.append('username', username); formData.append('email', email); formData.append('password', password); if (profilePicture) { formData.append('profilePicture', profilePicture); } try { // Call the CLIENT signup API const response = await axios.post('/api/client-auth/signup', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); if (response.status === 201) { // Automatically log in the user after successful signup clientLogin(response.data.user, response.data.token); router.push('/'); // Redirect to client homepage } } catch (err) { setError(err.response?.data?.message || 'Signup failed. Please try again.'); } finally { setLoading(false); } }; // --- Render Signup Form (similar to admin, adjust text/links) --- return ( Sign Up {error && {error}} {/* Username */} setUsername(e.target.value)} InputLabelProps={{ style: { color: '#aaa' } }} sx={{ input: { color: 'white' }, /* ... sx */ }} /> {/* Email */} setEmail(e.target.value)} InputLabelProps={{ style: { color: '#aaa' } }} sx={{ input: { color: 'white' }, /* ... sx */ }} /> {/* Password */} setPassword(e.target.value)} InputLabelProps={{ style: { color: '#aaa' } }} sx={{ input: { color: 'white' }, /* ... sx */ }} /> {/* Profile Picture Upload */} {preview && } {/* Submit Button */} {/* Links */} Already have an account? Sign in {/* Link back to home */} Go back to Home ); }; export default ClientSignupPage; // app/lib/models/BookChapter.js import pool from '../database'; class BookChapter { // Added duration to constructor constructor(book_name_id, chapter_name, chapter_image_filename, chapter_audio_video_filename, chapter_description, chapter_sequence, duration = null) { this.book_name_id = book_name_id; this.chapter_name = chapter_name; this.chapter_image = chapter_image_filename; this.chapter_audio_video = chapter_audio_video_filename; this.chapter_description = chapter_description; this.chapter_sequence = chapter_sequence; this.duration = duration; // Store duration } // --- getAll --- static async getAll() { // Select duration as well const query = ` SELECT bc.*, bn.book_name FROM book_chapters bc JOIN book_names bn ON bc.book_name_id = bn.id ORDER BY bn.book_name, bc.id `; // ... (error handling remains the same) try { const [rows] = await pool.query(query); return rows; } catch (error) { console.error('Error getting all book chapters:', error); throw error; } } // --- findByBookId --- static async findByBookId(bookId) { // Select duration const query = 'SELECT * FROM book_chapters WHERE book_name_id = ? ORDER BY id'; // ... (error handling remains the same) try { const [rows] = await pool.query(query, [bookId]); return rows; } catch (error) { console.error('Error finding book chapters by book id:', error); throw error; } } // --- findById --- static async findById(id) { // Select duration const query = ` SELECT bc.*, bn.book_name FROM book_chapters bc JOIN book_names bn ON bc.book_name_id = bn.id WHERE bc.id = ? `; // ... (error handling remains the same) try { const [rows] = await pool.query(query, [id]); return rows[0] || null; } catch (error) { console.error('Error finding book chapter by id:', error); throw error; } } // --- findByCriteria --- static async findByCriteria(criteria) { // Select duration let query = ` SELECT bc.*, bn.book_name FROM book_chapters bc JOIN book_names bn ON bc.book_name_id = bn.id `; // ... (rest of the logic remains the same) try { const queryParams = []; const conditions = []; if (criteria.bookId) { conditions.push('bc.book_name_id = ?'); queryParams.push(criteria.bookId); } if (criteria.chapterSequence) { conditions.push('bc.chapter_sequence = ?'); queryParams.push(criteria.chapterSequence); } if (criteria.chapterName) { conditions.push('bc.chapter_name = ?'); queryParams.push(criteria.chapterName); } if (criteria.chapterAudioVideo) { conditions.push('bc.chapter_audio_video = ?'); queryParams.push(criteria.chapterAudioVideo); } if (conditions.length > 0) { query += ' WHERE ' + conditions.join(' AND '); } if (criteria.limit) { query += ' LIMIT ?'; queryParams.push(criteria.limit); } else if (criteria.chapterName && criteria.chapterAudioVideo) { query += ' LIMIT 1'; } if (criteria.orderBy) { query += ` ORDER BY ${criteria.orderBy}`; } else if (criteria.bookId && !criteria.chapterSequence && !criteria.chapterName) { query += ' ORDER BY bc.id'; } const [rows] = await pool.query(query, queryParams); return rows; } catch (error) { console.error('Error finding book chapters by criteria:', error); throw error; } } // --- save --- async save() { if (!pool) throw new Error("Database pool not initialized"); try { // Include duration in insert const [result] = await pool.query( 'INSERT INTO book_chapters (book_name_id, chapter_name, chapter_image, chapter_audio_video, chapter_description, chapter_sequence, duration) VALUES (?, ?, ?, ?, ?, ?, ?)', [this.book_name_id, this.chapter_name, this.chapter_image, this.chapter_audio_video, this.chapter_description, this.chapter_sequence, this.duration] ); return result.insertId; } catch (error) { // ... (error handling remains the same) console.error('Error saving book chapter:', error); if (error.code === 'ER_DUP_ENTRY') throw new Error('Chapter name or sequence may already exist for this book.'); if (error.code === 'ER_NO_REFERENCED_ROW_2') throw new Error('Invalid Book ID provided.'); throw error; } } // --- update --- static async update(id, updates) { if (!pool) throw new Error("Database pool not initialized"); if (Object.keys(updates).length === 0) return 0; try { let updateQuery = 'UPDATE book_chapters SET '; const updateParams = []; // Add duration to allowed fields const allowedFields = [ 'book_name_id', 'chapter_name', 'chapter_image', 'chapter_audio_video', 'chapter_description', 'chapter_sequence', 'duration' ]; allowedFields.forEach(field => { if (updates.hasOwnProperty(field)) { if (updates[field] === null || updates[field] !== undefined) { updateQuery += `${field} = ?, `; updateParams.push(updates[field]); } } }); if (updateParams.length === 0) return 0; updateQuery = updateQuery.slice(0, -2); // Remove trailing ', ' updateQuery += ' WHERE id = ?'; updateParams.push(id); const [result] = await pool.query(updateQuery, updateParams); return result.affectedRows; } catch (error) { // ... (error handling remains the same) console.error('Error updating book chapter:', error); if (error.code === 'ER_DUP_ENTRY') throw new Error('Chapter name or sequence may already exist for this book (during update).'); if (error.code === 'ER_NO_REFERENCED_ROW_2') throw new Error('Invalid Book ID provided during update.'); throw error; } } // --- delete --- (No change needed for duration) static async delete(id) { // ... (delete logic remains the same) if (!pool) throw new Error("Database pool not initialized"); try { const [result] = await pool.query('DELETE FROM book_chapters WHERE id = ?', [id]); return result.affectedRows; } catch (error) { console.error('Error deleting book chapter:', error); throw error; } } } export default BookChapter; // app/lib/models/BookGenre.js import pool from '../database'; class BookGenre { constructor(genre_name, genre_image) { this.genre_name = genre_name; // Name of the genre this.genre_image = genre_image; // Filename of the uploaded image } static async getAll() { if (!pool) throw new Error("Database pool not initialized"); try { const [rows] = await pool.query('SELECT * FROM book_genres ORDER BY genre_name'); // Added ordering return rows; } catch (error) { console.error('Error getting all book genres:', error); throw error; } } static async findById(id) { if (!pool) throw new Error("Database pool not initialized"); try { const [rows] = await pool.query('SELECT * FROM book_genres WHERE id = ?', [id]); return rows[0] || null; } catch(error){ console.error("Error finding bookGenre by id:", error); throw error; } } async save() { if (!pool) throw new Error("Database pool not initialized"); try { const [result] = await pool.query( 'INSERT INTO book_genres (genre_name, genre_image) VALUES (?, ?)', [this.genre_name, this.genre_image] ); return result.insertId; // Return the ID of the newly inserted genre } catch (error) { console.error('Error saving book genre:', error); if (error.code === 'ER_DUP_ENTRY') { throw new Error('Genre name already exists.'); } throw error; } } static async update(id, updates) { if (!pool) throw new Error("Database pool not initialized"); if (Object.keys(updates).length === 0) { console.log("No updates provided for BookGenre. Returning 0 rows affected."); return 0; // No updates to perform } try { let updateQuery = 'UPDATE book_genres SET '; const updateParams = []; const allowedFields = ['genre_name', 'genre_image']; // Fields allowed to update // Build query dynamically and safely allowedFields.forEach(field => { if (updates.hasOwnProperty(field)) { updateQuery += `${field} = ?, `; updateParams.push(updates[field]); } }); // Only proceed if there are valid fields to update if (updateParams.length === 0) { console.log("No valid fields provided for BookGenre update. Returning 0 rows affected."); return 0; } // Remove the last comma and space updateQuery = updateQuery.slice(0, -2); updateQuery += ' WHERE id = ?'; updateParams.push(id); const [result] = await pool.query(updateQuery, updateParams); return result.affectedRows; // Number of rows updated } catch (error) { console.error('Error updating book genre:', error); if (error.code === 'ER_DUP_ENTRY') { throw new Error('Genre name already exists.'); } throw error; } } static async delete(id) { // Note: Consider implications - what happens to books in this genre? // You might need to handle foreign key constraints or update related books. if (!pool) throw new Error("Database pool not initialized"); try { // TODO: Add logic here to potentially delete associated image file // const genre = await this.findById(id); // if (genre && genre.genre_image) { // await deleteUploadedFile(genre.genre_image, 'images'); // Assuming 'images' subfolder // } const [result] = await pool.query('DELETE FROM book_genres WHERE id = ?', [id]); return result.affectedRows; // Number of rows deleted } catch (error) { console.error('Error deleting book genre:', error); // Handle foreign key constraint errors if books depend on this genre if (error.code === 'ER_ROW_IS_REFERENCED_2') { throw new Error('Cannot delete genre because books are associated with it.'); } throw error; } } } export default BookGenre; // app/lib/models/BookName.js import pool from '../database'; class BookName { // Constructor expects filenames for image/trailer constructor(book_name, book_image_filename, trailer_filename, genre_id, book_description, book_author, book_preference = 'none') { this.book_name = book_name; this.book_image = book_image_filename; this.trailer = trailer_filename; this.genre_id = genre_id; this.book_description = book_description; this.book_author = book_author; this.book_preference = book_preference; // 'none', 'top', 'featured' } static async getAll() { if (!pool) throw new Error("Database pool not initialized"); try { // Join with genres to get genre name const [rows] = await pool.query(` SELECT bn.*, bg.genre_name FROM book_names bn LEFT JOIN book_genres bg ON bn.genre_id = bg.id ORDER BY bn.book_name `); return rows; } catch (error) { console.error('Error getting all book names:', error); throw error; } } static async findById(id) { if (!pool) throw new Error("Database pool not initialized"); try { // Join with genres to get genre name const [rows] = await pool.query(` SELECT bn.*, bg.genre_name FROM book_names bn LEFT JOIN book_genres bg ON bn.genre_id = bg.id WHERE bn.id = ? `, [id]); return rows[0] || null; } catch (error) { console.error('Error finding book name by id:', error); throw error; } } async save() { if (!pool) throw new Error("Database pool not initialized"); try { const [result] = await pool.query( 'INSERT INTO book_names (book_name, book_image, trailer, genre_id, book_description, book_author, book_preference) VALUES (?, ?, ?, ?, ?, ?, ?)', [this.book_name, this.book_image, this.trailer, this.genre_id, this.book_description, this.book_author, this.book_preference] ); return result.insertId; } catch (error) { console.error('Error saving book name:', error); if (error.code === 'ER_DUP_ENTRY') { throw new Error('Book name already exists.'); // Assuming book_name has a UNIQUE index } if (error.code === 'ER_NO_REFERENCED_ROW_2') { throw new Error('Invalid Genre ID provided.'); } throw error; } } static async update(id, updates) { if (!pool) throw new Error("Database pool not initialized"); if (Object.keys(updates).length === 0) { console.log("No updates provided for BookName. Returning 0 rows affected."); return 0; } try { let updateQuery = 'UPDATE book_names SET '; const updateParams = []; // Define allowed fields for security const allowedFields = ['book_name', 'book_image', 'trailer', 'genre_id', 'book_description', 'book_author', 'book_preference']; allowedFields.forEach(field => { if (updates.hasOwnProperty(field)) { // Special handling for NULL values if needed (e.g., when deleting trailer/image) if (updates[field] === null || updates[field] !== undefined) { updateQuery += `${field} = ?, `; updateParams.push(updates[field]); } } }); if (updateParams.length === 0) { console.log("No valid fields provided for BookName update. Returning 0 rows affected."); return 0; } // Remove the last comma and space updateQuery = updateQuery.slice(0, -2); updateQuery += ' WHERE id = ?'; updateParams.push(id); const [result] = await pool.query(updateQuery, updateParams); return result.affectedRows; } catch (error) { console.error('Error updating book name:', error); if (error.code === 'ER_DUP_ENTRY') { throw new Error('Book name already exists.'); } if (error.code === 'ER_NO_REFERENCED_ROW_2') { throw new Error('Invalid Genre ID provided for update.'); } throw error; } } static async delete(id) { // Note: Deleting a book might require deleting associated chapters first // depending on foreign key constraints (ON DELETE CASCADE?) if (!pool) throw new Error("Database pool not initialized"); try { // TODO: Add logic to delete associated files (image, trailer) // const book = await this.findById(id); // if (book?.book_image) await deleteUploadedFile(book.book_image, 'images'); // if (book?.trailer) await deleteUploadedFile(book.trailer, 'videos'); // TODO: Add logic to delete associated book chapters or handle constraints // await pool.query('DELETE FROM book_chapters WHERE book_name_id = ?', [id]); const [result] = await pool.query('DELETE FROM book_names WHERE id = ?', [id]); return result.affectedRows; } catch (error) { console.error('Error deleting book name:', error); // Handle foreign key errors if chapters depend on this book if (error.code === 'ER_ROW_IS_REFERENCED_2') { throw new Error('Cannot delete book because chapters are associated with it. Delete chapters first.'); } throw error; } } } export default BookName; // app/lib/models/Client.js import pool from '../database'; import bcrypt from 'bcrypt'; class Client { constructor(username, email, password, profilePictureFilename) { this.username = username; this.email = email; this.password = password; this.profilePicture = profilePictureFilename; // Store the filename } static async findByEmail(email) { if (!pool) throw new Error("Database pool not initialized"); try { // Query the 'clients' table const [rows] = await pool.query('SELECT * FROM clients WHERE email = ?', [email]); return rows[0] || null; } catch (error) { console.error("Error finding client by email:", error); throw error; } } static async findById(id) { if (!pool) throw new Error("Database pool not initialized"); try { // Query the 'clients' table, exclude password const [rows] = await pool.query('SELECT id, username, email, profile_picture, created_at FROM clients WHERE id = ?', [id]); return rows[0] || null; } catch (error) { console.error("Error finding client by id:", error); throw error; } } async save() { if (!pool) throw new Error("Database pool not initialized"); try { // Hash password before saving const hashedPassword = await bcrypt.hash(this.password, 10); // Insert into the 'clients' table const [result] = await pool.query( 'INSERT INTO clients (username, email, password, profile_picture) VALUES (?, ?, ?, ?)', [this.username, this.email, hashedPassword, this.profilePicture] ); // Get the newly inserted client data (excluding password) const [rows] = await pool.query('SELECT id, username, email, profile_picture, created_at FROM clients WHERE id = ?', [result.insertId]); return rows[0]; // Return the created client object } catch (error) { console.error("Error saving client:", error); if (error.code === 'ER_DUP_ENTRY') { // Check which unique key caused the error if (error.sqlMessage.includes(`for key 'clients.email'`)) { throw new Error('Email address already in use by another client.'); } else if (error.sqlMessage.includes(`for key 'clients.username'`)) { throw new Error('Username already taken by another client.'); } else { throw new Error('Duplicate entry error.'); } } throw error; // Re-throw other errors } } // Add static update/delete methods if needed later } export default Client; // app/lib/models/User.js import pool from '../database'; // Correct path to pool import bcrypt from 'bcrypt'; // Note: Path is no longer needed as file uploads handled in API routes class User { constructor(username, email, password, profilePictureFilename) { // Store only filename this.username = username; this.email = email; this.password = password; this.profilePicture = profilePictureFilename; // Store the generated filename } static async findByEmail(email) { if (!pool) throw new Error("Database pool not initialized"); try { const [rows] = await pool.query('SELECT * FROM users WHERE email = ?', [email]); return rows[0] || null; } catch (error) { console.error("Error finding user by email:", error); throw error; } } static async findById(id) { // Added findById often useful if (!pool) throw new Error("Database pool not initialized"); try { const [rows] = await pool.query('SELECT id, username, email, profile_picture, created_at FROM users WHERE id = ?', [id]); return rows[0] || null; } catch (error) { console.error("Error finding user by id:", error); throw error; } } async save() { if (!pool) throw new Error("Database pool not initialized"); try { // Hash password before saving const hashedPassword = await bcrypt.hash(this.password, 10); // Salt rounds = 10 const [result] = await pool.query( 'INSERT INTO users (username, email, password, profile_picture) VALUES (?, ?, ?, ?)', [this.username, this.email, hashedPassword, this.profilePicture] ); // Get the newly inserted user data (excluding password) const [rows] = await pool.query('SELECT id, username, email, profile_picture, created_at FROM users WHERE id = ?', [result.insertId]); return rows[0]; // Return the created user object } catch (error) { console.error("Error saving user:", error); // Check for duplicate email error (ER_DUP_ENTRY usually) if (error.code === 'ER_DUP_ENTRY') { throw new Error('Email address already in use.'); } throw error; // Re-throw other errors } } // Static update and delete methods could also be added here // Or handled directly in the API routes as in the original code } export default User; // app/lib/store/bookStore.js import { create } from 'zustand'; import axios from 'axios'; export const useBookStore = create((set) => ({ books: [], isLoading: false, error: null, fetchBooks: async () => { set({ isLoading: true, error: null }); // Set loading state try { // Use internal API route const response = await axios.get(`/api/book-names`); set({ books: response.data, isLoading: false }); } catch (error) { console.error('Error fetching books:', error); const errorMessage = error.response?.data?.message || 'Failed to fetch books'; set({ error: errorMessage, isLoading: false }); } }, // Optional: Add functions to add/update/delete books from store if needed // addBook: (newBook) => set((state) => ({ books: [...state.books, newBook] })), // updateBook: (updatedBook) => set((state) => ({ // books: state.books.map(book => book.id === updatedBook.id ? updatedBook : book) // })), // removeBook: (bookId) => set((state) => ({ // books: state.books.filter(book => book.id !== bookId) // })), })); // app/lib/store/chapterStore.js import { create } from 'zustand'; import axios from 'axios'; export const useChapterStore = create((set) => ({ chapters: [], isLoading: false, error: null, fetchChapters: async (bookId = null) => { // Allow fetching all or by book ID set({ isLoading: true, error: null }); try { const url = bookId ? `/api/book-chapters?bookId=${bookId}` : '/api/book-chapters'; const response = await axios.get(url); set({ chapters: response.data, isLoading: false }); } catch (error) { console.error('Error fetching chapters:', error); const errorMessage = error.response?.data?.message || 'Failed to fetch chapters'; set({ error: errorMessage, isLoading: false }); } }, // Optional: Add/update/remove functions similar to bookStore })); // app/lib/store/genreStore.js import { create } from 'zustand'; import axios from 'axios'; export const useGenreStore = create((set) => ({ genres: [], isLoading: false, error: null, fetchGenres: async () => { set({ isLoading: true, error: null }); try { // Use internal API route const response = await axios.get(`/api/book-genres`); set({ genres: response.data, isLoading: false }); } catch (error) { console.error('Error fetching genres:', error); const errorMessage = error.response?.data?.message || 'Failed to fetch genres'; set({ error: errorMessage, isLoading: false }); } }, // Optional: Add/update/remove functions similar to bookStore })); // app/lib/store/uiStore.js import { create } from 'zustand'; export const useUIStore = create((set) => ({ // State: null, 'genre', 'book', 'search' modalType: null, // Data needed for the specific modal modalData: null, // Actions openGenreModal: (genreId) => set({ modalType: 'genre', modalData: { genreId } }), openBookModal: (book) => set({ modalType: 'book', modalData: { book } }), openSearchModal: (searchTerm) => set({ modalType: 'search', modalData: { searchTerm } }), closeModal: () => set({ modalType: null, modalData: null }), })); // app/lib/ThemeRegistry/EmotionCache.js 'use client'; // Mark this as a Client Component as well, since it interacts with client-side cache import * as React from 'react'; import createCache from '@emotion/cache'; import { useServerInsertedHTML } from 'next/navigation'; // Hook from Next.js import { CacheProvider as EmotionCacheProvider } from '@emotion/react'; // Renamed to avoid conflict // This implementation is taken directly from the MUI documentation // for integrating with the Next.js App Router. // https://mui.com/material-ui/guides/next-js-app-router/ export default function NextAppDirEmotionCacheProvider(props) { const { options, CacheProvider = EmotionCacheProvider, children } = props; // Use state to manage the cache instance const [registry] = React.useState(() => { const cache = createCache(options); // Create the Emotion cache instance cache.compat = true; // Enable compatibility mode if needed const prevInsert = cache.insert; // Store the original insert method let inserted = []; // Array to track inserted styles // Override the insert method to track styles on the server cache.insert = (...args) => { const serialized = args[1]; if (cache.inserted[serialized.name] === undefined) { inserted.push(serialized.name); } return prevInsert(...args); // Call the original insert method }; // Function to flush inserted styles const flush = () => { const prevInserted = inserted; inserted = []; // Clear the tracked styles return prevInserted; // Return the flushed styles }; return { cache, flush }; // Return the cache instance and flush function }); // Hook to insert styles into the HTML head on the server useServerInsertedHTML(() => { const names = registry.flush(); // Get the flushed style names if (names.length === 0) { return null; // Return null if no styles were flushed } let styles = ''; // Generate style tags for the flushed styles for (const name of names) { styles += registry.cache.inserted[name]; } // Return style tags to be inserted into the head return (