// 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 (
);
};
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 (
);
};
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 (
);
};
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 */}
{/* Content Overlay */}
{currentBook.book_name}
{currentBook.book_description}
}
onClick={handlePlayChapter}
sx={{ bgcolor: 'white', color: 'black', '&:hover': { bgcolor: 'rgba(255, 255, 255, 0.8)' } }}
>
Play First Chapter
}
onClick={handleMoreInfoClick}
sx={{ bgcolor: 'rgba(109, 109, 110, 0.7)', color: 'white', '&:hover': { bgcolor: 'rgba(109, 109, 110, 0.4)' } }}
>
More Info
{/* Controls Overlay */}
{/* Previous Button */}
{/* Play/Pause Button (Centered - Optional) */}
{/*
{isPlaying ? : }
*/}
{/* Next Button */}
{/* Mute/Unmute Button (Bottom Right) */}
{isMuted ? : }
{/* Modal Rendering is handled by the parent (Home) component */}
{/* {selectedBook && setSelectedBook(null)} />} */}
);
};
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
<>
>
) : (
// 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 */}
} // Optional: Icon indicating return
sx={{
position: 'absolute', // Position it independently
top: 16,
left: 16,
}}
>
Go to Client Site
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 */}
}
disabled={loading}
sx={{color:'#aaa', borderColor:'#aaa', '&:hover':{borderColor:'white', color:'white'}}}
>
Upload Picture (Optional)
{/* 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
);
};
// --- 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 */}
} onClick={handleAddAdminClick}>
Add Admin
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 } }}
/>
} onClick={fetchUsers} disabled={loading}>
Refresh
{/* Add Admin Modal */}
{/* Delete Confirmation Dialog (already uses MUI Dialog - responsive) */}
{/* Users Table */}
{/* TableContainer handles horizontal scroll on small screens */}
{/* 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
);
};
// --- 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 */}
} onClick={handleAddClick}>Add Chapter
setSearchTerm(e.target.value)}
InputProps={{
startAdornment: (
),
}}
sx={{ flexGrow: { xs: 1, sm: 0 }, width: { xs: '100%', sm: 300, md: 400 } }}
/>
} onClick={fetchData} disabled={loading}>Refresh
{/* Add/Edit Chapter Modal */}
{/* Delete Confirmation */}
{/* 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 (
);
};
// --- 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 */}
} onClick={handleAddClick}>
Add Genre
setSearchTerm(e.target.value)}
InputProps={{
startAdornment: (
),
}}
sx={{ flexGrow: { xs: 1, sm: 0 }, width: { xs: '100%', sm: 250, md: 300 } }}
/>
} onClick={fetchGenres} disabled={loading}>
Refresh
{/* Add/Edit Genre Modal */}
{/* Delete Confirmation Dialog */}
{/* 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
);
};
// --- 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 */}
} onClick={handleAddClick}>Add Book
setSearchTerm(e.target.value)}
InputProps={{
startAdornment: (
),
}}
sx={{ flexGrow: { xs: 1, sm: 0 }, width: { xs: '100%', sm: 300, md: 400 } }}
/>
} onClick={fetchBookNamesAndGenres} disabled={loading}>Refresh
{/* Add/Edit Book Modal (Multi-Step) */}
{/* Delete Confirmation */}
{/* 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}${tag}>${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 (
);
};
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 (
);
};
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}${tag}>${afterText}`);
textarea.focus();
setTimeout(() => {
if (textarea) {
textarea.setSelectionRange(start + `<${tag}>`.length, end + `<${tag}>`.length);
}
}, 0);
};
return (
);
};
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 (
);
};
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 */}
} disabled={loading} sx={{color:'#aaa', borderColor:'#aaa', '&:hover':{borderColor:'white', color:'white'}}}>
Upload Picture (Optional)
{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 (
);
});
// Provide the cache instance to children using CacheProvider
return {children};
}
// app/lib/ThemeRegistry/theme.js
import { createTheme } from '@mui/material/styles';
import { red } from '@mui/material/colors';
// Create a theme instance.
const theme = createTheme({
palette: {
mode: 'dark', // Netflix-like dark mode
primary: {
main: '#E50914', // Netflix Red
},
secondary: {
main: '#ffffff', // White for secondary elements/text
},
background: {
default: '#141414', // Very dark grey/black
paper: '#1f1f1f', // Slightly lighter for paper elements (cards, modals)
},
text: {
primary: '#ffffff', // White text
secondary: '#b3b3b3', // Grey text for less emphasis
},
error: {
main: red.A400,
},
// Add other customizations if needed
action: {
active: '#ffffff',
hover: 'rgba(255, 255, 255, 0.08)',
selected: 'rgba(255, 255, 255, 0.16)',
disabled: 'rgba(255, 255, 255, 0.3)',
disabledBackground: 'rgba(255, 255, 255, 0.12)',
focus: 'rgba(255, 255, 255, 0.12)',
},
},
typography: {
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif', // Example font stack
// Define other typography variants if needed
},
components: {
// Example: Customize Button globally
MuiButton: {
styleOverrides: {
root: {
textTransform: 'none', // Prevent uppercase buttons
},
},
},
MuiAppBar: {
styleOverrides: {
colorPrimary: {
backgroundColor: '#141414', // Ensure AppBar matches default background
}
}
}
// Customize other components (TextField, Card, etc.)
}
});
export default theme;
// app/lib/ThemeRegistry/ThemeRegistry.js
'use client'; // <--- THE FIX: Mark this component as a Client Component
import * as React from 'react';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import NextAppDirEmotionCacheProvider from './EmotionCache'; // Assuming EmotionCache.js exists as per MUI docs
import theme from './theme'; // Your theme definition
export default function ThemeRegistry({ children }) {
return (
// Emotion Cache Provider for MUI + App Router compatibility
{/* MUI Theme Provider - Needs to run client-side */}
{/* CssBaseline kickstarts an elegant, consistent, and simple baseline to build upon. */}
{/* Render the rest of the application */}
{children}
);
}
// app/lib/utils/authUtils.js (Create this file if it doesn't exist)
import jwt from 'jsonwebtoken';
export function verifyToken(token) {
if (!token) return null;
try {
return jwt.verify(token, process.env.JWT_SECRET);
} catch (error) {
console.error("Token verification failed:", error.message);
return null;
}
}
// app/lib/utils/fileUpload.js
import { writeFile, unlink, mkdir } from 'fs/promises';
import path from 'path';
import ffmpeg from 'fluent-ffmpeg';
// Optional: If ffmpeg/ffprobe aren't in PATH, set their paths explicitly
// import ffmpegPath from '@ffmpeg-installer/ffmpeg';
// import ffprobePath from '@ffprobe-installer/ffprobe';
// ffmpeg.setFfmpegPath(ffmpegPath.path);
// ffmpeg.setFfprobePath(ffprobePath.path);
async function ensureDirExists(dirPath) {
try {
await mkdir(dirPath, { recursive: true });
} catch (error) {
if (error.code !== 'EEXIST') {
console.error(`Error creating directory ${dirPath}:`, error);
throw error;
}
}
}
/**
* Extracts media duration using ffprobe.
* @param {string} filePath - Absolute path to the media file.
* @returns {Promise} Duration in seconds, or null on error.
*/
function getMediaDuration(filePath) {
return new Promise((resolve, reject) => {
ffmpeg.ffprobe(filePath, (err, metadata) => {
if (err) {
console.error(`ffprobe error for ${filePath}:`, err.message);
// Don't reject, just resolve with null if duration can't be found
resolve(null);
// reject(new Error(`ffprobe failed: ${err.message}`));
} else {
resolve(metadata.format.duration ? parseFloat(metadata.format.duration) : null);
}
});
});
}
/**
* Handles file upload and extracts duration for media files.
* @param {File} file - The file object from FormData.
* @param {string} subfolder - The subfolder within public/uploads.
* @returns {Promise<{filename: string, duration: number|null}|null>} Object with filename and duration, or null on failure.
*/
export async function handleFileUpload(file, subfolder) {
if (!file || typeof file.arrayBuffer !== 'function') {
console.warn("handleFileUpload: Invalid file object received.");
return null;
}
const targetDir = path.join(process.cwd(), 'public', 'uploads', subfolder);
let uploadPath = ''; // Keep track of the path for cleanup/duration check
try {
await ensureDirExists(targetDir);
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const originalName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_');
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const fileExtension = path.extname(originalName) || path.extname(file.name);
const filename = path.basename(originalName, fileExtension) + '-' + uniqueSuffix + fileExtension;
uploadPath = path.join(targetDir, filename); // Assign full path
await writeFile(uploadPath, buffer);
console.log(`File uploaded: ${subfolder}/${filename}`);
let duration = null;
// Check if it's likely an audio/video file before probing
const mediaExtensions = ['.mp3', '.wav', '.ogg', '.aac', '.m4a', '.mp4', '.mov', '.avi', '.webm', '.mkv'];
if (subfolder === 'audio-video' || mediaExtensions.includes(fileExtension.toLowerCase())) {
console.log(`Probing duration for: ${uploadPath}`);
duration = await getMediaDuration(uploadPath);
console.log(`Detected duration: ${duration} seconds`);
}
return { filename, duration }; // Return object with filename and duration
} catch (error) {
console.error(`Error processing file upload to ${subfolder}:`, error);
// Attempt to clean up partially uploaded file if path exists
if (uploadPath) {
try { await unlink(uploadPath); } catch (cleanupError) { /* Ignore cleanup error */ }
}
return null;
}
}
// deleteUploadedFile remains the same
export async function deleteUploadedFile(filename, subfolder) {
if (!filename || subfolder === undefined || subfolder === null) { // Check subfolder presence
console.warn("deleteUploadedFile: Filename or subfolder missing or invalid.", { filename, subfolder });
return false;
}
const filePath = path.join(process.cwd(), 'public', 'uploads', subfolder, filename);
try {
await unlink(filePath);
console.log(`File deleted successfully: ${subfolder}/${filename}`);
return true;
} catch (error) {
if (error.code === 'ENOENT') {
console.warn(`File not found for deletion: ${subfolder}/${filename}`);
return true;
}
console.error(`Error deleting file ${subfolder}/${filename}:`, error);
return false;
}
}
// app/lib/auth.js
"use client";
import React, { createContext, useState, useEffect, useContext } from 'react';
import Cookies from 'js-cookie'; // Use js-cookie for client-side cookie handling
const AuthContext = createContext();
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [token, setToken] = useState(null);
const [loading, setLoading] = useState(true); // Add loading state
useEffect(() => {
// Check for token and user in localStorage/cookies on initial load
const storedToken = localStorage.getItem('token');
const storedUser = localStorage.getItem('user');
if (storedToken && storedUser) {
setToken(storedToken);
try {
setUser(JSON.parse(storedUser));
} catch (err) {
console.error("Error parsing user from localStorage:", err);
// Clear invalid data if parsing fails
localStorage.removeItem('token');
localStorage.removeItem('user');
}
}
setLoading(false); // Finished loading initial auth state
}, []);
const login = (userData, token) => {
console.log("AuthContext: Logging in user:", userData);
setUser(userData);
setToken(token);
localStorage.setItem('token', token);
localStorage.setItem('user', JSON.stringify(userData));
// Optionally set a cookie for the token if needed for server-side checks
// Cookies.set('authToken', token, { expires: 1/24 }); // Example: 1 hour expiry
};
const logout = () => {
console.log("AuthContext: Logging out user");
setUser(null);
setToken(null);
localStorage.removeItem('token');
localStorage.removeItem('user');
Cookies.remove('authToken'); // Remove token cookie if set
// Optionally redirect here or let the consuming component handle it
};
const isLoggedIn = () => !!token && !!user;
const value = {
user,
token,
loading, // Provide loading state
login,
logout,
isLoggedIn
};
return (
{/* Render children only after initial auth state is loaded */}
{!loading && children}
);
}
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
// app/lib/clientAuth.js
"use client"; // Essential for context and hooks
import React, { createContext, useState, useEffect, useContext } from 'react';
import Cookies from 'js-cookie'; // Client-side library
// Initialize context with undefined for better error checking in the hook
const ClientAuthContext = createContext(undefined);
// Export the Provider component
export function ClientAuthProvider({ children }) {
const [clientUser, setClientUser] = useState(null);
const [clientToken, setClientToken] = useState(null);
const [clientLoading, setClientLoading] = useState(true); // Start loading
useEffect(() => {
// Check local storage on mount
const storedToken = localStorage.getItem('clientToken');
const storedUser = localStorage.getItem('clientUser');
if (storedToken && storedUser) {
setClientToken(storedToken);
try {
setClientUser(JSON.parse(storedUser));
} catch (err) {
console.error("Error parsing client user from localStorage:", err);
// Clear invalid stored data
localStorage.removeItem('clientToken');
localStorage.removeItem('clientUser');
}
}
// Mark loading as complete after checking storage
setClientLoading(false);
}, []);
// Login function: updates state and local storage
const clientLogin = (userData, token) => {
console.log("ClientAuthContext: Logging in client:", userData);
setClientUser(userData);
setClientToken(token);
localStorage.setItem('clientToken', token);
localStorage.setItem('clientUser', JSON.stringify(userData));
};
// Logout function: clears state and local storage
const clientLogout = () => {
console.log("ClientAuthContext: Logging out client");
setClientUser(null);
setClientToken(null);
localStorage.removeItem('clientToken');
localStorage.removeItem('clientUser');
};
// Helper function to check login status
const isClientLoggedIn = () => !!clientToken && !!clientUser;
// Value provided by the context
const value = {
clientUser,
clientToken,
clientLoading, // Provide loading state
clientLogin,
clientLogout,
isClientLoggedIn
};
return (
{/* Don't render children until loading is complete */}
{!clientLoading ? children : null}
);
}
// Export the custom hook to consume the context
export const useClientAuth = () => {
const context = useContext(ClientAuthContext);
// Ensure the hook is used within a provider
if (context === undefined) {
throw new Error('useClientAuth must be used within a ClientAuthProvider');
}
return context;
};
// app/lib/database.js
// Using import syntax is preferred in Next.js
import mysql from 'mysql2/promise';
// Load environment variables - Next.js handles .env.local automatically
// require('dotenv').config(); // Not typically needed directly in Next.js
let pool;
try {
pool = mysql.createPool({
host: process.env.DATABASE_HOST,
user: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD,
database: process.env.DATABASE_NAME,
waitForConnections: true,
connectionLimit: 10, // Adjust as needed
queueLimit: 0,
// Recommended: Add connection timeout
connectTimeout: 10000 // 10 seconds
});
// Optional: Test connection on startup (can add logging)
pool.getConnection()
.then(connection => {
console.log("Database connected successfully!");
connection.release();
})
.catch(err => {
console.error("Database connection failed on startup:", err.message);
// Consider how to handle startup failure (e.g., log and continue, or exit)
});
} catch (error) {
console.error("Failed to create database pool:", error);
// Handle pool creation error (e.g., exit process)
process.exit(1); // Exit if pool creation fails critically
}
// Function to get a connection (useful for transactions)
export async function getConnection() {
if (!pool) {
throw new Error("Database pool is not initialized.");
}
try {
const connection = await pool.getConnection();
return connection;
} catch (error) {
console.error("Error getting database connection:", error);
throw error; // Re-throw the error
}
}
// Export the pool for direct querying
export default pool;
/*app/global.css*/
/* Remove Tailwind directives */
/* @tailwind base; */
/* @tailwind components; */
/* @tailwind utilities; */
body {
margin: 0;
padding: 0;
box-sizing: border-box;
/* Background color will be handled by MUI CssBaseline and Theme */
}
/* Optional: Global scrollbar styling (example) */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #2e2e2e;
}
::-webkit-scrollbar-thumb {
background: #555;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #777;
}
/* Fix for background scroll when MUI modal is open */
body.modal-open {
overflow: hidden;
}
// app/layout.js
import { Inter } from 'next/font/google';
import ThemeRegistry from './lib/ThemeRegistry/ThemeRegistry';
import { AuthProvider } from './lib/auth';
import { ClientAuthProvider } from './lib/clientAuth'; // <--- Import ClientAuthProvider
const inter = Inter({ subsets: ['latin'] });
export const metadata = {
title: 'SkizaFM',
description: 'Kenyan AudioBook App & Admin Panel',
};
export default function RootLayout({ children }) {
return (
{/* Wrap everything with both providers */}
{/* Order doesn't strictly matter here unless one depends on the other */}
{/* <--- Add ClientAuthProvider here */}
{/* MUI Setup */}
{children}
{/* <--- Close ClientAuthProvider */}
);
}
/*env.local*/
# Database Configuration
DATABASE_HOST=localhost
DATABASE_USER=root
DATABASE_PASSWORD=
DATABASE_NAME=skizafm_admin # Make sure this DB exists
# JWT Configuration
JWT_SECRET=b9e0552296674cee0053e8eb345b9793691e9e1af5f6273addf99038a5ed3a1b # Use the output of generateSecret.js or a new one
# Email Configuration (for Nodemailer)
EMAIL_USER=morrisndurereofficial@gmail.com
EMAIL_PASSWORD=@Ndureremorris93! # Use App Password for Gmail if 2FA is on
SMTP_HOST=smtp.gmail.com
SMTP_PORT=465
SMTP_SECURE=true # Should be 'true' for port 465
# Frontend URL (for email links, adapt as needed for Next.js routing)
# Might point to specific reset password page within the admin route group
FRONTEND_URL=http://localhost:3000/admin/auth # Example
# NODE_ENV=development # Optional: Next.js usually handles this
// app/api/auth/login/route.js
import pool from '@/lib/database';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import User from '@/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/admin/components/AdminAccounts.js
'use client';
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { useAuth } from '@/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
);
};
// --- 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 */}
} onClick={handleAddAdminClick}>
Add Admin
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 } }}
/>
} onClick={fetchUsers} disabled={loading}>
Refresh
{/* Add Admin Modal */}
{/* Delete Confirmation Dialog (already uses MUI Dialog - responsive) */}
{/* Users Table */}
{/* TableContainer handles horizontal scroll on small screens */}
{/* Optional: Add TablePagination component here if needed */}
);
};
export default AdminAccounts;
// 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();
// --- MODIFICATION: Get clientLogout function ---
const { clientUser, clientToken, isClientLoggedIn, clientLogout } = useClientAuth();
const [continueWatchingList, setContinueWatchingList] = useState([]);
const [isLoadingProgress, setIsLoadingProgress] = useState(false);
const [progressError, setProgressError] = useState(null);
const featuredBooks = booksLoading ? [] : books.filter(book => book.book_preference === 'featured');
const otherBooks = booksLoading ? [] : books.filter(book => book.book_preference !== 'top' && book.book_preference !== 'featured');
useEffect(() => {
fetchBooks();
fetchGenres();
}, [fetchBooks, fetchGenres]);
useEffect(() => {
const fetchProgress = async () => {
if (isClientLoggedIn() && clientToken) {
setIsLoadingProgress(true);
setProgressError(null);
try {
console.log('Attempting to fetch progress with token:', clientToken); // Debug log
const response = await axios.get('/api/viewing-progress', {
headers: { Authorization: `Bearer ${clientToken}` }
});
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([]);
// --- MODIFICATION: Handle 401 Unauthorized ---
if (err.response?.status === 401) {
console.warn("Unauthorized (401) fetching progress. Logging out client.");
setProgressError("Your session has expired. Please log in again."); // User-friendly message
clientLogout(); // Log the user out automatically
// Optionally redirect to login page:
// router.push('/auth/login');
}
// --- END MODIFICATION ---
} finally {
setIsLoadingProgress(false);
}
} else {
setContinueWatchingList([]);
setIsLoadingProgress(false);
setProgressError(null);
}
};
fetchProgress();
// --- MODIFICATION: Add clientLogout to dependency array if used in cleanup (not needed here but good practice if it were) ---
}, [isClientLoggedIn, clientToken, clientLogout, router]); // Added clientLogout and router
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);
return;
}
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);
};
const initialLoading = booksLoading || genresLoading;
const initialError = booksError || genresError;
return (
{/* Hero Section */}
{!initialLoading && !initialError && }
{initialLoading && }
{initialError && !initialLoading && Error loading initial data: {initialError}}
{/* Continue Watching Section */}
{/* --- MODIFICATION: Check isClientLoggedIn() again before rendering to reflect logout state --- */}
{isClientLoggedIn() && !isLoadingProgress && continueWatchingList.length > 0 && (
)}
{isClientLoggedIn() && isLoadingProgress && }
{/* Show progress error even if logged out now due to 401 */}
{!isLoadingProgress && progressError && Could not load progress: {progressError}}
{/* --- END MODIFICATION --- */}
{/* Regular Card Sections */}
{!initialLoading && !initialError && (
<>
{featuredBooks.length > 0 && ( )}
{otherBooks.length > 0 && ( )}
{genres.slice(0, 3).map(genre => {
const genreBooks = books ? 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/(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';
import Tooltip from '@mui/material/Tooltip'; // Added Tooltip for longer text
// --- HELPER FUNCTIONS ---
// Keep or move formatTimeShort to a utils file
function formatTimeShort(totalSeconds) {
if (totalSeconds == null || totalSeconds < 0) return '';
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = Math.floor(totalSeconds % 60);
if (hours > 0) {
return `${hours}h ${minutes}m`; // Simplified for cards
} else if (minutes > 0) {
return `${minutes}m ${seconds}s`;
} else {
return `${seconds}s`;
}
}
// --- STYLED COMPONENTS ---
// Overlay covers the entire card, becomes visible on hover
const CardOverlay = styled(Box)(({ theme }) => ({
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.7)', // Dark overlay
color: 'white',
opacity: 0, // Hidden by default
transition: 'opacity 0.3s ease-in-out',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: theme.spacing(1), // Add some padding
textAlign: 'center',
borderRadius: 'inherit', // Match card's border radius
pointerEvents: 'none', // Allow clicks to pass through unless hovering card
}));
// Progress info positioned at the bottom
const ProgressInfoBox = styled(Box)(({ theme }) => ({
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: 'rgba(0, 0, 0, 0.75)', // Slightly darker background for progress
color: 'white',
padding: theme.spacing(0.5, 1), // Vertical padding 0.5, horizontal 1
borderBottomLeftRadius: theme.shape.borderRadius, // Match card radius
borderBottomRightRadius: theme.shape.borderRadius,
zIndex: 2, // Ensure it's above the image but below overlay on hover
minHeight: '35px', // Give it a minimum height to contain text+progress
display: 'flex',
flexDirection: 'column',
justifyContent: 'center', // Center content vertically
}));
// --- END STYLED COMPONENTS ---
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';
let progressPercent = 0;
if (isProgressCard && chapterDuration && chapterDuration > 0 && progressTimestamp) {
progressPercent = Math.min(100, Math.max(0, (progressTimestamp / chapterDuration) * 100));
}
const formattedTime = isProgressCard && progressTimestamp != null ? formatTimeShort(progressTimestamp) : null;
const displayChapterName = isProgressCard ? progressChapterName || 'Chapter' : ''; // Default if name missing
// Truncate long chapter names for display
const truncatedChapterName = displayChapterName.length > 20 ? displayChapterName.substring(0, 18) + '...' : displayChapterName;
return (
theme.shadows[8], // Add shadow on hover
'& .card-overlay': { // Show overlay on hover
opacity: 1,
pointerEvents: 'auto', // Enable interaction with overlay content if needed
},
},
bgcolor: 'background.paper', // Use theme background
}}
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
elevation={2} // Base elevation
>
{/* --- Enforce Image Aspect Ratio and Fit --- */}
{/* Hover Overlay */}
{book.book_name}
{/* Show author only if NOT a progress card OR if author exists */}
{(!isProgressCard || book.book_author) && (
{book.book_author || ''}
)}
{/* Progress Info (only if isProgressCard is true) */}
{isProgressCard && (
{/* Tooltip for full chapter name if truncated */}
{/* Display truncated name */}
{truncatedChapterName ? `Ch: ${truncatedChapterName}` : ''}{formattedTime ? ` @ ${formattedTime}` : ''}
)}
);
};
export default BookCard;
// 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'; // Ensure path is correct
// formatTimeShort function definition (or import from utils)
function formatTimeShort(totalSeconds) {
if (totalSeconds == null || totalSeconds < 0) return '';
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = Math.floor(totalSeconds % 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
} else if (minutes > 0) {
return `${minutes}m ${seconds}s`;
} else {
return `${seconds}s`;
}
}
const CardSection = ({ title, books = [], onCardClick, isProgressSection = false }) => {
const scrollContainerRef = useRef(null);
const scroll = (direction) => {
if (scrollContainerRef.current) {
const { current } = scrollContainerRef;
// Calculate scroll amount (e.g., 80% of visible width)
const scrollAmount = current.clientWidth * 0.8;
current.scrollBy({
left: direction === 'left' ? -scrollAmount : scrollAmount,
behavior: 'smooth',
});
}
};
if (!books || books.length === 0) return null;
return (
// Add bottom margin to separate sections
{/* Section Title */}
{title}
{/* Container for Buttons and Scrollable Area */}
{/* Scroll Buttons (Appear on hover over the section on larger screens) */}
scroll('left')}
sx={{
position: 'absolute',
left: 0,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 20, // Ensure buttons are above cards
bgcolor: 'rgba(0, 0, 0, 0.5)',
color: 'white',
'&:hover': { bgcolor: 'rgba(0, 0, 0, 0.7)' },
// Hide on small screens, maybe show on hover for larger ones
display: { xs: 'none', md: 'inline-flex' },
opacity: 0, // Hidden by default
transition: 'opacity 0.3s ease',
// Show buttons when hovering the parent Box
'.MuiBox-root:hover > &': {
opacity: 1,
},
}}
aria-label="scroll left"
size="large"
>
scroll('right')}
sx={{
position: 'absolute',
right: 0,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 20,
bgcolor: 'rgba(0, 0, 0, 0.5)',
color: 'white',
'&:hover': { bgcolor: 'rgba(0, 0, 0, 0.7)' },
display: { xs: 'none', md: 'inline-flex' },
opacity: 0, // Hidden by default
transition: 'opacity 0.3s ease',
// Show buttons when hovering the parent Box
'.MuiBox-root:hover > &': {
opacity: 1,
},
}}
aria-label="scroll right"
size="large"
>
{/* Scrollable Card Container */}
{/* Map through books and render BookCard */}
{books.map((item) => {
// Determine the unique key - crucial for React lists
// For progress items, book_id exists. For regular books, use id.
const key = isProgressSection ? item.book_id : item.id;
// Pass the correct book object (progress items might have book data nested)
const bookData = isProgressSection ? item : item; // Adjust if progress structure is different
return (
onCardClick(item)} // Pass the original item to handler
isProgressCard={isProgressSection}
progressTimestamp={isProgressSection ? item.last_watched_timestamp : undefined}
progressChapterName={isProgressSection ? item.last_watched_chapter_name : undefined}
chapterDuration={isProgressSection ? item.last_watched_chapter_duration : undefined}
/>
);
})}
{/* Optional: Add a spacer at the end if needed, though padding usually suffices */}
{/* */}
);
};
export default CardSection;
// app/api/book-names/route.js
import pool from '@/lib/database'; // Might not be needed if model handles all
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 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 });
}
}
//the following is a next-js app called skiza-fm-admin. Please puyt in your memory every coding file, and evry coding structure.
Please dont spew out any code just note evrything in yout memory. I will ask you questions later from it.
To show that you have understood my instructions, please show me the file structure of this app.